From ff44d36c7e63216947b6c81b9de621c1d908c856 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:32:35 -0700 Subject: [PATCH 001/233] Harden divider resize cancellation (#7039) --- .../src/lib/pane-manager/pane-divider-drag.ts | 31 +-- .../src/lib/pane-manager/pane-divider.test.ts | 185 +++++++++++++++++- 2 files changed, 196 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index c2c701bee7d..0b552ccc4ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -158,7 +158,7 @@ export function attachDividerDrag( if (shouldRefit && nextEl) { callbacks.refitPanesUnder(nextEl) } - if (shouldRefit) { + if (commitLayout && shouldRefit) { releasePtyResizeHold?.flush() } else { releasePtyResizeHold?.cancel() @@ -179,6 +179,21 @@ export function attachDividerDrag( e.preventDefault() flexScheduler.cancel() finishActiveDrag(false) + const previousPane = divider.previousElementSibling as HTMLElement | null + const nextPane = divider.nextElementSibling as HTMLElement | null + if (!previousPane || !nextPane) { + return + } + + const prevRect = previousPane.getBoundingClientRect() + const nextRect = nextPane.getBoundingClientRect() + const prevSize = isVertical ? prevRect.width : prevRect.height + const nextSize = isVertical ? nextRect.width : nextRect.height + const measuredTotalSize = prevSize + nextSize + if (!Number.isFinite(measuredTotalSize) || measuredTotalSize <= 0) { + return + } + divider.setPointerCapture(e.pointerId) activePointerId = e.pointerId divider.classList.add('is-dragging') @@ -188,12 +203,8 @@ export function attachDividerDrag( addWindowListeners() startPos = isVertical ? e.clientX : e.clientY - prevEl = divider.previousElementSibling as HTMLElement | null - nextEl = divider.nextElementSibling as HTMLElement | null - - if (!prevEl || !nextEl) { - return - } + prevEl = previousPane + nextEl = nextPane prevInitialFlex = prevEl.style.flex nextInitialFlex = nextEl.style.flex @@ -201,11 +212,7 @@ export function attachDividerDrag( // we still fit xterm locally, but forward only the final PTY size on drop. releasePtyResizeHold = holdPtyResizesForPaneSubtrees([prevEl, nextEl]) - const prevRect = prevEl.getBoundingClientRect() - const nextRect = nextEl.getBoundingClientRect() - const prevSize = isVertical ? prevRect.width : prevRect.height - const nextSize = isVertical ? nextRect.width : nextRect.height - totalSize = prevSize + nextSize + totalSize = measuredTotalSize prevFlex = prevSize } diff --git a/src/renderer/src/lib/pane-manager/pane-divider.test.ts b/src/renderer/src/lib/pane-manager/pane-divider.test.ts index 59d9f878f84..04475662b66 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createDivider, createDividerFlexFrameScheduler, disposeDivider } from './pane-divider' +import { queuePanePtyResizeIfHeld } from './pane-pty-resize-hold' afterEach(() => { vi.unstubAllGlobals() @@ -47,6 +48,94 @@ describe('createDividerFlexFrameScheduler', () => { }) describe('disposeDivider', () => { + it('does not start divider drag state without resizeable pane siblings', () => { + const dividerListeners = new Map() + const onDragActiveChange = vi.fn() + const preventDefault = vi.fn() + const divider = { + style: { + setProperty: vi.fn() + }, + classList: { + add: vi.fn(), + remove: vi.fn() + }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn(), + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => false), + releasePointerCapture: vi.fn(), + previousElementSibling: null, + nextElementSibling: null + } as unknown as HTMLElement + vi.stubGlobal('document', { + createElement: vi.fn(() => divider) + }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('requestAnimationFrame', vi.fn()) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onDragActiveChange }) + dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 7, clientX: 10, preventDefault }) + ) + + expect(preventDefault).toHaveBeenCalled() + expect(divider.setPointerCapture).not.toHaveBeenCalled() + expect(divider.classList.add).not.toHaveBeenCalled() + expect(onDragActiveChange).not.toHaveBeenCalled() + expect(window.addEventListener).not.toHaveBeenCalled() + }) + + it('does not start divider drag state when sibling panes have no measurable size', () => { + const dividerListeners = new Map() + const onDragActiveChange = vi.fn() + const previousPane = createSizedPaneElement({ width: 0, height: 200 }) + const nextPane = createSizedPaneElement({ width: 0, height: 200 }) + const divider = { + style: { + setProperty: vi.fn() + }, + classList: { + add: vi.fn(), + remove: vi.fn() + }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn(), + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => false), + releasePointerCapture: vi.fn(), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { + createElement: vi.fn(() => divider) + }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('requestAnimationFrame', vi.fn()) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onDragActiveChange }) + dividerListeners.get('pointerdown')?.(createPointerEvent({ pointerId: 7, clientX: 10 })) + + expect(divider.setPointerCapture).not.toHaveBeenCalled() + expect(divider.classList.add).not.toHaveBeenCalled() + expect(onDragActiveChange).not.toHaveBeenCalled() + expect(window.addEventListener).not.toHaveBeenCalled() + expect(previousPane.style.flex).toBeUndefined() + expect(nextPane.style.flex).toBeUndefined() + }) + it('finishes an active resize from a window-level pointerup', () => { const dividerListeners = new Map() const windowListeners = new Map() @@ -251,8 +340,75 @@ describe('disposeDivider', () => { expect(onLayoutChanged).not.toHaveBeenCalled() }) + it('drops held PTY resize updates when an active resize is cancelled', () => { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const previousPane = createSizedPaneElement( + { width: 100, height: 200 }, + { classNames: ['pane'] } + ) + const nextPane = createSizedPaneElement({ width: 300, height: 200 }, { classNames: ['pane'] }) + const divider = { + style: { + setProperty: vi.fn() + }, + classList: { + add: vi.fn(), + remove: vi.fn() + }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn(), + setPointerCapture: vi.fn((pointerId: number) => { + capturedPointerIds.add(pointerId) + }), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => { + capturedPointerIds.delete(pointerId) + }), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { + createElement: vi.fn(() => divider) + }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn() + }) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 7) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged: vi.fn() }) + dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100, clientY: 0 }) + ) + + expect(queuePanePtyResizeIfHeld(previousPane, 140, 30)).toBe(true) + expect(queuePanePtyResizeIfHeld(nextPane, 80, 30)).toBe(true) + + windowListeners.get('pointermove')?.( + createPointerEvent({ pointerId: 9, clientX: 180, clientY: 0 }) + ) + windowListeners.get('pointercancel')?.(createPointerEvent({ pointerId: 9 })) + + expect(previousPane.dispatchEvent).not.toHaveBeenCalled() + expect(nextPane.dispatchEvent).not.toHaveBeenCalled() + expect(queuePanePtyResizeIfHeld(previousPane, 140, 30)).toBe(false) + expect(queuePanePtyResizeIfHeld(nextPane, 80, 30)).toBe(false) + }) + it('removes divider-local drag listeners and releases active pointer capture', () => { const listeners = new Map() + const previousPane = createSizedPaneElement({ width: 100, height: 200 }) + const nextPane = createSizedPaneElement({ width: 300, height: 200 }) const divider = { style: { setProperty: vi.fn() @@ -272,8 +428,8 @@ describe('disposeDivider', () => { setPointerCapture: vi.fn(), hasPointerCapture: vi.fn(() => true), releasePointerCapture: vi.fn(), - previousElementSibling: null, - nextElementSibling: null + previousElementSibling: previousPane, + nextElementSibling: nextPane } as unknown as HTMLElement vi.stubGlobal('document', { createElement: vi.fn(() => divider) @@ -311,15 +467,25 @@ function createPointerEvent(args: Partial): PointerEvent { } as unknown as PointerEvent } -function createSizedPaneElement(rect: { - width: number - height: number -}): HTMLElement & { style: Record } { +function createSizedPaneElement( + rect: { + width: number + height: number + }, + options?: { + classNames?: string[] + } +): HTMLElement & { + dispatchEvent: ReturnType + style: Record +} { + const classNames = new Set(options?.classNames ?? []) return { style: {}, classList: { - contains: vi.fn(() => false) + contains: vi.fn((className: string) => classNames.has(className)) }, + dispatchEvent: vi.fn(() => true), getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, @@ -329,5 +495,8 @@ function createSizedPaneElement(rect: { height: rect.height })), querySelectorAll: vi.fn(() => []) - } as unknown as HTMLElement & { style: Record } + } as unknown as HTMLElement & { + dispatchEvent: ReturnType + style: Record + } } From e0a7f0eadde38255f06651cf9599a1a93118d490 Mon Sep 17 00:00:00 2001 From: Eddie Jaoude Date: Wed, 1 Jul 2026 21:17:52 +0100 Subject: [PATCH 002/233] fix: reordered default columns in kanban board (#6934) * fix: reordered default columns in kanban board * Introduce dedicated flag to repair reversed default workspace statuses - Add `_workspaceStatusesReorderedDefaultRepaired` to decouple the one-shot repair from the initial status order migration. - Ensure the repair runs for users who saved the reversed default payload (with "Done" on the left) during a short-lived broken build. - Support both "Completed" and "Done" labels when identifying default status shapes to migrate or repair. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/persistence.test.ts | 87 +++++++++++++------ src/main/persistence.ts | 11 ++- src/main/runtime/rpc/methods/client-ui.ts | 1 + src/shared/constants.ts | 1 + src/shared/types.ts | 8 +- .../workspace-status-default-migration.ts | 13 +-- src/shared/workspace-status-defaults.ts | 6 +- src/shared/workspace-statuses.test.ts | 33 ++++++- src/shared/workspace-statuses.ts | 2 +- 9 files changed, 115 insertions(+), 47 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5b6585a86bf..376466450c4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -71,13 +71,7 @@ const REORDERED_DEFAULT_WORKSPACE_STATUSES = [ }, { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } ] -const LEGACY_DEFAULT_WORKSPACE_STATUSES = [ - { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, - { id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }, - { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, - { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } -] -const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ +const REORDERED_DONE_DEFAULT_WORKSPACE_STATUSES = [ { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' }, { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, { @@ -88,6 +82,23 @@ const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ }, { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } ] +const LEGACY_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, + { id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }, + { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, + { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } +] +const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }, + { + id: 'in-progress', + label: 'In progress', + color: 'conductor-progress', + icon: 'conductor-progress' + }, + { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, + { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' } +] const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), @@ -4993,34 +5004,55 @@ describe('Store', () => { const store = await createStore() const ui = store.getUI() expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([ - 'completed', - 'in-review', + 'todo', 'in-progress', - 'todo' + 'in-review', + 'completed' ]) - expect(ui.workspaceStatuses?.[0]?.label).toBe('Done') + expect(ui.workspaceStatuses?.at(-1)?.label).toBe('Done') expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true) expect(ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) store.flush() - const persisted = readDataFile() as { - ui?: { - workspaceStatuses?: typeof REORDERED_DEFAULT_WORKSPACE_STATUSES - _workspaceStatusesDefaultOrderMigrated?: boolean - _workspaceStatusesDefaultWorkflowMigrated?: boolean - _workspaceStatusesDefaultVisualsMigrated?: boolean - } - } - expect(persisted.ui?._workspaceStatusesDefaultOrderMigrated).toBe(true) - expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) - expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) - expect(persisted.ui?.workspaceStatuses?.map((status) => status.id)).toEqual([ - 'completed', - 'in-review', + const persisted = readDataFile() as PersistedState + expect(persisted.ui._workspaceStatusesDefaultOrderMigrated).toBe(true) + expect(persisted.ui._workspaceStatusesReorderedDefaultRepaired).toBe(true) + expect(persisted.ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) + expect(persisted.ui._workspaceStatusesDefaultVisualsMigrated).toBe(true) + expect(persisted.ui.workspaceStatuses?.map((status) => status.id)).toEqual([ + 'todo', 'in-progress', - 'todo' + 'in-review', + 'completed' ]) - expect(persisted.ui?.workspaceStatuses?.[0]?.label).toBe('Done') + expect(persisted.ui.workspaceStatuses?.at(-1)?.label).toBe('Done') + }) + + it('repairs the known-bad reordered default statuses after old migration flags are set', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + workspaceStatuses: REORDERED_DONE_DEFAULT_WORKSPACE_STATUSES, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([ + 'todo', + 'in-progress', + 'in-review', + 'completed' + ]) + expect(store.getUI().workspaceStatuses?.at(-1)?.label).toBe('Done') + expect(store.getUI()._workspaceStatusesReorderedDefaultRepaired).toBe(true) }) it('migrates legacy default workspace status visuals and workflow once on load', async () => { @@ -5085,6 +5117,7 @@ describe('Store', () => { ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true }, githubCache: { pr: {}, issue: {} }, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 44b774e619d..8469f6b7250 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2990,8 +2990,11 @@ export class Store { } const workspaceStatusesDefaultOrderMigrated = parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: the default workflow changed to Done -> Review -> Progress -> Todo. - // Only exact legacy default payloads are migrated; users who + // Why: a short-lived default put Done on the left. Repair only + // the exact raw payload once; user-authored reorders then survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads are migrated; users who // customized status labels, colors, icons, or order keep theirs. const workspaceStatusesDefaultWorkflowMigrated = parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true @@ -3003,12 +3006,13 @@ export class Store { parsed.ui?.workspaceStatuses, { migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated } ) if ( !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || !workspaceStatusesDefaultWorkflowMigrated || !workspaceStatusesDefaultVisualsMigrated ) { @@ -3106,6 +3110,7 @@ export class Store { ), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, _sortBySmartMigrated: true, diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 78e2440819c..a18dfb3bd0f 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -184,6 +184,7 @@ const UiUpdate = z workspaceBoardColumnWidth: z.number().finite().optional(), syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), + _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), statusBarItems: z.array(StatusBarItem).optional(), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c8e60b3eb4..0cf42920411 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -476,6 +476,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceBoardColumnWidth: 308, syncTaskStatusFromWorkspaceBoard: false, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], diff --git a/src/shared/types.ts b/src/shared/types.ts index 1247d7dc449..60d47108280 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3191,9 +3191,11 @@ export type PersistedUIState = { * default workspace statuses in reverse workflow order. Once stamped, * user-authored status ordering is never inferred from IDs/labels again. */ _workspaceStatusesDefaultOrderMigrated?: boolean - /** One-shot migration flag for the default status workflow order/label: - * Done -> In review -> In progress -> Todo. Exact legacy default payloads - * migrate; customized statuses are preserved. */ + /** One-shot repair flag for the exact default payload that a short-lived + * build persisted in reverse workflow order. */ + _workspaceStatusesReorderedDefaultRepaired?: boolean + /** One-shot migration flag for default status workflow labels/visuals. + * Exact legacy default payloads migrate; customized statuses are preserved. */ _workspaceStatusesDefaultWorkflowMigrated?: boolean /** One-shot migration flag for the old default blue/violet/emerald status * visuals. Once stamped, valid user-authored colors/icons are preserved. */ diff --git a/src/shared/workspace-status-default-migration.ts b/src/shared/workspace-status-default-migration.ts index 6e56f8b5da0..f6d02f67c85 100644 --- a/src/shared/workspace-status-default-migration.ts +++ b/src/shared/workspace-status-default-migration.ts @@ -1,8 +1,9 @@ -const LEGACY_DEFAULT_STATUS_LABELS: Record = { - todo: 'Todo', - 'in-progress': 'In progress', - 'in-review': 'In review', - completed: 'Completed' +const DEFAULT_STATUS_LABELS: Record = { + todo: ['Todo'], + 'in-progress': ['In progress'], + 'in-review': ['In review'], + // Why: both labels have shipped as defaults; either raw shape is safe to migrate. + completed: ['Completed', 'Done'] } const CONDUCTOR_DEFAULT_STATUS_VISUALS: Record = { @@ -45,7 +46,7 @@ function isLegacyDefaultStatusPayload( return ( Object.keys(raw).length === 4 && raw.id === expectedId && - raw.label === LEGACY_DEFAULT_STATUS_LABELS[expectedId] && + DEFAULT_STATUS_LABELS[expectedId]?.includes(raw.label as string) === true && raw.color === expectedVisual?.color && raw.icon === expectedVisual?.icon ) diff --git a/src/shared/workspace-status-defaults.ts b/src/shared/workspace-status-defaults.ts index a698c9a374e..f0c4f10e426 100644 --- a/src/shared/workspace-status-defaults.ts +++ b/src/shared/workspace-status-defaults.ts @@ -8,13 +8,13 @@ export const DEFAULT_STATUS_VISUALS: Record { it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ - 'completed', - 'in-review', + 'todo', 'in-progress', - 'todo' + 'in-review', + 'completed' ]) - expect(cloneDefaultWorkspaceStatuses()[0]).toMatchObject({ id: 'completed', label: 'Done' }) + expect(cloneDefaultWorkspaceStatuses()[0]).toMatchObject({ id: 'todo', label: 'Todo' }) + expect(cloneDefaultWorkspaceStatuses().at(-1)).toMatchObject({ id: 'completed', label: 'Done' }) }) it('migrates legacy default statuses to the default workflow order', () => { @@ -161,6 +162,30 @@ describe('workspace status visuals', () => { expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) }) + it('repairs the exact reordered default status payload with the Done label', () => { + const statuses = normalizePersistedWorkspaceStatuses( + [ + { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' }, + { + id: 'in-review', + label: 'In review', + color: 'conductor-review', + icon: 'conductor-review' + }, + { + id: 'in-progress', + label: 'In progress', + color: 'conductor-progress', + icon: 'conductor-progress' + }, + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } + ], + { repairReorderedDefaultStatuses: true } + ) + + expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) + }) + it('does not repair reordered default-label statuses with a different raw shape', () => { const statuses = normalizePersistedWorkspaceStatuses( [ diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 08bc54c9ed1..b3aa587043b 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -214,7 +214,7 @@ export function normalizePersistedWorkspaceStatuses( ) { return cloneDefaultWorkspaceStatuses() } - // Why: this PR briefly wrote the default columns in reverse workflow order. + // Why: a previous build briefly wrote the default columns in reverse order. // The repair is one-shot and checks the raw payload, because normalized // IDs/labels are indistinguishable from a user-authored column reorder. if ( From 8afa84af82e63ca6386d4ea2ac65914ea9267c0e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:19:50 -0700 Subject: [PATCH 003/233] Fix terminal switch input lag from daemon session listing (#7002) Co-authored-by: Orca --- ...erminal-switch-typing-lag-investigation.md | 362 ++++++++++++++++++ src/main/daemon/terminal-host.test.ts | 21 +- src/main/daemon/terminal-host.ts | 6 +- src/main/ipc/pty.test.ts | 95 +++++ src/main/ipc/pty.ts | 18 + src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../terminal-pane/pty-connection.test.ts | 174 ++++++--- .../terminal-pane/pty-connection.ts | 101 +++-- .../terminal-dead-session-reconcile.test.ts | 76 ++++ .../terminal-dead-session-reconcile.ts | 34 ++ .../use-terminal-pane-lifecycle.test.ts | 56 +-- .../use-terminal-pane-lifecycle.ts | 43 +-- src/renderer/src/web/web-preload-api.ts | 1 + 14 files changed, 786 insertions(+), 203 deletions(-) create mode 100644 docs/reference/terminal-switch-typing-lag-investigation.md diff --git a/docs/reference/terminal-switch-typing-lag-investigation.md b/docs/reference/terminal-switch-typing-lag-investigation.md new file mode 100644 index 00000000000..d8f6da5a608 --- /dev/null +++ b/docs/reference/terminal-switch-typing-lag-investigation.md @@ -0,0 +1,362 @@ +# Terminal Switch Typing Lag Investigation + +Date: 2026-07-01 + +## Scope + +This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile. + +The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles. + +## Current Reproduction Setup + +- Main packaged Orca app is used for the meaningful repro. +- The main profile had roughly 150 terminals when the lag reproduced. +- The current worktree under test is `/Users/jinwoohong/orca/workspaces/orca/osprey`. +- The main app did not expose CDP, so browser-level renderer profiling was limited. +- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal. +- Harness path: `.tmp/terminal-main-app-typing-lag/probe.mjs` +- The strongest repro switches through an old, output-rich workspace before typing in the probe terminal. +- Current deterministic alternate terminal: + `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. + +Useful harness modes: + +```sh +ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +``` + +## Key Measurements + +### 2026-07-01 follow-up: cold visit vs warm revisit + +After the metadata-only `listSessions()` fix, the user reported a sharper +pattern: + +- First visit to a terminal, where the terminal briefly shows blank while the + renderer/webgl surface loads, does not show the typing lag. +- Revisiting that same already-mounted terminal brings the lag back. + +Code-level reproduction: + +- `connectPanePty` previously armed a one-shot input liveness check from + `noteVisibilityResume()`. +- The first `xterm.onData` input after a warm visibility resume called + `window.api.pty.listSessions()` before forwarding the byte with + `transport.sendInput(data)`. +- A focused unit regression captured the bad order as + `["listSessions", "sendInput"]`. + +Fix direction: + +- Terminal input no longer starts a renderer→main→daemon session enumeration. +- Hidden→visible lifecycle reconciliation and daemon missing-session exit events + remain responsible for stale pane cleanup. +- The focused regression now asserts that repeated warm resumes plus typing + produce zero `listSessions()` calls from the input handler. + +### 2026-07-01 follow-up: preserved old daemon after input fix + +After the input-handler fix, the main packaged profile still reproduced the +warm-switch lag because the live v18 daemon was preserved from an older build: + +- Fresh main-profile repro: + `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json` +- Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms. +- Receipt latency after the daemon write returned stayed low at 77-85 ms. +- No-switch control: + `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.json` + ended with fast direct daemon writes once the probe terminal settled. + +The remaining source hot path was the visibility-resume dead-session sweep: + +1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect. +2. The effect scheduled `reconcileDeadSessions`. +3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`. +4. A preserved old daemon still implemented `listSessions` by snapshotting every + live session, so the user's next daemon `write` queued behind that work. + +Fix: + +- Replace the automatic visibility-resume `listSessions()` sweep with a targeted + single-session liveness check. +- Main exposes `pty:hasPty(id)`, which reads provider-owned in-memory PTY state + and returns `null` when the provider cannot answer authoritatively. +- Renderer visible-resume asks only about each mounted pane's current PTY id. + The pane tears down only on an authoritative `false`; `true`, `null`, rejected + checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open. +- Keep visibility resume process tracking and PTY-size reassertion intact. +- This preserves the recovery added by `a9ef6f916` for panes that missed + `pty:exit` while hidden, without putting a daemon-wide session enumeration on + the warm-switch/input path. + +Verification: + +- Focused vitest suite: `508` tests passed across terminal lifecycle, + pty-connection, dead-session reconcile, and PTY IPC. +- Headful fullscreen E2E harness: + `tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.ts` + wraps main-process `pty:listSessions` with an 800 ms stall and then switches + warm workspaces and types into the terminal. +- Latest E2E artifact: + `.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.json` + showed `fullscreen: true`, `listSessionCallCount: 0`, and + `postTypeEchoLatencyMs: 10`. + +### CLI `terminal-send`, switch away/back + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json` + +- Average echo latency was about 1841 ms. +- Receipt latency was roughly 836-1445 ms. + +### CLI `terminal-send`, no switch + +- Average echo latency was about 312 ms. + +### CLI `terminal-send`, switch away/back, 1000 ms settle before send + +- Average echo latency was about 574 ms. +- Receipt latency was mostly 1-227 ms, with one run around 684 ms. +- This suggests the problematic window is bounded and concentrated immediately after switch/resume. + +### Direct daemon request, switch away/back + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json` + +- The harness opened its own daemon control socket and sent the real daemon `write` request directly. +- Daemon write response time was roughly 947-1142 ms. +- Receipt latency after the daemon write response was about 76 ms. +- Average echo latency was about 1385 ms. + +### Direct daemon request, no switch + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json` + +- Daemon write response time was 0-45 ms. +- Receipt latency was roughly 76-85 ms. +- Average echo latency was about 258 ms. + +### Direct daemon request, output-rich workspace switch + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json` + +- The alternate terminal was `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. +- All four runs delayed. +- Direct daemon `write` response times were 1566, 1527, 1477, and 1333 ms. +- Average echo latency was about 1790 ms. +- A daemon CPU sample was captured at `.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt`. + +Latest reproduced run: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json` + +- Two runs after switching through `triage-issues`. +- Direct daemon `write` response times were 1556 and 1582 ms. +- Receipt latency after the daemon write response was about 76-77 ms. +- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly. + +### Direct daemon ping loop during switch + +A direct daemon client sent `ping` requests every roughly 50 ms while the harness issued `orca terminal switch` away and back. + +- Pings before and after the switch were effectively immediate. +- One ping sent around 469 ms after switch start waited 1212 ms. +- No terminal input was involved in this probe. + +This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger. + +### Direct daemon request, no switch + +Latest no-switch result: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json` + +- First run had a direct daemon `write` response of 571 ms, likely a new-probe/settling artifact. +- Next two runs were 0-1 ms. +- No-switch is generally fast once the probe terminal is settled. + +### Pause after switching away + +- Adding `ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500` before switching back made direct daemon writes fast again. +- This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window. + +Latest pause-control result: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json` + +- Direct daemon `write` response times were 0, 0, and 0 ms. +- Echo latency was about 343-363 ms. +- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace. + +### Synthetic heavy terminals + +- Synthetic split panes with large scrollback were not enough to reproduce reliably. +- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast. +- A synthetic TUI repaint split stayed fast. +- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect. + +### Output timestamp check + +- `lastOutputAt` did not advance when switching through the `triage-issues` terminal and away. +- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst. +- The delay can happen without fresh PTY output from the child process. + +### Snapshot and resize controls + +Direct `getSnapshot` probes on real sessions were much cheaper than the observed lag: + +- Output-rich `triage` terminal: about 10 ms, roughly 122 KB response. +- Active `osprey` Codex terminal: about 57 ms, roughly 804 KB response. + +Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap: + +- Background 80x24 resize pulse: about 2 ms. +- Focused 232x86 resize pulse: about 2 ms. + +These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon. + +### Daemon `listSessions` proof + +The daemon-only measurement identified the blocking request: + +```json +[ + { "type": "ping", "elapsedMs": 0 }, + { "type": "listSessions", "elapsedMs": 552, "count": 137 }, + { "type": "ping", "elapsedMs": 0 }, + { "type": "listSessions", "elapsedMs": 547, "count": 137 }, + { "type": "ping", "elapsedMs": 0 } +] +``` + +A second daemon-only queue test sent two `listSessions` requests and then a `ping` from another client: + +```json +{ + "totalMs": 1069, + "listSessions1Ms": 1068, + "listSessions2Ms": 1068, + "pingBehindListSessionsMs": 1034, + "sessions": 137 +} +``` + +This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time `listSessions` waits about one second. + +Source cause: + +- `TerminalHost.listSessions()` loops every live daemon session. +- For each session, it calls `session.getSnapshot()` only to read `cols` and `rows`. +- `getSnapshot()` serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile. +- Renderer visibility resume calls `window.api.pty.listSessions()` for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds. + +## What This Rules Out + +- It is probably not keyboard focus. Direct daemon writes reproduce the delay. +- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes. +- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low. +- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay. +- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching. +- It is unlikely to be a single normal `getSnapshot` or `resize` call, because direct controls for those operations are much cheaper than the observed stall. +- The resume-time dead-pane recovery is still necessary; the hot path should + use single-PTY liveness, not a global session list. + +## Current Conclusion + +Switching workspaces or terminals can create a short daemon/main busy window +when warm resume triggers global session enumeration. During that window, even a +direct terminal `write` request waits before the daemon services it. Once the +daemon services the write, the shell receives and processes the bytes quickly. + +The confirmed root for the remaining warm-switch lag is the visibility-resume +dead-session reconciliation path calling global `listSessions()` in profiles +with many preserved daemon sessions. The original dead-session recovery is valid; +the expensive primitive was the problem. + +## Leading Hypotheses + +1. Confirmed: resume-time dead-session reconciliation called daemon + `listSessions`, and older daemons synchronously snapshot every live session + to return cols/rows. +2. Confirmed: avoiding global `listSessions()` on warm resume removes the + request that queued ahead of first post-switch input. +3. Possible secondary contributor: switching to certain old or output-rich + workspaces may also reattach existing daemon-backed PTYs. The daemon + `createOrAttach` path synchronously calls `existing.getSnapshot()` before + responding. +4. Possible secondary contributor: hidden-output recovery, pending-output + draining, or another snapshot-like serialization path runs synchronously on + resume and delays request handling. +5. Less likely: workspace or terminal resume triggers visible-terminal + resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt` + observation currently makes this less likely than `listSessions`. + +## Relevant Code Areas + +- CLI terminal send: `src/cli/handlers/terminal.ts` +- Runtime terminal send/focus/write: `src/main/runtime/orca-runtime.ts` +- Runtime PTY data handling: `onPtyData`, `trackHeadlessTerminalData`, hidden-output serialization in `src/main/runtime/orca-runtime.ts` +- Daemon request routing: `src/main/daemon/daemon-server.ts` +- Daemon session write/resize/output handling: `src/main/daemon/session.ts` +- Daemon-side headless terminal state: `src/main/daemon/headless-emulator.ts` +- Daemon adapter: `src/main/daemon/daemon-pty-adapter.ts` +- Main IPC PTY controller: `src/main/ipc/pty.ts` +- Renderer terminal resume: `src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts` +- Renderer PTY connection and resume size reassertion: `src/renderer/src/components/terminal-pane/pty-connection.ts` +- Renderer output scheduler: `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` +- Existing-session reattach snapshot: `TerminalHost.createOrAttach` in `src/main/daemon/terminal-host.ts` +- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/main/daemon/session.ts` +- Snapshot cost benchmark: `src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts` + +## Current Root-Cause Theory + +The root cause is using global session enumeration as a per-pane liveness check: + +1. Workspace switching makes a terminal pane visible again. +2. The renderer schedules dead-session reconciliation on hidden-to-visible resume. +3. The old reconcile path called `window.api.pty.listSessions()`, which reaches + the daemon `listSessions` RPC. +4. Older preserved daemons implement `TerminalHost.listSessions()` by calling + `session.getSnapshot()` for every live session. +5. With a heavy profile, those snapshot serializations block the daemon event + loop for about 550 ms per list. +6. A switch away/back can put a `write` behind two resume-time lists, yielding + about 1.0-1.6 seconds of delayed input. + +This theory fits the current evidence: + +- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing. +- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck. +- No-switch writes are fast. +- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast. +- `lastOutputAt` does not advance, so the child process probably is not generating the expensive work. +- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count. + +The correct fix is two-layered: + +1. Keep daemon `listSessions` metadata-only for builds where callers genuinely + need the global session list. +2. Do not use `listSessions` for warm-resume dead-pane recovery. Use + `pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider + in-memory state. Close only on authoritative `false`; fail open on `true`, + `null`, unsupported providers, remote-runtime/SSH ids, and stale/newborn + races. + +## Investigation Constraints + +- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval. +- Use throwaway terminals for probes and close them afterward. +- Keep temp harnesses and screenshots out of commits unless explicitly requested. +- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem. + +## Verification Plan + +1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`. +2. Unit-test that targeted liveness still closes a missing local PTY and fails + open for live/unknown/stale cases. +3. Unit-test that terminal input and warm resume do not call `listSessions()`. +4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions` + artificially delayed and assert the count stays zero. +5. Re-run the main-app direct-daemon switch/no-switch harness when validating + against the user's heavy profile. diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 405b38982ed..ecc9fd1161d 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' -import type { SubprocessHandle } from './session' function createMockSubprocess( options: { startupCommandDeliveredInShellArgs?: boolean } = {} @@ -351,6 +351,25 @@ describe('TerminalHost', () => { expect(sessions).toHaveLength(2) expect(sessions.map((s) => s.sessionId).sort()).toEqual(['session-1', 'session-2']) }) + + it('uses applied size without serializing terminal snapshots', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + host.resize('session-1', 132, 43) + + const getSnapshot = vi.spyOn(Session.prototype, 'getSnapshot') + + expect(host.listSessions()[0]).toMatchObject({ + sessionId: 'session-1', + cols: 132, + rows: 43 + }) + expect(getSnapshot).not.toHaveBeenCalled() + }) }) describe('detach', () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6571d14c353..4731322e526 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -295,7 +295,7 @@ export class TerminalHost { if (!session.isAlive) { continue } - const snapshot = session.getSnapshot() + const size = session.getAppliedSize() result.push({ sessionId: session.sessionId, state: session.state, @@ -303,8 +303,8 @@ export class TerminalHost { isAlive: true, pid: session.pid, cwd: session.getCwd(), - cols: snapshot?.cols ?? 0, - rows: snapshot?.rows ?? 0, + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, createdAt: 0 }) } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6da965ecd69..0afc0c3e4e0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3140,6 +3140,101 @@ describe('registerPtyHandlers', () => { }) }) + it('checks single-PTY liveness without listing every session', async () => { + const hasPty = vi.fn((id: string) => id === 'live-pty') + const listProcesses = vi.fn(async () => { + throw new Error('listProcesses should not be called') + }) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + attach: vi.fn(), + hasPty, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'live-pty' })).resolves.toBe(true) + await expect(handlers.get('pty:hasPty')!(null, { id: 'dead-pty' })).resolves.toBe(false) + + expect(hasPty).toHaveBeenCalledWith('live-pty') + expect(hasPty).toHaveBeenCalledWith('dead-pty') + expect(listProcesses).not.toHaveBeenCalled() + }) + + it('treats unsupported or failed single-PTY liveness as unknown', async () => { + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null) + + const hasPty = vi.fn(() => { + throw new Error('provider unavailable') + }) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + hasPty, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null) + }) + it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9ba3685c973..9f654ea1e1f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1263,6 +1263,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:spawn') ipcMain.removeHandler('pty:kill') ipcMain.removeHandler('pty:listSessions') + ipcMain.removeHandler('pty:hasPty') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:getCwd') @@ -3445,6 +3446,23 @@ export function registerPtyHandlers( } ) + ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const provider = parsedSshId + ? sshProviders.get(parsedSshId.connectionId) + : tryGetProviderForPty(args.id) + if (!provider?.hasPty) { + return null + } + try { + return provider.hasPty(args.id) + } catch { + // Why: liveness is only allowed to close panes on an authoritative false. + return null + } + }) + ipcMain.handle( 'pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2ada15bed88..139474b25d6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1158,6 +1158,7 @@ export type PreloadApi = { getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> + hasPty: (id: string) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3ab44d91be1..e705586d4f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -830,6 +830,7 @@ const api = { listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), + hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), getMainBufferSnapshot: ( id: string, 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 426cbf15656..4c84c3f825b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -666,6 +666,7 @@ describe('connectPanePty', () => { pty: { signal: vi.fn(), listSessions: vi.fn().mockResolvedValue([]), + hasPty: vi.fn().mockResolvedValue(true), getSize: vi.fn().mockResolvedValue(null), reportGeometry: vi.fn(), getMainBufferSnapshot: vi.fn().mockResolvedValue(null), @@ -11428,6 +11429,83 @@ describe('connectPanePty', () => { expect(manager.closePane).toHaveBeenCalledWith(2) }) + it('closes a split pane when targeted liveness says its local session is missing', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-pane-2' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const hasPty = vi.fn(async () => false) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + + binding.reconcileIfSessionMissing(hasPty) + await flushAsyncTicks() + + expect(hasPty).toHaveBeenCalledWith('pty-pane-2') + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('does not close when targeted liveness is live or unknown', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(vi.fn(async () => true)) + binding.reconcileIfSessionMissing(vi.fn(async () => null)) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('does not apply a stale targeted liveness result after reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + let resolveHasPty: (value: boolean) => void = () => { + throw new Error('hasPty promise resolver was not initialized') + } + const hasPty = vi.fn( + () => + new Promise((resolve) => { + resolveHasPty = resolve + }) + ) + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + binding.reconcileIfSessionMissing(hasPty) + transport.getPtyId.mockReturnValue('pty-pane-2-reattached') + resolveHasPty(false) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + it('does NOT tear down a newborn pane when the snapshot was requested before it bound', async () => { // Why (regression): a snapshot requested before the spawn bound cannot // prove the fresh ptyId dead. Drives the REAL reconcile body to prove the @@ -11728,13 +11806,13 @@ describe('connectPanePty', () => { }) }) - describe('input liveness re-check IPC gating (perf)', () => { + describe('terminal input liveness IPC gating (perf)', () => { // Why (perf regression guard): listSessions() is a renderer→main→daemon - // round-trip. The input-driven liveness re-check must fire at most once per - // resume window, never once per keystroke, or every healthy local pane puts - // a process-enumeration round-trip on the typing hot path. + // round-trip over every live session. Terminal input must never start that + // enumeration; visibility reconcile and daemon exit events own liveness. async function connectActivePaneWithInput(): Promise<{ binding: { noteVisibilityResume: () => void } + transport: MockTransport typeKeystroke: (data?: string) => void }> { const { connectPanePty } = await import('./pty-connection') @@ -11751,6 +11829,7 @@ describe('connectPanePty', () => { } return { binding, + transport, // Drives the real xterm onData (terminal input) handler. typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data) } @@ -11768,7 +11847,7 @@ describe('connectPanePty', () => { expect(listSessions).not.toHaveBeenCalled() }) - it('fires listSessions once for the first input after a visibility resume', async () => { + it('does not fire listSessions for input after a visibility resume', async () => { const listSessions = vi.mocked(window.api.pty.listSessions) listSessions.mockClear() const { binding, typeKeystroke } = await connectActivePaneWithInput() @@ -11777,73 +11856,42 @@ describe('connectPanePty', () => { typeKeystroke('a') typeKeystroke('b') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('re-arms one re-check after a second visibility resume', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { binding, typeKeystroke } = await connectActivePaneWithInput() - - binding.noteVisibilityResume() - typeKeystroke('a') - typeKeystroke('b') - expect(listSessions).toHaveBeenCalledTimes(1) - - binding.noteVisibilityResume() - typeKeystroke('c') - typeKeystroke('d') - expect(listSessions).toHaveBeenCalledTimes(2) - }) - - it('never fires listSessions for a remote: web-runtime pane (liveness owned by host snapshot)', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { connectPanePty } = await import('./pty-connection') - // Remote panes report a null connectionId but a remote:-prefixed ptyId, so - // the SSH/connectionId guard alone would not exclude them — the remote - // prefix guard must. - const transport = createMockTransport('remote:env-1@@terminal-2') - transport.getConnectionId.mockReturnValue(null) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - - binding.noteVisibilityResume() - sendTerminalInputThroughPane(pane, 'x') - sendTerminalInputThroughPane(pane, 'y') - expect(listSessions).not.toHaveBeenCalled() }) - it('never fires listSessions for an SSH pane after resume', async () => { + it('sends the first post-resume input without starting the liveness re-check', async () => { const listSessions = vi.mocked(window.api.pty.listSessions) listSessions.mockClear() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('ssh-pty-2') - transport.getConnectionId.mockReturnValue('ssh-connection-1') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + const calls: string[] = [] + listSessions.mockImplementation(() => { + calls.push('listSessions') + return Promise.resolve([]) + }) + const { binding, transport, typeKeystroke } = await connectActivePaneWithInput() + transport.sendInput.mockImplementation(() => { + calls.push('sendInput') + return true }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } binding.noteVisibilityResume() - sendTerminalInputThroughPane(pane, 'x') - sendTerminalInputThroughPane(pane, 'y') + typeKeystroke('a') + expect(calls).toEqual(['sendInput']) + }) + + it('does not re-arm input-driven listSessions across repeated visibility resumes', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const { binding, typeKeystroke } = await connectActivePaneWithInput() + + binding.noteVisibilityResume() + typeKeystroke('a') + typeKeystroke('b') + expect(listSessions).not.toHaveBeenCalled() + + binding.noteVisibilityResume() + typeKeystroke('c') + typeKeystroke('d') expect(listSessions).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 00db0461cdf..0bc7fd41975 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -23,7 +23,11 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' -import { shouldReconcileDeadSession } from './terminal-dead-session-reconcile' +import { + shouldReconcileDeadSession, + shouldReconcileMissingSession, + type HasPty +} from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' @@ -542,6 +546,7 @@ type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void } function isAgentTaskCompleteNotificationEnabled(): boolean { @@ -1449,7 +1454,7 @@ export function connectPanePty( pane.container.dataset.ptyId = ptyId } let activePanePtyBinding: string | null = null - // Why: bind time so reconcile can ignore a listSessions snapshot requested + // Why: bind time lets async liveness reconcile ignore a request started // before this PTY bound (newborn race). Null disables the guard (fail-safe). let activePanePtyBindingBoundAt: number | null = null const clearPanePtyFitBinding = (): void => { @@ -2293,11 +2298,6 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } - // Why (Defect #2): a keystroke against a pane whose daemon session was - // reaped while hidden is silently dropped — sendInput still returns true. - // Kick off a fire-and-forget liveness re-check so the dead pane is cleaned - // up without relying on (the never-occurring) sendInput false return. - recheckLivenessAfterInput() const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -5083,49 +5083,49 @@ export function connectPanePty( onExit(currentPtyId) } - // Why (perf + startup correctness): listSessions() is authoritative only - // after a real visibility resume. Fresh PTY startup can briefly lag the daemon - // listing, so newborn terminals start disarmed and noteVisibilityResume grants - // exactly one first-input liveness probe for the next resume window. - let livenessRecheckArmedForResume = false - - // Why (Defect #2 defense-in-depth): in the broken state sendInput returns - // true (connected/ptyId still set) so the dropped keystroke is invisible to - // the renderer. A fire-and-forget liveness re-check on the FIRST input after - // a resume cleans the pane up promptly instead of waiting for the resume - // pass alone. It REDUCES but cannot eliminate the first-keystroke drop (that - // byte is already gone daemon-side). - const recheckLivenessAfterInput = (): void => { - if (disposed || !livenessRecheckArmedForResume) { - return - } - // Why: consume the resume token before inspecting provider details so SSH, - // remote-runtime, and concurrent keystrokes cannot retry this hot-path check - // until the lifecycle reports another true hidden-to-visible resume. - livenessRecheckArmedForResume = false - const currentPtyId = transport.getPtyId() - const currentConnectionId = transport.getConnectionId?.() + const reconcileIfSessionMissing = ( + hasPty: HasPty, + livenessRequestedAt = performance.now() + ): void => { + const requestedPtyId = transport.getPtyId() if ( - !currentPtyId || - // Why: this ptyId's exit was already handled — nothing left to reconcile. - handledExitPtyId === currentPtyId || - // Why: `remote:` web-runtime liveness is owned by the host snapshot, not - // listSessions; skip here so a remote pane's keystrokes never put a local - // daemon round-trip on the typing hot path (reconcile would no-op anyway). - isRemoteRuntimePtyId(currentPtyId) || - (currentConnectionId !== null && currentConnectionId !== undefined) + !requestedPtyId || + requestedPtyId === handledExitPtyId || + requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) || + transport.getConnectionId?.() != null ) { return } - // Why: capture request time before the round-trip so a pane that bound after - // this request is not torn down by its (pre-bind) stale snapshot. - const requestedAt = performance.now() - void window.api.pty - .listSessions() - .then((sessions) => { - reconcileIfSessionDead(new Set(sessions.map((session) => session.id)), requestedAt) + + let livenessPromise: Promise + try { + livenessPromise = Promise.resolve(hasPty(requestedPtyId)) + } catch { + return + } + + void livenessPromise + .then((isLive) => { + if (disposed) { + return + } + const currentPtyId = transport.getPtyId() + if ( + !currentPtyId || + currentPtyId !== requestedPtyId || + handledExitPtyId === currentPtyId || + !shouldReconcileMissingSession({ + ptyId: currentPtyId, + connectionId: transport.getConnectionId?.(), + isLive, + ptyBoundAt: activePanePtyBindingBoundAt, + livenessRequestedAt + }) + ) { + return + } + onExit(currentPtyId) }) - // Why: a rejected listing is "unknown" — never close a pane on it. .catch(() => {}) } @@ -5133,17 +5133,14 @@ export function connectPanePty( syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() }, - // Why: re-arm the once-per-resume input re-check when the pane becomes - // visible again. Called from the lifecycle visibility effect; the gate - // keeps the typing hot path off the listSessions IPC between resumes. + // Why: called from the lifecycle visibility effect so the visible-resume + // size readback can repair dropped hidden resizes without refitting against + // xterm's transient hidden DOM fallback. noteVisibilityResume() { - livenessRecheckArmedForResume = true - // Why: the visibility-resume path reattaches WebGL before doing the - // authoritative fit. Fitting here can measure xterm's hidden DOM fallback - // and send a transient narrow SIGWINCH before the renderer is restored. ptySizeReassertion.request({ fit: false }) }, reconcileIfSessionDead, + reconcileIfSessionMissing, dispose() { disposed = true // Why: the post-spawn reconcile polls across frames; cancel its pending diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts index 9e5c5d2bc1f..163252403ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileDeadSessions, + reconcileMissingSessions, + shouldReconcileMissingSession, shouldReconcileDeadSession } from './terminal-dead-session-reconcile' @@ -128,6 +130,80 @@ describe('shouldReconcileDeadSession', () => { }) }) +describe('shouldReconcileMissingSession', () => { + it('reconciles only an authoritative missing local PTY', () => { + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@dead', + connectionId: null, + isLive: false + }) + ).toBe(true) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@alive', + connectionId: null, + isLive: true + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@unknown', + connectionId: null, + isLive: null + }) + ).toBe(false) + }) + + it('keeps the remote, SSH, and newborn guards from the broad reconcile path', () => { + expect( + shouldReconcileMissingSession({ + ptyId: 'remote:env-1:abc', + connectionId: null, + isLive: false + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@ssh-dead', + connectionId: 'ssh-target-1', + isLive: false + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@newborn', + connectionId: null, + isLive: false, + ptyBoundAt: 1000, + livenessRequestedAt: 900 + }) + ).toBe(false) + }) +}) + +describe('reconcileMissingSessions', () => { + it('invokes each binding with the targeted liveness probe and request timestamp', () => { + const hasPty = vi.fn(async () => true) + const bindingA = { reconcileIfSessionMissing: vi.fn() } + const bindingB = { reconcileIfSessionMissing: vi.fn() } + const before = performance.now() + + reconcileMissingSessions({ bindings: [bindingA, bindingB], hasPty }) + + const after = performance.now() + expect(bindingA.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number)) + expect(bindingB.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number)) + const [, requestedAt] = bindingA.reconcileIfSessionMissing.mock.calls[0]! + expect(requestedAt).toBeGreaterThanOrEqual(before) + expect(requestedAt).toBeLessThanOrEqual(after) + }) +}) + describe('reconcileDeadSessions', () => { function createBinding() { return { diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts index 8aa0f22685f..7c9fe8a23fc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts +++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts @@ -13,8 +13,11 @@ const REMOTE_PTY_ID_PREFIX = 'remote:' */ export type ReconcilableBinding = { reconcileIfSessionDead?: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing?: (hasPty: HasPty, livenessRequestedAt?: number) => void } +export type HasPty = (ptyId: string) => Promise + /** * PURE decision: should the pane bound to `ptyId` be reconciled (torn down) * given the resolved set of live session ids? @@ -60,6 +63,37 @@ export function shouldReconcileDeadSession(args: { return !liveSessionIds.has(ptyId) } +export function shouldReconcileMissingSession(args: { + ptyId: string | null | undefined + connectionId: string | null | undefined + isLive: boolean | null | undefined + ptyBoundAt?: number | null + livenessRequestedAt?: number | null +}): boolean { + if (args.isLive !== false) { + return false + } + return shouldReconcileDeadSession({ + ptyId: args.ptyId, + connectionId: args.connectionId, + liveSessionIds: new Set(), + ptyBoundAt: args.ptyBoundAt, + snapshotRequestedAt: args.livenessRequestedAt + }) +} + +export function reconcileMissingSessions(args: { + bindings: Iterable + hasPty: HasPty +}): void { + // Why: the liveness request time must predate every async response so a + // stale response cannot close a PTY that bound after the request started. + const requestedAt = performance.now() + for (const binding of args.bindings) { + binding.reconcileIfSessionMissing?.(args.hasPty, requestedAt) + } +} + /** * Thin orchestration: fetch the live session listing once and invoke each * binding's `reconcileIfSessionDead` with the resolved set. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 8806cde6030..d3f35c8f5e1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -8,7 +8,6 @@ import { resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, - scheduleVisibilityReconcilePass, shouldDetachPaneTransportOnUnmount, splitPaneWithOneShotStartup, suppressIntentionalPaneCloseExit @@ -291,7 +290,7 @@ describe('suppressIntentionalPaneCloseExit', () => { }) }) -describe('scheduleVisibilityReconcilePass', () => { +describe('terminal pane visibility resume tracking', () => { it('ignores previous visibility from a different terminal identity', () => { expect( getPreviousVisibleForTerminalPane({ @@ -324,57 +323,4 @@ describe('scheduleVisibilityReconcilePass', () => { ) expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true) }) - - it('schedules a reconcile pass over the bindings when becoming visible', async () => { - const reconcileIfSessionDead = vi.fn() - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([{ id: 'live-1', cwd: '/a', title: 'a' }]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: false, - isVisible: true, - bindings: [{ reconcileIfSessionDead }], - listSessions - }) - - expect(scheduled).toBe(true) - // Fire-and-forget: let the async listSessions resolve before asserting. - await Promise.resolve() - await Promise.resolve() - expect(listSessions).toHaveBeenCalledTimes(1) - expect(reconcileIfSessionDead).toHaveBeenCalledWith(new Set(['live-1']), expect.any(Number)) - }) - - it('does not schedule on an initially visible mount', () => { - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: null, - isVisible: true, - bindings: [{ reconcileIfSessionDead: vi.fn() }], - listSessions - }) - - expect(scheduled).toBe(false) - expect(listSessions).not.toHaveBeenCalled() - }) - - it('self-gates: does not schedule when hiding (isVisible false)', () => { - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: true, - isVisible: false, - bindings: [{ reconcileIfSessionDead: vi.fn() }], - listSessions - }) - - expect(scheduled).toBe(false) - expect(listSessions).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 442f64a05fd..a5d9172855b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -78,8 +78,11 @@ import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' -import { reconcileDeadSessions, type ReconcilableBinding } from './terminal-dead-session-reconcile' import type { PtyTransport } from './pty-transport' +import { + reconcileMissingSessions, + type ReconcilableBinding +} from './terminal-dead-session-reconcile' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -473,21 +476,6 @@ export function getPreviousVisibleForTerminalPane(args: { return args.previous.isVisible } -export function scheduleVisibilityReconcilePass(args: { - previousIsVisible: boolean | null - isVisible: boolean - bindings: Iterable - listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> -}): boolean { - if (!isTerminalPaneVisibilityResume(args)) { - return false - } - // Why: fire-and-forget so the async listSessions IPC never blocks the - // synchronous WebGL/fit resume work the user sees first. - void reconcileDeadSessions({ bindings: args.bindings, listSessions: args.listSessions }) - return true -} - export function useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1694,23 +1682,20 @@ export function useTerminalPaneLifecycle({ noteVisibilityResume?: () => void } bindingWithVisibility.syncProcessTracking?.() - // Why: re-arm the once-per-resume input liveness re-check so the typing - // hot path stays off the listSessions IPC between resumes (the re-check - // is only useful right after a hidden→visible flip). + // Why: visible-resume repairs dropped hidden resizes, but it must not fit + // against xterm's transient hidden DOM fallback. if (resumedFromHidden) { bindingWithVisibility.noteVisibilityResume?.() } } - // Why: the reconcile pass self-gates on becoming visible (resume) — the - // effect also fires on hide and initial mount. Initial visible mounts are - // fresh PTY startup, so an early listSessions snapshot must not close the - // newborn tab before the daemon lists it. - scheduleVisibilityReconcilePass({ - previousIsVisible, - isVisible, - bindings: panePtyBindingsRef.current.values() as Iterable, - listSessions: () => window.api.pty.listSessions() - }) + if (resumedFromHidden && typeof window.api.pty.hasPty === 'function') { + // Why: preserve missed-exit recovery without daemon-wide listSessions; + // providers can answer a single-PTY liveness check from in-memory state. + reconcileMissingSessions({ + bindings: panePtyBindingsRef.current.values() as Iterable, + hasPty: window.api.pty.hasPty + }) + } // eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility and terminal identity changes must refresh existing PTY process tracking even though the ref object identity is stable. }, [cwd, isVisible, isVisibleRef, panePtyBindingsRef, tabId]) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ca0fcb2806d..c7c472123a3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2547,6 +2547,7 @@ function createPtyApi(): NonNullable['pty']> { getCwd: () => Promise.resolve('~'), getSize: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), + hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ From dc8161353eb22b6257f827e0f1ac442100d61674 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:06:31 -0700 Subject: [PATCH 004/233] Fix Resource Manager session polling lag (#7050) Co-authored-by: Orca --- ...UsageStatusSegment.session-polling.test.ts | 23 +++++++ .../status-bar/ResourceUsageStatusSegment.tsx | 68 +++++++++---------- 2 files changed, 57 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts new file mode 100644 index 00000000000..c30366e8df4 --- /dev/null +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx') + +describe('ResourceUsageStatusSegment session polling', () => { + it('does not poll global terminal sessions while the popover is closed', () => { + const source = readFileSync(SOURCE_PATH, 'utf8') + + expect(source).not.toContain('installWindowVisibilityInterval') + expect(source).not.toContain('SESSIONS_POLL_MS') + expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1) + + const openEffectIndex = source.indexOf('if (!open || runtimeEnvironmentActive)') + const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex) + + // Why: pty.listSessions() is a global daemon inventory and can pause input + // with large preserved-session sets. Keep it on explicit Resource Manager use. + expect(openEffectIndex).toBeGreaterThanOrEqual(0) + expect(refreshIndex).toBeGreaterThan(openEffectIndex) + }) +}) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 67095fee7c8..86aa18ef2cd 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -31,7 +31,6 @@ import { cn } from '@/lib/utils' import { useMountedRef } from '@/hooks/useMountedRef' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' @@ -76,7 +75,6 @@ import { import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 -const SESSIONS_POLL_MS = 10_000 type SortOption = 'memory' | 'cpu' | 'name' @@ -877,19 +875,19 @@ export function ResourceUsageStatusSegment({ } const spaceScanReady = nextSpaceScanSnapshot.ready - // Poll memory + sessions when popover is open. Sessions also poll in the - // background at a slower rate so the badge count stays reasonably fresh - // without keeping the Memory IPC hot. + // Poll memory when popover is open. Sessions are refreshed on open and after + // session actions; a closed status-bar badge must not globally inventory + // daemon PTYs because large preserved-session sets make that visible while + // typing. useEffect(() => { if (!open || runtimeEnvironmentActive) { return } void fetchSnapshot() void refreshSessions() - // Why: sessions already have an always-on poll in the effect below; only - // the memory snapshot is gated on the popover being open. Stacking a - // second sessions interval here doubled IPC traffic while the popover - // was open. + // Why: only the memory snapshot keeps an interval while the popover is + // open. Session inventory is explicit-on-open/action because it can be + // expensive with many daemon-preserved terminals. const memTimer = window.setInterval(() => { void fetchSnapshot() }, POLL_MS) @@ -902,16 +900,8 @@ export function ResourceUsageStatusSegment({ if (runtimeEnvironmentActive) { setSessions([]) setSessionsError(false) - return } - // Why: the closed-popover badge is informational. Polling daemon sessions - // while the whole window is hidden keeps IPC and daemon list calls hot for - // no visible UI; visibility refreshes catch the badge up immediately. - return installWindowVisibilityInterval({ - run: () => void refreshSessions(), - intervalMs: SESSIONS_POLL_MS - }) - }, [runtimeEnvironmentActive, refreshSessions]) + }, [runtimeEnvironmentActive]) const repoDisplayNameById = useMemo(() => { const map = new Map() @@ -987,14 +977,26 @@ export function ResourceUsageStatusSegment({ ] ) - // Why: orphanCount drives the trigger badge (always visible in the status - // bar, popover open or not) so it must compute outside the open-gate. + // Why: orphan detection needs daemon inventory. Keep it open-only so the + // closed badge never reintroduces a background global session scan. const orphanCount = useMemo(() => { - if (!workspaceSessionReady || runtimeEnvironmentActive) { + if (!open || !workspaceSessionReady || runtimeEnvironmentActive) { return 0 } return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) + }, [open, sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) + + const closedSessionCount = useMemo(() => { + if (!workspaceSessionReady || runtimeEnvironmentActive) { + return 0 + } + return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size + }, [resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) + const triggerSessionCount = runtimeEnvironmentActive + ? 0 + : open + ? sessions.length + : closedSessionCount const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1009,10 +1011,8 @@ export function ResourceUsageStatusSegment({ }, [resourceSnapshot]) // Why: memorySnapshotError is null both for "last fetch succeeded" and - // "never fetched". When the segment is mounted but the popover hasn't - // been opened, fetchMemorySnapshot has never run, so a sessions IPC - // failure on the always-on poll would otherwise be silent. Treat the - // absence of any snapshot plus a sessions error as unreachable too. + // "never fetched". If session refresh fails before a memory snapshot exists, + // treat that as daemon-unreachable too. const daemonUnreachable = !runtimeEnvironmentActive && sessionsError && @@ -1026,12 +1026,12 @@ export function ResourceUsageStatusSegment({ !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null const resourceManagerTooltipLines = getResourceManagerTooltipLines({ memoryLabel: memBadgeLabel, - sessionCount: sessions.length, + sessionCount: triggerSessionCount, runtimeEnvironmentActive, spaceScanReady }) const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: sessions.length, + sessionCount: triggerSessionCount, runtimeEnvironmentActive, spaceScanReady }) @@ -1132,8 +1132,8 @@ export function ResourceUsageStatusSegment({ if (orphans.length === 0) { return } - // Why: optimistic removal so the rows disappear immediately rather than - // lingering up to SESSIONS_POLL_MS while the daemon-side list reconciles. + // Why: optimistic removal so rows disappear immediately instead of waiting + // for the next explicit daemon-side list refresh. const orphanIds = new Set(orphans.map((s) => s.id)) setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) @@ -1148,7 +1148,7 @@ export function ResourceUsageStatusSegment({ setKilling(true) // Why: optimistic removal — the kill X was on the row that's about to be // unmounted, so updating local state immediately avoids a flash where the - // dialog closes but the killed row stays for up to 10s. + // dialog closes but the killed row waits for the next list refresh. setSessions((prev) => prev.filter((s) => s.id !== target.sessionId)) try { await window.api.pty.kill(target.sessionId) @@ -1220,16 +1220,16 @@ export function ResourceUsageStatusSegment({ · - {sessions.length} + {triggerSessionCount} {orphanCount > 0 && ( ({orphanCount}) )} )} - {iconOnly && sessions.length > 0 && ( + {iconOnly && triggerSessionCount > 0 && ( - {sessions.length} + {triggerSessionCount} )} {daemonUnreachable && ( From 91e7e1d91cf6c0dccd5dbabfa3e72be3246c32c8 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Wed, 1 Jul 2026 15:10:35 -0700 Subject: [PATCH 005/233] fix: detect valid repos when git rev-parse can't confirm (#6173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: detect valid repos when git rev-parse can't confirm isGitRepo() has depended entirely on a successful `git rev-parse` since 18ed7b27d, with catch blocks that collapse every failure into "not a git repository". When that subprocess fails for a reason unrelated to repo-ness — a transient spawn / git-shim hiccup in the packaged app, main-process resource pressure, or a config-level error — a real repository is silently downgraded to a plain folder. The folder scanner already tolerates this via a `.git` marker, so the scan reports "git_repo" but the subsequent addRepo throws, producing the spurious "Open as Folder" prompt for a valid repo. Keep `git rev-parse` as the authoritative positive signal, but on any non-positive result fall back to a validated `.git` marker instead of returning false: `.git` dir must contain HEAD, a `.git` file must point at a gitdir, and bare roots need HEAD + objects/ + refs/. A garbage `.git` file and an empty `.git/` are still rejected, preserving the validation 18ed7b27d added. Logs a warning when recovery via the marker happens so the underlying probe failure stays visible. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address review feedback on repo detection - isGitRepo: warn at most once per session when recovering a repo via the .git marker, so a broken-git scan can't flood main-process logs. - repo-detection test: delete PATH instead of assigning "undefined" when it was originally unset, avoiding a corrupted PATH for later tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: harden git repo marker fallback Co-authored-by: Orca --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/git/repo-detection.test.ts | 289 +++++++++++++++++++++++- src/main/git/repo.ts | 328 +++++++++++++++++++++++++++- 2 files changed, 610 insertions(+), 7 deletions(-) diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 56ae4a0fd7d..ff76b7e12ea 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -35,6 +43,262 @@ describe('isGitRepo', () => { expect(isGitRepo(bareRepo)).toBe(true) }) + it('accepts a real repository when git itself cannot be run', () => { + // Why: regression guard for the spurious "Open as Folder" prompt. When the + // `git rev-parse` probe fails for an environmental reason (here simulated by + // making `git` unresolvable), a directory carrying valid Git metadata must + // still be recognized rather than silently downgraded to a plain folder. + const realRepo = path.join(tmpDir, 'real') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(realRepo)).toBe(true) + }) + }) + + it('accepts a nested directory in a repository when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'nested-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(nestedDir)).toBe(true) + }) + }) + + it('resolves a nested directory to the repo root when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'nested-root-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(getGitRepoRoot(nestedDir)).toBe(realRepo) + }) + }) + + it('accepts a symlinked nested directory in a repository when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'symlink-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + const symlinkedNestedDir = path.join(tmpDir, 'linked-nested') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + symlinkSync(nestedDir, symlinkedNestedDir, process.platform === 'win32' ? 'junction' : 'dir') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedNestedDir)).toBe(true) + expect(getGitRepoRoot(symlinkedNestedDir)).toBe( + realpathSync.native(realRepo).replace(/\\/g, '/') + ) + }) + }) + + it('rejects a symlink inside a repository that points outside when git cannot be run', () => { + const realRepo = path.join(tmpDir, 'symlink-parent-real') + const outsideDir = path.join(tmpDir, 'outside-target') + const symlinkedOutsideDir = path.join(realRepo, 'links', 'outside') + mkdirSync(path.dirname(symlinkedOutsideDir), { recursive: true }) + mkdirSync(outsideDir) + git(realRepo, ['init', '--quiet']) + symlinkSync(outsideDir, symlinkedOutsideDir, process.platform === 'win32' ? 'junction' : 'dir') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedOutsideDir)).toBe(false) + }) + }) + + it('resolves a symlink from one repository into another to the real target repo', () => { + const sourceRepo = path.join(tmpDir, 'symlink-source-real') + const targetRepo = path.join(tmpDir, 'symlink-target-real') + const targetNestedDir = path.join(targetRepo, 'packages', 'web') + const symlinkedTargetDir = path.join(sourceRepo, 'links', 'target') + mkdirSync(path.dirname(symlinkedTargetDir), { recursive: true }) + mkdirSync(targetNestedDir, { recursive: true }) + git(sourceRepo, ['init', '--quiet']) + git(targetRepo, ['init', '--quiet']) + symlinkSync( + targetNestedDir, + symlinkedTargetDir, + process.platform === 'win32' ? 'junction' : 'dir' + ) + + const expectedRoot = git(symlinkedTargetDir, ['rev-parse', '--show-toplevel']) + .trim() + .replace(/\\/g, '/') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedTargetDir)).toBe(true) + expect(getGitRepoRoot(symlinkedTargetDir)).toBe(expectedRoot) + }) + }) + + it('accepts a linked worktree when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'linked-main') + const linkedWorktree = path.join(tmpDir, 'linked-worktree') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + git(realRepo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.com', + 'commit', + '--allow-empty', + '--message', + 'initial' + ]) + git(realRepo, ['worktree', 'add', '--quiet', '-b', 'offline-linked', linkedWorktree]) + + withGitUnavailable(() => { + expect(isGitRepo(linkedWorktree)).toBe(true) + }) + }) + + it('rejects a plain folder when git cannot be run', () => { + const plain = path.join(tmpDir, 'plain') + mkdirSync(plain) + + withGitUnavailable(() => { + expect(isGitRepo(plain)).toBe(false) + }) + }) + + it('rejects a garbage .git file even when git cannot be run', () => { + const fakeRepo = path.join(tmpDir, 'fake-offline') + mkdirSync(fakeRepo) + writeFileSync(path.join(fakeRepo, '.git'), 'not a gitdir file') + + withGitUnavailable(() => { + expect(isGitRepo(fakeRepo)).toBe(false) + }) + }) + + it('rejects a nested folder with an invalid .git marker even inside a valid repo', () => { + const realRepo = path.join(tmpDir, 'outer-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + writeFileSync(path.join(nestedDir, '.git'), 'not a gitdir file') + + withGitUnavailable(() => { + expect(isGitRepo(nestedDir)).toBe(false) + }) + }) + + it('rejects a .git file that points at a missing gitdir when git cannot be run', () => { + const fakeRepo = path.join(tmpDir, 'missing-gitdir') + mkdirSync(fakeRepo) + writeFileSync(path.join(fakeRepo, '.git'), 'gitdir: /missing/orca/gitdir') + + withGitUnavailable(() => { + expect(isGitRepo(fakeRepo)).toBe(false) + }) + }) + + it('rejects an empty .git directory', () => { + const emptyGitDir = path.join(tmpDir, 'empty-gitdir') + mkdirSync(path.join(emptyGitDir, '.git'), { recursive: true }) + + withGitUnavailable(() => { + expect(isGitRepo(emptyGitDir)).toBe(false) + }) + }) + + it('rejects an incomplete .git directory with only HEAD', () => { + const incompleteGitDir = path.join(tmpDir, 'incomplete-gitdir') + mkdirSync(path.join(incompleteGitDir, '.git'), { recursive: true }) + writeFileSync(path.join(incompleteGitDir, '.git', 'HEAD'), 'ref: refs/heads/main\n') + + withGitUnavailable(() => { + expect(isGitRepo(incompleteGitDir)).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'admin-dir') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a case-insensitive .git admin directory alias when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'admin-dir-uppercase') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const uppercaseAdminDir = path.join(realRepo, '.GIT') + try { + realpathSync.native(uppercaseAdminDir) + } catch { + return + } + + withGitUnavailable(() => { + expect(isGitRepo(uppercaseAdminDir)).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare uses alternate false spelling', () => { + const realRepo = path.join(tmpDir, 'admin-dir-no') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + git(realRepo, ['config', 'core.bare', 'no']) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare is empty false', () => { + const realRepo = path.join(tmpDir, 'admin-dir-empty-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + writeFileSync(configPath, readFileSync(configPath, 'utf8').replace(/bare = false/, 'bare =')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare false has inline comments', () => { + const realRepo = path.join(tmpDir, 'admin-dir-commented-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + const config = readFileSync(configPath, 'utf8') + writeFileSync(configPath, config.replace(/bare = false/, 'bare = false # regular worktree')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + + writeFileSync(configPath, config.replace(/bare = false/, 'bare = false ; regular worktree')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare is quoted false', () => { + const realRepo = path.join(tmpDir, 'admin-dir-quoted-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + writeFileSync( + configPath, + readFileSync(configPath, 'utf8').replace(/bare = false/, String.raw`bare = \"false\"`) + ) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + it('resolves a contained path to the worktree root', () => { const repoRoot = path.join(tmpDir, 'repo') const nestedDir = path.join(repoRoot, 'packages', 'web') @@ -64,3 +328,26 @@ describe('isGitRepo', () => { expect(getGitRepoRoot(bareRepo)).toBe(bareRepo) }) }) + +/** + * Run `fn` with `git` removed from PATH so the in-process git probe fails the + * same way a transient spawn failure would, exercising the `.git`-marker + * fallback path. PATH is restored afterward. + */ +function withGitUnavailable(fn: () => void): void { + const originalPath = process.env.PATH + // An empty PATH leaves no directory to resolve the bare `git` binary, so the + // probe throws ENOENT — the indeterminate failure the fallback exists for. + process.env.PATH = '' + try { + fn() + } finally { + // Why: restoring an originally-unset PATH via assignment would write the + // string "undefined", corrupting PATH for later tests in this process. + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index c2219c4f68c..0c8f1efbe61 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import { execSync } from 'node:child_process' -import { existsSync, statSync } from 'node:fs' -import { basename } from 'node:path' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' @@ -21,6 +21,9 @@ type LocalGitExecOptions = { wslDistro?: string } +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' +type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } + function gitExecOptions( cwd: string, options: LocalGitExecOptions = {} @@ -72,24 +75,80 @@ export function isGitRepo(path: string): boolean { if (!existsSync(path) || !statSync(path).isDirectory()) { return false } + } catch { + return false + } + + // Authoritative positive signal: ask git directly. Covers regular work + // trees, linked worktrees (gitfile), submodules, and bare repos. + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + // Why: `git rev-parse` can fail to produce a clean answer for reasons + // unrelated to repo-ness — a transient spawn failure or git-shim hiccup in + // the packaged app, resource pressure in the Electron main process, or a + // repo whose config errors out. Treating every such failure as "not a repo" + // silently downgrades a real repository to a plain folder (worktrees, SCM, + // PRs all disappear) and is the regression behind the spurious "Open as + // Folder" prompt. Fall back to a validated `.git` marker so a directory that + // genuinely carries Git metadata is still recognized; a directory with only + // a garbage `.git` file has no valid marker and is correctly rejected. + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + // Why: warn only once per session. The folder scanner calls isGitRepo for + // many paths; if git is genuinely unavailable, warning per path would flood + // the main-process logs without adding signal beyond the first occurrence. + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +let warnedMarkerFallbackThisSession = false + +/** + * Tri-state git probe: only a clean pair of negative answers is a definitive + * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. + */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() if (insideWorkTree === 'true') { - return true + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' } } catch { - // Fall through to the bare-repo probe below. + sawFailure = true } try { const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { cwd: path }).trim() - return bareRepo === 'true' + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } } catch { - return false + sawFailure = true } + + return sawFailure ? 'indeterminate' : 'not-repo' } export function getGitRepoRoot(path: string): string { @@ -109,6 +168,10 @@ export function getGitRepoRoot(path: string): string { } catch { // Fall through to preserving the original path. } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } return path } @@ -122,6 +185,259 @@ export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: st return normalizeRuntimePathSeparators(rootPath) } +/** + * Filesystem-only check for genuine Git metadata, used as a fallback when git + * cannot give a clean answer. Strict enough to reject a directory whose `.git` + * is a garbage file (preserving the validation added in 18ed7b27d): + * - `.git` directory: accepted only if it has real common or linked-worktree + * gitdir shape, so empty/incomplete `.git/` folders are rejected. + * - `.git` file: accepted only if its `gitdir:` target resolves to valid Git + * metadata, covering linked worktrees and submodules. + * - bare repo root: accepted when HEAD + objects/ + refs/ are present and the + * config does not mark it as a regular worktree admin dir. + */ +function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings such as /var vs /private/var, but let a + // symlink from one repo into another bind to the real target repo like git. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} + /** * Get a human-readable name for the repo from its path. */ From 126aae7734a731af9c8daf878b31155f846c0e07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:12:14 +0000 Subject: [PATCH 006/233] release: v1.4.116-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ddbb23729f1..55757c418e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.115-rc.4", + "version": "1.4.116-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From e0de46ffc8ed68079cb68e5da28a88bb32a44fe7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:10:50 -0700 Subject: [PATCH 007/233] Prevent hidden terminal TUI overlap (#7054) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 313 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 121 ++++++- .../pane-terminal-output-scheduler.test.ts | 85 ++++- .../pane-terminal-output-scheduler.ts | 54 +-- 4 files changed, 536 insertions(+), 37 deletions(-) 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 4c84c3f825b..01ad840dcbe 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -140,7 +140,7 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -371,6 +371,21 @@ function createPane(paneId: number) { } } +function captureCallbackTerminalWrites(pane: ReturnType): { + writes: string[] + parseCallbacks: (() => void)[] +} { + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + pane.terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof pane.terminal.write + return { writes, parseCallbacks } +} + function createManager(paneCount = 1, initialActivePaneId: number | null = null) { let activePaneId = initialActivePaneId const panes = Array.from({ length: paneCount }, (_, index) => ({ @@ -5375,6 +5390,269 @@ describe('connectPanePty', () => { } }) + it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| hidden Claude row |\r\n' + const endChunk = '\x1b[?2026l' + + capturedDataCallback.current?.(startChunk) + capturedDataCallback.current?.(plainRowChunk) + capturedDataCallback.current?.(endChunk) + + expect(writes).toEqual([]) + vi.advanceTimersByTime(50) + + expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('recognizes hidden synchronized output markers split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6hbody row\r\n') + capturedDataCallback.current?.('tail\x1b[?20') + capturedDataCallback.current?.('26l') + + vi.advanceTimersByTime(50) + + expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n') + capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n') + capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07') + + vi.advanceTimersByTime(50) + for (const callback of parseCallbacks) { + callback() + } + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('schedules hidden atlas recovery for high-confidence TUI redraw controls', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') + + vi.advanceTimersByTime(50) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('advances hidden rewrite state when synchronized output already requests recovery', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('prompt rewrite\r') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + + capturedDataCallback.current?.('plain after frame') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('resets hidden synchronized state when hidden renderer output is skipped', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { background?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const isVisibleRef = { current: false } + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?2026h') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + writes.length = 0 + + isVisibleRef.current = true + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + capturedDataCallback.current?.('\x1b[?2026l', { background: true }) + expect(writes).toEqual([]) + + isVisibleRef.current = false + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + capturedDataCallback.current?.('plain after skipped close\r\n') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(writes).toEqual(['plain after skipped close\r\n']) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('queues visible split-pane PTY bytes when the pane is not active', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -8263,6 +8541,39 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let parseCallback: (() => void) | undefined + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0bc7fd41975..3147e291644 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -181,6 +181,9 @@ const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' +const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' +const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 @@ -796,16 +799,16 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { } function containsSynchronizedOutputStart(data: string): boolean { - return data.includes('\x1b[?2026h') + return data.includes(SYNCHRONIZED_OUTPUT_START_SEQUENCE) } function containsSynchronizedOutputEnd(data: string): boolean { - return data.includes('\x1b[?2026l') + return data.includes(SYNCHRONIZED_OUTPUT_END_SEQUENCE) } function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): boolean { - const lastStartIndex = data.lastIndexOf('\x1b[?2026h') - const lastEndIndex = data.lastIndexOf('\x1b[?2026l') + const lastStartIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE) + const lastEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE) if (lastStartIndex === -1 && lastEndIndex === -1) { return wasActive } @@ -3244,6 +3247,96 @@ export function connectPanePty( return prefersRefresh } + function resetHiddenRendererRiskState(ptyId: string | null = null): void { + hiddenRiskPtyId = ptyId + hiddenSynchronizedOutputActive = false + hiddenSynchronizedOutputMarkerTail = '' + hiddenRewriteChunkEndedWithCarriageReturn = false + hiddenRewriteCsiScanTail = '' + } + + function ensureHiddenRendererRiskStateForCurrentPty(): void { + const ptyId = transport.getPtyId() + if (hiddenRiskPtyId === ptyId) { + return + } + resetHiddenRendererRiskState(ptyId) + } + + function resetSkippedHiddenRendererRiskState(): void { + // Why: skipped/backlog bytes were not parsed by xterm; reset any live hidden + // frame instead of letting dropped DEC starts make later plain bytes risky. + resetHiddenRendererRiskState(transport.getPtyId()) + } + + function hiddenSynchronizedOutputTouchesParsedFrame(data: string): boolean { + const scanData = hiddenSynchronizedOutputMarkerTail + ? `${hiddenSynchronizedOutputMarkerTail}${data}` + : data + const currentChunkStartIndex = scanData.length - data.length + let active = hiddenSynchronizedOutputActive + let touchesParsedFrame = active && data.length > 0 + let offset = 0 + + while (offset < scanData.length) { + const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) + const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) + if (startIndex === -1 && endIndex === -1) { + break + } + if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { + if ( + active && + endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex + ) { + touchesParsedFrame = true + } + active = false + offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length + continue + } + if (startIndex !== -1) { + active = true + if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { + touchesParsedFrame = true + } + offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length + continue + } + } + + if (active && data.length > 0) { + touchesParsedFrame = true + } + hiddenSynchronizedOutputActive = active + hiddenSynchronizedOutputMarkerTail = scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) + return touchesParsedFrame + } + + function hiddenTuiRedrawOutputPrefersAtlasRecovery(data: string): boolean { + if (!data) { + return false + } + const scanData = hiddenRewriteCsiScanTail ? `${hiddenRewriteCsiScanTail}${data}` : data + const decision = terminalRewriteOutputRenderRefreshDecision(data, { + previousChunkEndsWithCarriageReturn: hiddenRewriteChunkEndedWithCarriageReturn, + previousRewriteCsiScanTail: hiddenRewriteCsiScanTail + }) + hiddenRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn + hiddenRewriteCsiScanTail = decision.nextRewriteCsiScanTail + return decision.prefersRenderRefresh || containsCursorPositionSequence(scanData) + } + + function hiddenOutputNeedsAtlasRecoveryAfterParse(data: string): boolean { + if (!data) { + return false + } + ensureHiddenRendererRiskStateForCurrentPty() + const synchronizedOutputTouchesParsedFrame = hiddenSynchronizedOutputTouchesParsedFrame(data) + const tuiRedrawOutputPrefersAtlasRecovery = hiddenTuiRedrawOutputPrefersAtlasRecovery(data) + return synchronizedOutputTouchesParsedFrame || tuiRedrawOutputPrefersAtlasRecovery + } + // The replay path uses the guard so xterm auto-replies to embedded query // sequences don't leak into the shell. xterm.write() buffers internally // regardless of DOM visibility and the guard stays engaged via the @@ -3345,6 +3438,11 @@ export function connectPanePty( const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' let hiddenRendererStateDirty = false + let hiddenRiskPtyId: string | null = null + let hiddenSynchronizedOutputActive = false + let hiddenSynchronizedOutputMarkerTail = '' + let hiddenRewriteChunkEndedWithCarriageReturn = false + let hiddenRewriteCsiScanTail = '' function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -3519,6 +3617,7 @@ export function connectPanePty( ): void { if (foreground) { resetHiddenOutputRestoreIfPtyChanged() + resetHiddenRendererRiskState() } const parseHiddenStartupOutput = !foreground && @@ -3552,6 +3651,10 @@ export function connectPanePty( const renderRefreshDecision = foregroundOutput ? shouldForceForegroundRenderRefresh(data) : { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false } + const recoverHiddenWebglAtlasAfterParse = + !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) + const recoverWebglAtlasAfterParse = + renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3600,9 +3703,7 @@ export function connectPanePty( nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, // Why: atlas recovery must repaint from the parsed xterm buffer, not // a pre-write snapshot that a late TUI redraw can immediately stale. - onForegroundParsed: renderRefreshDecision.recoverWebglAtlasAfterParse - ? scheduleTerminalWebglAtlasRecovery - : undefined, + onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -3620,6 +3721,7 @@ export function connectPanePty( } function markHiddenOutputRestoreNeeded(): void { + resetSkippedHiddenRendererRiskState() const ptyId = transport.getPtyId() if (!canUseHiddenOutputSnapshot(ptyId)) { return @@ -3932,6 +4034,7 @@ export function connectPanePty( hiddenOutputRestoreScheduled = false hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() cancelScheduledHiddenOutputRestore(pane.terminal) clearHiddenOutputRestoreDeferredRetryTimer() clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -3982,6 +4085,7 @@ export function connectPanePty( clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null hiddenOutputRestoreGeneration += 1 @@ -4014,6 +4118,7 @@ export function connectPanePty( function skipBackgroundAlternateScreenOutput(data: string): void { writeHiddenStartupRendererQueries(data) respondToSkippedMode2031Subscribe(data) + resetSkippedHiddenRendererRiskState() hiddenRendererStateDirty = true recordHiddenRendererSkip(data.length) const ptyId = transport.getPtyId() @@ -4089,6 +4194,7 @@ export function connectPanePty( writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) hiddenRendererStateDirty = false + resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { @@ -4350,6 +4456,7 @@ export function connectPanePty( queueLiveChunkDuringRestore(rendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { + resetSkippedHiddenRendererRiskState() hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 0312419a560..2e107d9b218 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -69,26 +69,26 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function)) }) - it('runs foreground parsed callbacks after immediate foreground output parses', async () => { + it('runs parsed callbacks after immediate foreground output parses', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() let parseCallback: (() => void) | undefined terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'foreground', { foreground: true, - onForegroundParsed + onParsed }) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) - it('runs foreground parsed callbacks after queued foreground output parses', async () => { + it('runs parsed callbacks after queued foreground output parses', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -96,20 +96,20 @@ describe('pane terminal output scheduler', () => { terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'queued', { foreground: true, latencySensitive: false, - onForegroundParsed + onParsed }) vi.advanceTimersByTime(0) expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) it('synchronously refreshes visible rows after foreground output parses', async () => { @@ -244,6 +244,71 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('runs parsed callbacks after background output parses without foreground refresh', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'hidden redraw', { + foreground: false, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes).toEqual(['hidden redraw']) + expect(onParsed).not.toHaveBeenCalled() + expect(terminal._core.refresh).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'x'.repeat(20 * 1024), { + foreground: false, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) + expect(parseCallbacks).toHaveLength(2) + expect(onParsed).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + parseCallbacks[1]?.() + expect(onParsed).toHaveBeenCalledTimes(2) + }) + it('defers throughput foreground output to the shared high-priority drain', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index e56df22c0df..c43fc8cf161 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -21,7 +21,7 @@ type TerminalOutputParsedCallback = () => void type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -37,7 +37,7 @@ type QueueChunk = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueuedWrite = { @@ -46,7 +46,7 @@ type QueuedWrite = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueueEntry = { @@ -499,13 +499,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { remaining -= chunk.data.length entry.queuedChars -= chunk.data.length entry.chunkIndex += 1 - if (chunk.foreground && chunk.onForegroundParsed) { - parsedCallbacks.push(chunk.onForegroundParsed) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) } continue } data += chunk.data.slice(0, remaining) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) + } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -526,7 +529,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { forceForegroundRefresh, followupForegroundRefresh, stripTransientCursorShows, - onForegroundParsed: + onParsed: parsedCallbacks.length > 0 ? () => { for (const callback of parsedCallbacks) { @@ -561,7 +564,7 @@ function enqueueChunk( forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } ): void { entry.chunks.push({ @@ -570,7 +573,7 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onForegroundParsed: options?.onForegroundParsed + onParsed: options?.onParsed }) entry.queuedChars += data.length recordQueueDebugPressure() @@ -628,19 +631,30 @@ function hasDrainableBacklog(): boolean { return false } -function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void { +function writeBackgroundTerminalChunk( + terminal: TerminalOutputTarget, + data: string, + onParsed?: TerminalOutputParsedCallback +): void { const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - terminal.write(data) + if (!onParsed || terminal.write.length < 2) { + terminal.write(data) + onParsed?.() + return + } + terminal.write(data, onParsed) return } if (terminal.write.length < 2) { terminal.write(data) enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() return } terminal.write(data, () => { enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() }) } @@ -693,11 +707,11 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data) + writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; @@ -782,7 +796,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -849,7 +863,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -876,7 +890,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -899,7 +913,7 @@ export function writeTerminalOutput( { forceViewportRefresh: options.forceForegroundRefresh === true, followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onForegroundParsed + onParsed: options.onParsed } ) return @@ -914,7 +928,9 @@ export function writeTerminalOutput( entry.beforeWrite = options.beforeWrite entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } - enqueueChunk(entry, data) + enqueueChunk(entry, data, { + onParsed: options.onParsed + }) if ( entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS @@ -975,11 +991,11 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data) + writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; From d7322ec1f239db245c557f85370600ca67639137 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:11:35 +0000 Subject: [PATCH 008/233] release: v1.4.116 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55757c418e7..b3250bb355b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.116-rc.0", + "version": "1.4.116", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From d535cb569afc82e88de34e390215c58cad587d5d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:43:56 -0700 Subject: [PATCH 009/233] Show collapse affordance on status sidebar headers (#7051) --- .../WorktreeList.lineage-child-card.test.ts | 15 +++++++++++++-- .../src/components/sidebar/WorktreeList.tsx | 7 ++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 8d9fcc5f0d1..fba1e48b1d5 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -10,6 +10,7 @@ import type { WorktreeLineage } from '../../../../shared/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ state: {} as Record @@ -508,7 +509,7 @@ function setPinnedDuplicateFixtureState(): void { } function setLineageFixtureState( - groupBy: 'none' | 'repo' = 'none', + groupBy: 'none' | 'repo' | 'workspace-status' = 'none', options: { childWorktreeOverrides?: Partial deletingWorktreeIds?: string[] @@ -631,7 +632,7 @@ function setLineageFixtureState( // Why: multi-host added a host scope filter; 'all' (the store default) // bypasses it so the fixture's worktrees aren't dropped before rendering. workspaceHostScope: 'all', - workspaceStatuses: [], + workspaceStatuses: groupBy === 'workspace-status' ? cloneDefaultWorkspaceStatuses() : [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: { [child.id]: makeLineage(child, parent), @@ -884,6 +885,16 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('data-repo-header-collapse-affordance=""') }) + it('renders a collapse chevron on status group headers with worktrees', async () => { + setLineageFixtureState('workspace-status') + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('In progress') + expect(markup).toContain('data-workspace-status-drop-target=""') + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="true"') + }) + it('renders a collapse chevron on grouped repo headers with worktrees', async () => { setLineageFixtureState('repo') const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index ac3c2d6a088..bb16fbde91b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -3997,10 +3997,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectGroupPathStatus.reason === 'ambiguous-connection') const projectGroupDepth = row.projectGroupDepth ?? 0 const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project headers already reveal actions on hover; tuck - // the collapse chevron into that cluster instead of a new surface. + // Why: repo/project and status headers use the same compact + // section chrome; flat "All" stays a simple label. const showHeaderCollapseAffordance = - row.count > 0 && (isRepoHeader || isProjectGroupHeader) + row.count > 0 && + (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null) // Why: non-project section headers like "All" are labels for the // flat list, so they should not reserve project hierarchy indent. const headerPaddingLeft = From f8c1f0fdf80360863795380a4d2c4bf906263986 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:54:53 -0700 Subject: [PATCH 010/233] Fix delayed TUI mouse wheel reports (#7060) Co-authored-by: Orca --- .../runtime-home-service.test.ts | 2 +- src/main/codex-accounts/service.test.ts | 2 +- src/main/persistence.test.ts | 54 +++++++- src/main/persistence.ts | 38 ++++++ .../pane-terminal-mouse-wheel.test.ts | 115 +++++++++++++++++- .../pane-manager/pane-terminal-mouse-wheel.ts | 78 ++++++++---- src/shared/constants.ts | 3 +- src/shared/types.ts | 2 + tests/e2e/terminal-tui-wheel-reports.spec.ts | 114 +++++++++++++++++ 9 files changed, 372 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/terminal-tui-wheel-reports.spec.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d8e7a48e137..87e3a0ea90d 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -62,7 +62,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalLineHeight: 1, terminalScrollSensitivity: 1.15, terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivity: 1, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 00f0f4f6045..807d502f934 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -66,7 +66,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalLineHeight: 1, terminalScrollSensitivity: 1.15, terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivity: 1, terminalGpuAcceleration: 'auto', terminalLigatures: 'auto', terminalCursorStyle: 'block', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 376466450c4..c70360d6cb2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -492,7 +492,8 @@ describe('Store', () => { expect(settings.terminalFontWeight).toBe(500) expect(settings.terminalScrollSensitivity).toBe(1.15) expect(settings.terminalFastScrollSensitivity).toBe(5) - expect(settings.terminalTuiScrollSensitivity).toBe(3) + expect(settings.terminalTuiScrollSensitivity).toBe(1) + expect(settings.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) expect(settings.terminalUseSeparateLightTheme).toBe(true) expect(settings.rightSidebarOpenByDefault).toBe(true) expect(settings.showTasksButton).toBe(true) @@ -1938,6 +1939,57 @@ describe('Store', () => { expect(updated.autoRenameBranchFromWorkDefaultedOn).toBe(true) }) + it('migrates inherited TUI scroll sensitivity defaults to one report on first load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalTuiScrollSensitivity: 3 }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + + expect(store.getSettings().terminalTuiScrollSensitivity).toBe(1) + expect(store.getSettings().terminalTuiScrollSensitivityDefaultedToOne).toBe(true) + store.flush() + expect((readDataFile() as PersistedState).settings.terminalTuiScrollSensitivity).toBe(1) + }) + + it('preserves TUI scroll sensitivity choices after the one-report migration', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivityDefaultedToOne: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + + expect(store.getSettings().terminalTuiScrollSensitivity).toBe(3) + expect(store.getSettings().terminalTuiScrollSensitivityDefaultedToOne).toBe(true) + }) + + it('stamps the TUI scroll sensitivity migration guard on future updates', async () => { + const store = await createStore() + + const updated = store.updateSettings({ + terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivityDefaultedToOne: false + }) + + expect(updated.terminalTuiScrollSensitivity).toBe(3) + expect(updated.terminalTuiScrollSensitivityDefaultedToOne).toBe(true) + }) + it('merges rollback commit-message AI writes into existing source-control AI on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8469f6b7250..7f8a9a8f960 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -474,6 +474,8 @@ type LegacyTerminalScrollbackSettings = { terminalScrollbackBytes?: unknown } +const LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT = 3 + function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings { return settings && typeof settings === 'object' ? (settings as LegacyTerminalScrollbackSettings) @@ -509,6 +511,29 @@ function migrateTerminalScrollbackRows(settings: unknown): { } } +function migrateTerminalTuiScrollSensitivityDefault(settings: GlobalSettings | undefined): { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean +} { + const alreadyDefaultedToOne = settings?.terminalTuiScrollSensitivityDefaultedToOne === true + const current = settings?.terminalTuiScrollSensitivity + const shouldMoveInheritedDefault = + !alreadyDefaultedToOne && + (current === undefined || current === LEGACY_TERMINAL_TUI_SCROLL_SENSITIVITY_DEFAULT) + const terminalTuiScrollSensitivity = shouldMoveInheritedDefault ? 1 : (current ?? 1) + + return { + settings: { + terminalTuiScrollSensitivity, + terminalTuiScrollSensitivityDefaultedToOne: true + }, + needsSave: !alreadyDefaultedToOne || current === undefined + } +} + function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string { return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}` } @@ -2672,6 +2697,12 @@ export class Store { if (migratedTerminalScrollback.needsSave) { this.loadNeedsSave = true } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + this.loadNeedsSave = true + } const rawSourceControlAi = parsed.settings?.sourceControlAi const rawSourceControlAiMissing = rawSourceControlAi === undefined const rawSourceControlAiActionsMissing = @@ -2913,6 +2944,7 @@ export class Store { primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, ...migratedAutoRenameBranchFromWork, ...migratedTerminalCursorStyle, + ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, // Why: open first-run onboarding is the local fresh-install signal; @@ -4816,6 +4848,12 @@ export class Store { updates.terminalScrollbackRows ) } + if ( + 'terminalTuiScrollSensitivity' in updates || + 'terminalTuiScrollSensitivityDefaultedToOne' in updates + ) { + sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true + } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 5ced0c616d0..0d515ce8ec1 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + attachTerminalMouseWheelMultiplier, normalizeTerminalTuiMouseWheelMultiplier, shouldMultiplyTerminalMouseWheel } from './pane-terminal-mouse-wheel' @@ -8,6 +9,51 @@ import { const DOM_DELTA_PIXEL = 0 const DOM_DELTA_LINE = 1 +class TestWheelEvent extends Event { + static readonly DOM_DELTA_PIXEL = DOM_DELTA_PIXEL + static readonly DOM_DELTA_LINE = DOM_DELTA_LINE + static readonly DOM_DELTA_PAGE = 2 + + readonly altKey: boolean + readonly button: number + readonly buttons: number + readonly clientX: number + readonly clientY: number + readonly ctrlKey: boolean + readonly deltaMode: number + readonly deltaX: number + readonly deltaY: number + readonly deltaZ: number + readonly detail: number + readonly metaKey: boolean + readonly relatedTarget: EventTarget | null + readonly screenX: number + readonly screenY: number + readonly shiftKey: boolean + readonly view: Window | null + + constructor(type: string, init: WheelEventInit = {}) { + super(type, init) + this.altKey = init.altKey ?? false + this.button = init.button ?? 0 + this.buttons = init.buttons ?? 0 + this.clientX = init.clientX ?? 0 + this.clientY = init.clientY ?? 0 + this.ctrlKey = init.ctrlKey ?? false + this.deltaMode = init.deltaMode ?? DOM_DELTA_PIXEL + this.deltaX = init.deltaX ?? 0 + this.deltaY = init.deltaY ?? 0 + this.deltaZ = init.deltaZ ?? 0 + this.detail = init.detail ?? 0 + this.metaKey = init.metaKey ?? false + this.relatedTarget = init.relatedTarget ?? null + this.screenX = init.screenX ?? 0 + this.screenY = init.screenY ?? 0 + this.shiftKey = init.shiftKey ?? false + this.view = init.view ?? null + } +} + function terminalElement(mouseReporting = true): HTMLElement { return { classList: { @@ -16,7 +62,9 @@ function terminalElement(mouseReporting = true): HTMLElement { } as HTMLElement } -function wheelEvent(init: Partial = {}): WheelEvent { +function wheelEvent( + init: Partial & { wheelDelta?: number; wheelDeltaY?: number } = {} +): WheelEvent { return { deltaY: 100, deltaMode: DOM_DELTA_PIXEL, @@ -25,12 +73,16 @@ function wheelEvent(init: Partial = {}): WheelEvent { } describe('terminal mouse wheel multiplier', () => { - it('uses a three-report multiplier for TUI mouse wheel scrolling', () => { - expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(3) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses a one-report multiplier for TUI mouse wheel scrolling', () => { + expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(1) }) it('normalizes TUI wheel multipliers to the supported report range', () => { - expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(3) + expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(1) expect(normalizeTerminalTuiMouseWheelMultiplier(0)).toBe(1) expect(normalizeTerminalTuiMouseWheelMultiplier(4.4)).toBe(4) expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) @@ -56,6 +108,19 @@ describe('terminal mouse wheel multiplier', () => { ).toBe(false) }) + it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 12, + deltaMode: DOM_DELTA_PIXEL, + wheelDeltaY: -120 + }), + terminalElement() + ) + ).toBe(true) + }) + it('multiplies non-pixel wheel deltas as discrete input', () => { expect( shouldMultiplyTerminalMouseWheel( @@ -78,4 +143,44 @@ describe('terminal mouse wheel multiplier', () => { ) ).toBe(false) }) + + it('replays discrete TUI wheel ticks as line-mode reports', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + + expect(handlers).toHaveLength(1) + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched).toHaveLength(1) + expect(dispatched.map((entry) => entry.deltaMode)).toEqual([DOM_DELTA_LINE]) + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1]) + expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index 6e3ca37e996..b8654f7c3ce 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -3,8 +3,11 @@ import type { Terminal } from '@xterm/xterm' const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 +const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 3 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 @@ -18,6 +21,11 @@ type ReplayedWheelEvent = WheelEvent & { [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean } +type WheelEventWithLegacyDelta = WheelEvent & { + wheelDelta?: number + wheelDeltaY?: number +} + function isReplayedWheelEvent(event: WheelEvent): boolean { return (event as ReplayedWheelEvent)[REPLAYED_WHEEL_EVENT_PROPERTY] === true } @@ -29,32 +37,31 @@ function markReplayedWheelEvent(event: WheelEvent): void { }) } +function legacyVerticalWheelDelta(event: WheelEvent): number | null { + const wheelEvent = event as WheelEventWithLegacyDelta + if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { + return wheelEvent.wheelDeltaY + } + if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { + return wheelEvent.wheelDelta + } + return null +} + function isDiscreteWheelEvent(event: WheelEvent): boolean { if (event.deltaMode !== DOM_DELTA_PIXEL) { return true } - return Math.abs(event.deltaY) >= 50 -} - -export function shouldMultiplyTerminalMouseWheel( - event: WheelEvent, - terminalElement: HTMLElement | null | undefined -): boolean { - if ( - isReplayedWheelEvent(event) || - !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || - event.deltaY === 0 || - event.shiftKey || - !isDiscreteWheelEvent(event) - ) { - return false + if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { + return true } - return true + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN } -function cloneWheelEvent(event: WheelEvent): WheelEvent { +function cloneWheelReportEvent(event: WheelEvent): WheelEvent { const clone = new WheelEvent(event.type, { bubbles: event.bubbles, cancelable: event.cancelable, @@ -72,15 +79,32 @@ function cloneWheelEvent(event: WheelEvent): WheelEvent { button: event.button, buttons: event.buttons, relatedTarget: event.relatedTarget, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, - deltaMode: event.deltaMode + deltaX: 0, + deltaY: event.deltaY < 0 ? -1 : 1, + deltaZ: 0, + deltaMode: DOM_DELTA_LINE }) markReplayedWheelEvent(clone) return clone } +export function shouldMultiplyTerminalMouseWheel( + event: WheelEvent, + terminalElement: HTMLElement | null | undefined +): boolean { + if ( + isReplayedWheelEvent(event) || + !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || + event.deltaY === 0 || + event.shiftKey || + !isDiscreteWheelEvent(event) + ) { + return false + } + + return true +} + export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER @@ -108,17 +132,17 @@ export function attachTerminalMouseWheelMultiplier( return true } - // Why: mouse-reporting TUIs receive wheel input as reports, not viewport - // scrollback, so normal xterm scrollSensitivity cannot tune their speed. + // Why: xterm dampens small pixel deltas before emitting mouse reports; + // line-mode replays make each notched wheel tick produce immediate reports. queueMicrotask(() => { const multiplier = normalizeTerminalTuiMouseWheelMultiplier( options.getTuiMouseWheelMultiplier?.() ) - for (let i = 1; i < multiplier; i++) { - target.dispatchEvent(cloneWheelEvent(event)) + for (let i = 0; i < multiplier; i++) { + target.dispatchEvent(cloneWheelReportEvent(event)) } }) - return true + return false }) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0cf42920411..67ac5e65450 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -215,7 +215,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalLineHeight: 1, terminalScrollSensitivity: 1.15, terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivity: 1, + terminalTuiScrollSensitivityDefaultedToOne: true, // Why: "auto" should use WebGL when supported while keeping DOM fallback // for renderer failures and Linux software/unknown GPU renderers. terminalGpuAcceleration: 'auto', diff --git a/src/shared/types.ts b/src/shared/types.ts index 60d47108280..d7ff1547dd4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2488,6 +2488,8 @@ export type GlobalSettings = { terminalScrollSensitivity: number terminalFastScrollSensitivity: number terminalTuiScrollSensitivity: number + /** One-shot migration guard for moving inherited TUI wheel reports from 3 to 1. */ + terminalTuiScrollSensitivityDefaultedToOne?: boolean /** Terminal renderer policy. * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. * - 'on': always try xterm WebGL. diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts new file mode 100644 index 00000000000..0e7ae648422 --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -0,0 +1,114 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +type WheelReportSample = { + reportCount: number + reportDelta: number + reports: string[] +} + +const PHYSICAL_MOUSE_WHEEL_DELTA = -120 + +async function probeSmallMouseWheelReports( + page: Page, + ticks: number +): Promise { + return page.evaluate( + async ({ tickCount, physicalMouseWheelDelta }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: WheelReportSample[] = [] + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, 0)) + samples.push({ + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + return samples + } finally { + disposable.dispose() + } + }, + { tickCount: ticks, physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA } + ) +} + +test.describe('terminal TUI wheel reports', () => { + test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const samples = await probeSmallMouseWheelReports(orcaPage, 4) + + expect( + samples.map((sample) => sample.reportDelta), + `per-tick SGR mouse reports: ${JSON.stringify(samples)}` + ).toEqual([1, 1, 1, 1]) + expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;') + }) +}) From de807c5a8cf276cbdcec249a9f1244bdf9e70abc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:58:34 +0000 Subject: [PATCH 011/233] release: v1.4.117-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b3250bb355b..c74923b1295 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.116", + "version": "1.4.117-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4dbc9f38173e3bbb10a2c006f3b5aab517d61c16 Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Thu, 2 Jul 2026 00:29:48 -0400 Subject: [PATCH 012/233] feat(ssh): add ControlMaster multiplexing for system SSH transport (#6922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ssh): add ControlMaster multiplexing for system SSH transport System SSH transport spawns a new OpenSSH process per exec command (platform detect, relay install check, node resolution, relay launch, socket probe). Each process pays the full SSH handshake cost — ~9s on Uber devpods — making a typical relay connect take 54s+ and reliably exceeding the 15s startup reconnect budget. Add SSH ControlMaster multiplexing via a per-target socket in $TMPDIR/orca-ssh-ctl/.sock. The first command establishes the master; subsequent commands reuse it at ~100ms per exec instead of ~9s. ControlPersist=300 keeps the master alive after commands exit so rapid reconnects (e.g. on tab focus) also benefit. Windows is excluded since OpenSSH's ControlMaster support there is limited. * fix(ssh): address ControlMaster key collision and directory permission risks - Use target.id in the socket key so distinct SSH targets can never collide even when configHost/port/user happen to match - Switch from SHA1 to SHA256 and extend hash slice from 12 to 16 chars - Stat the control-socket directory after mkdirSync to reject pre-existing dirs that are symlinks, foreign-owned, or have group/other write bits (mkdirSync mode is ignored on pre-existing dirs) - Update two tests that used exact spawn-arg arrays; replace with ordering assertions (forward flags before --) that stay correct regardless of which extra ControlMaster options are injected * fix(ssh): bind ControlPath identity to route and reject symlinked ctl dir Fold proxyCommand/jumpHost/identity fields into the ControlPath hash so a target whose route is edited no longer reuses a still-alive master built on the old route. Switch the control-socket dir check from statSync to lstatSync so a planted symlink fails the directory validation outright. * test(ssh): drop tautological argv re-assertion in spawn checks The toHaveBeenCalledWith re-passed the args array extracted from the same mock call, making that argument position always pass. argv content is already verified by the index-ordering assertions above; use expect.any(Array) so the spawn check only claims what it actually verifies (binary path, stdio). * fix(ssh): harden system ssh connection reuse Co-authored-by: Orca * test(ssh): isolate control socket runtime dir Co-authored-by: Orca --------- Co-authored-by: Test Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/persistence.test.ts | 64 ++ src/main/persistence.ts | 14 +- src/main/ssh/ssh-config-parser.test.ts | 21 + src/main/ssh/ssh-config-parser.ts | 103 +-- src/main/ssh/ssh-connection-utils.test.ts | 2 + src/main/ssh/ssh-connection.test.ts | 341 ++++++++-- src/main/ssh/ssh-connection.ts | 253 +++++++- src/main/ssh/ssh-control-socket.test.ts | 127 ++++ src/main/ssh/ssh-control-socket.ts | 129 ++++ src/main/ssh/ssh-g-config-resolution.ts | 116 ++++ src/main/ssh/ssh-port-forward.test.ts | 33 + src/main/ssh/ssh-system-fallback.test.ts | 235 ++++++- src/main/ssh/ssh-system-fallback.ts | 610 +----------------- src/main/ssh/system-ssh-args.ts | 161 +++++ src/main/ssh/system-ssh-binary.ts | 21 + src/main/ssh/system-ssh-command.ts | 159 +++++ src/main/ssh/system-ssh-file-transfer.ts | 279 ++++++++ .../ssh/system-ssh-forward-process.test.ts | 89 ++- src/main/ssh/system-ssh-forward-process.ts | 12 +- .../ssh/system-ssh-operation-lifecycle.ts | 135 ++++ .../ssh/system-ssh-port-forward-provider.ts | 21 +- .../src/components/settings/SshPane.tsx | 69 +- .../SshTargetAdvancedConnectionSection.tsx | 106 +++ .../src/components/settings/SshTargetForm.tsx | 145 +---- .../SshTargetFormCollapsibleSection.tsx | 50 ++ .../SshTargetTerminalPersistenceSection.tsx | 98 +++ .../settings/ssh-target-draft.test.ts | 29 + .../components/settings/ssh-target-draft.ts | 3 + .../settings/ssh-target-save-payload.test.ts | 91 +++ .../settings/ssh-target-save-payload.ts | 87 +++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- src/shared/ssh-types.ts | 3 + 36 files changed, 2648 insertions(+), 990 deletions(-) create mode 100644 src/main/ssh/ssh-control-socket.test.ts create mode 100644 src/main/ssh/ssh-control-socket.ts create mode 100644 src/main/ssh/ssh-g-config-resolution.ts create mode 100644 src/main/ssh/system-ssh-args.ts create mode 100644 src/main/ssh/system-ssh-binary.ts create mode 100644 src/main/ssh/system-ssh-command.ts create mode 100644 src/main/ssh/system-ssh-file-transfer.ts create mode 100644 src/main/ssh/system-ssh-operation-lifecycle.ts create mode 100644 src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx create mode 100644 src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx create mode 100644 src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx create mode 100644 src/renderer/src/components/settings/ssh-target-save-payload.test.ts create mode 100644 src/renderer/src/components/settings/ssh-target-save-payload.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c70360d6cb2..d60033eac5e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1106,6 +1106,31 @@ describe('Store', () => { } }) + it('drops the legacy SSH relay default when updating targets', async () => { + const store = await createStore() + store.addSshTarget({ + id: 'ssh-update-legacy-default', + label: 'Update legacy default', + host: 'update-default.example.com', + port: 22, + username: 'dev' + }) + + const updated = store.updateSshTarget('ssh-update-legacy-default', { + relayGracePeriodSeconds: LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + }) + + expect(updated).not.toHaveProperty('relayGracePeriodSeconds') + expect(store.getSshTarget('ssh-update-legacy-default')).not.toHaveProperty( + 'relayGracePeriodSeconds' + ) + + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + const onDisk = persisted.sshTargets?.find((t) => t.id === 'ssh-update-legacy-default') + expect(onDisk).not.toHaveProperty('relayGracePeriodSeconds') + }) + it('persists the SSH target source field through add, update, and disk round-trip', async () => { const store = await createStore() store.addSshTarget({ @@ -1134,6 +1159,45 @@ describe('Store', () => { expect(onDisk?.port).toBe(2222) }) + it('persists only explicit SSH connection reuse opt-outs', async () => { + const store = await createStore() + store.addSshTarget({ + id: 'ssh-reuse-default', + label: 'Default reuse', + host: 'default.example.com', + port: 22, + username: 'dev', + systemSshConnectionReuse: true + }) + store.addSshTarget({ + id: 'ssh-reuse-off', + label: 'Reuse disabled', + host: 'legacy.example.com', + port: 22, + username: 'dev', + systemSshConnectionReuse: false + }) + + expect(store.getSshTarget('ssh-reuse-default')).not.toHaveProperty('systemSshConnectionReuse') + expect(store.getSshTarget('ssh-reuse-off')?.systemSshConnectionReuse).toBe(false) + + store.flush() + const persistedBeforeUpdate = readDataFile() as { sshTargets?: Record[] } + const defaultTarget = persistedBeforeUpdate.sshTargets?.find( + (t) => t.id === 'ssh-reuse-default' + ) + const disabledTarget = persistedBeforeUpdate.sshTargets?.find((t) => t.id === 'ssh-reuse-off') + expect(defaultTarget).not.toHaveProperty('systemSshConnectionReuse') + expect(disabledTarget?.systemSshConnectionReuse).toBe(false) + + const updated = store.updateSshTarget('ssh-reuse-off', { systemSshConnectionReuse: undefined }) + expect(updated).not.toHaveProperty('systemSshConnectionReuse') + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + const updatedTarget = persisted.sshTargets?.find((t) => t.id === 'ssh-reuse-off') + expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse') + }) + it('upserts ~/.ssh/config through the real store: rotated port updates in place and persists', async () => { loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) const candidate = (port: number, id: string) => [ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7f8a9a8f960..21b1ff28dc6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1024,11 +1024,13 @@ function normalizeSshTarget(t: SshTarget): SshTarget { const legacySyncEnabled = target.remoteWorkspaceSyncEnabled const currentGracePeriodSeconds = target.relayGracePeriodSeconds const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds + const systemSshConnectionReuse = target.systemSshConnectionReuse // Why: remote workspace sync now follows the SSH relay lifecycle, so the // retired per-target sync opt-out and grace-period fields stop at disk load. delete target.remoteWorkspaceSyncEnabled delete target.remoteWorkspaceSyncGracePeriodSeconds delete target.relayGracePeriodSeconds + delete target.systemSshConnectionReuse // Why: synced legacy targets ignored stale relayGracePeriodSeconds values. // Prefer the synced grace so a user's "unlimited" (0) survives migration. const relayGracePeriodSeconds = @@ -1047,6 +1049,9 @@ function normalizeSshTarget(t: SshTarget): SshTarget { ) { normalized.relayGracePeriodSeconds = relayGracePeriodSeconds } + if (systemSshConnectionReuse === false) { + normalized.systemSshConnectionReuse = false + } return normalized } @@ -5673,7 +5678,14 @@ export class Store { if (!target) { return null } - Object.assign(target, updates, normalizeSshTarget({ ...target, ...updates })) + const normalized = normalizeSshTarget({ ...target, ...updates }) + Object.assign(target, updates, normalized) + if (!Object.hasOwn(normalized, 'relayGracePeriodSeconds')) { + delete target.relayGracePeriodSeconds + } + if (!Object.hasOwn(normalized, 'systemSshConnectionReuse')) { + delete target.systemSshConnectionReuse + } this.scheduleSave() return { ...target } } diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index 48069cca6c9..5999c1c96d2 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -508,4 +508,25 @@ describe('parseSshGOutput', () => { const result = parseSshGOutput(output) expect(result.identitiesOnly).toBe(true) }) + + it('parses controlmaster options and filters controlpath none', () => { + const output = [ + 'hostname example.com', + 'controlmaster auto', + 'controlpath ~/.ssh/cm/%r@%h:%p', + 'controlpersist 10m', + 'port 22' + ].join('\n') + const result = parseSshGOutput(output) + expect(result.controlMaster).toBe('auto') + expect(result.controlPath).toBe(testHomePath('.ssh', 'cm', '%r@%h:%p')) + expect(result.controlPersist).toBe('10m') + + const noneResult = parseSshGOutput( + 'hostname example.com\ncontrolmaster no\ncontrolpath none\ncontrolpersist no\nport 22' + ) + expect(noneResult.controlMaster).toBe('no') + expect(noneResult.controlPath).toBeUndefined() + expect(noneResult.controlPersist).toBe('no') + }) }) diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 532e9e4e960..5bea9189598 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -1,10 +1,10 @@ import { existsSync } from 'node:fs' -import { execFile } from 'node:child_process' import { join } from 'node:path' import { homedir } from 'node:os' import type { SshTarget } from '../../shared/ssh-types' import { expandSshConfigIncludes } from './ssh-config-include-expander' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +export { parseSshGOutput, resolveWithSshG, type SshResolvedConfig } from './ssh-g-config-resolution' export type SshConfigHost = { host: string @@ -256,104 +256,3 @@ export function sshConfigHostsToTargets( return targets } -// ── ssh -G config resolution ────────────────────────────────────────── - -export type SshResolvedConfig = { - hostname: string - user?: string - port: number - identityFile: string[] - identityAgent?: string - identitiesOnly: boolean - forwardAgent: boolean - proxyCommand?: string - proxyUseFdpass: boolean - proxyJump?: string -} - -const SSH_G_TIMEOUT_MS = 5000 - -// Why: `ssh -G ` asks OpenSSH to resolve the full effective config -// for a host, including Include directives, Match blocks, wildcard -// inheritance, and ProxyCommand expansion. This gives us correct config -// resolution without reimplementing OpenSSH's complex matching logic. -export function resolveWithSshG(host: string): Promise { - return new Promise((resolve) => { - let settled = false - let child: ReturnType | undefined - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - child?.kill() - resolve(null) - }, SSH_G_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - // Why: '--' prevents a host label starting with '-' from being interpreted - // as an SSH flag (classic argument injection vector). - // Why: execFile's timeout only signals ssh; a stuck callback must still - // release SSH import/connection resolution with the existing null fallback. - try { - child = execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { - if (err) { - settle(() => resolve(null)) - return - } - settle(() => resolve(parseSshGOutput(stdout))) - }) - } catch { - settle(() => resolve(null)) - } - }) -} - -export function parseSshGOutput(stdout: string): SshResolvedConfig { - const map = new Map() - const identityFiles: string[] = [] - - for (const line of stdout.split('\n')) { - const spaceIdx = line.indexOf(' ') - if (spaceIdx === -1) { - continue - } - const key = line.substring(0, spaceIdx).toLowerCase() - const value = line.substring(spaceIdx + 1).trim() - if (key === 'identityfile') { - identityFiles.push(resolveSshConfigHomePath(value)) - } else { - map.set(key, value) - } - } - - // Why: `ssh -G` outputs `proxycommand none` / `proxyjump none` when no - // proxy is configured. Treating "none" as real would spawn bad commands. - const rawProxy = map.get('proxycommand') - const proxyCommand = rawProxy && rawProxy !== 'none' ? rawProxy : undefined - const rawJump = map.get('proxyjump') - const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined - const rawIdentityAgent = map.get('identityagent') - const identityAgent = rawIdentityAgent ? resolveSshConfigHomePath(rawIdentityAgent) : undefined - - return { - hostname: map.get('hostname') ?? '', - user: map.get('user') || undefined, - port: parseInt(map.get('port') ?? '22', 10), - identityFile: identityFiles, - identityAgent, - identitiesOnly: map.get('identitiesonly') === 'yes', - forwardAgent: map.get('forwardagent') === 'yes', - proxyCommand, - proxyUseFdpass: map.get('proxyusefdpass') === 'yes', - proxyJump - } -} diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index fd1eda0bc40..ed10780ca3d 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -326,6 +326,8 @@ function makeResolved(overrides?: Partial): SshResolvedConfig forwardAgent: false, identitiesOnly: false, proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', ...overrides } } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 0efbea7d9fa..ade239d56f6 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -116,24 +116,30 @@ vi.mock('ssh2', () => { } }) -const { spawnSystemSshCommandMock } = vi.hoisted(() => ({ +const { + getOrcaControlSocketPathMock, + removeControlSocketPathMock, + spawnSystemSshCommandMock, + spawnSystemSshMock +} = vi.hoisted(() => ({ + getOrcaControlSocketPathMock: vi.fn(), + removeControlSocketPathMock: vi.fn(), + spawnSystemSshMock: vi.fn(), spawnSystemSshCommandMock: vi.fn() })) vi.mock('./ssh-system-fallback', () => ({ - spawnSystemSsh: vi.fn().mockReturnValue({ - stdin: {}, - stdout: {}, - stderr: {}, - kill: vi.fn(), - onExit: vi.fn(), - pid: 99999 - }), + getOrcaControlSocketPath: getOrcaControlSocketPathMock, + spawnSystemSsh: spawnSystemSshMock, spawnSystemSshCommand: spawnSystemSshCommandMock, uploadDirectoryViaSystemSsh: vi.fn(), writeFileViaSystemSsh: vi.fn() })) +vi.mock('./ssh-control-socket', () => ({ + removeControlSocketPath: removeControlSocketPathMock +})) + vi.mock('./ssh-config-parser', () => ({ resolveWithSshG: vi.fn().mockResolvedValue(null) })) @@ -144,7 +150,7 @@ import { shouldUseSystemSshTransport, type SshConnectionCallbacks } from './ssh-connection' -import { resolveWithSshG } from './ssh-config-parser' +import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' @@ -160,6 +166,20 @@ function createTarget(overrides?: Partial): SshTarget { } } +function createResolvedConfig(overrides?: Partial): SshResolvedConfig { + return { + hostname: 'example.com', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: true, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + function createCallbacks(overrides?: Partial): SshConnectionCallbacks { return { onStateChange: vi.fn(), @@ -187,6 +207,51 @@ function createSystemCommandChannel(): EventEmitter & { return channel } +function createFailingSystemCommandChannel( + code: number, + stderrText = '' +): ReturnType { + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn() + queueMicrotask(() => { + if (stderrText) { + channel.stderr.emit('data', Buffer.from(stderrText)) + } + channel.emit('close', code) + }) + return channel +} + +function createPendingSystemSshProcess() { + const stdout = new EventEmitter() + return { + stdin: {}, + stdout, + stderr: new EventEmitter(), + kill: vi.fn(), + onExit: vi.fn(), + pid: 99999 + } +} + +function createSystemSshProcess() { + const proc = createPendingSystemSshProcess() + queueMicrotask(() => { + proc.stdout.emit('data', Buffer.from('ORCA-SYSTEM-SSH-READY')) + }) + return proc +} + +function createFailingSystemSshProcess(code: number) { + const proc = createPendingSystemSshProcess() + proc.onExit = vi.fn((handler: (exitCode: number | null) => void) => { + queueMicrotask(() => handler(code)) + }) + return proc +} + describe('SshConnection', () => { beforeEach(() => { eventHandlers = new Map() @@ -200,6 +265,11 @@ describe('SshConnection', () => { sftpBehavior = 'callback' pendingSftpCallback = null clientInstances = [] + getOrcaControlSocketPathMock.mockReset() + getOrcaControlSocketPathMock.mockReturnValue(null) + removeControlSocketPathMock.mockReset() + spawnSystemSshMock.mockReset() + spawnSystemSshMock.mockImplementation(() => createSystemSshProcess()) spawnSystemSshCommandMock.mockReset() spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel()) vi.mocked(uploadDirectoryViaSystemSsh).mockReset() @@ -674,14 +744,7 @@ describe('SshConnection', () => { }) it('uses system SSH transport when ProxyUseFdpass is resolved by OpenSSH', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce({ - hostname: 'example.com', - port: 22, - identityFile: [], - forwardAgent: false, - identitiesOnly: false, - proxyUseFdpass: true - }) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) await conn.connect() @@ -692,7 +755,62 @@ describe('SshConnection', () => { expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( expect.objectContaining({ configHost: 'fdpass-host' }), 'echo ORCA-SYSTEM-SSH-OK', - { wrapCommand: false } + { + wrapCommand: false, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + } + ) + }) + + it('retries a failed system SSH probe without ControlMaster and disables mux for the session', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + spawnSystemSshCommandMock + .mockImplementationOnce(() => createFailingSystemCommandChannel(255, 'mux client failed')) + .mockImplementation(() => createSystemCommandChannel()) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connect() + await conn.exec('echo after-connect') + await conn.writeFile('/tmp/after-connect', 'contents') + + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ configHost: 'fdpass-host' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ + wrapCommand: false, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ configHost: 'fdpass-host' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ + disableControlMaster: true, + wrapCommand: false, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ configHost: 'fdpass-host' }), + 'echo after-connect', + expect.objectContaining({ + disableControlMaster: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(writeFileViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + '/tmp/after-connect', + 'contents', + expect.objectContaining({ + disableControlMaster: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) ) }) @@ -757,14 +875,7 @@ describe('SshConnection', () => { }) it('passes the detected host platform to system SSH file operations', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce({ - hostname: 'example.com', - port: 22, - identityFile: [], - forwardAgent: false, - identitiesOnly: false, - proxyUseFdpass: true - }) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) const hostPlatform = getRemoteHostPlatform('win32-x64') @@ -780,13 +891,19 @@ describe('SshConnection', () => { expect.objectContaining({ configHost: 'fdpass-host' }), '/tmp/local-relay', 'C:/Users/me/.orca-remote/relay', - expect.objectContaining({ hostPlatform }) + expect.objectContaining({ + hostPlatform, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) ) expect(writeFileViaSystemSsh).toHaveBeenCalledWith( expect.objectContaining({ configHost: 'fdpass-host' }), 'C:/Users/me/.orca-remote/relay/.version', '0.1.0', - expect.objectContaining({ hostPlatform }) + expect.objectContaining({ + hostPlatform, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) ) }) @@ -797,14 +914,7 @@ describe('SshConnection', () => { channel.stderr = new EventEmitter() channel.close = vi.fn() spawnSystemSshCommandMock.mockReturnValueOnce(channel) - vi.mocked(resolveWithSshG).mockResolvedValueOnce({ - hostname: 'example.com', - port: 22, - identityFile: [], - forwardAgent: false, - identitiesOnly: false, - proxyUseFdpass: true - }) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) try { @@ -824,6 +934,163 @@ describe('SshConnection', () => { vi.useRealTimers() } }) + + it('passes resolved OpenSSH config to direct system SSH connections', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connectViaSystemSsh() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(spawnSystemSshMock).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + { + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + } + ) + }) + + it('retries direct system SSH connections without ControlMaster after mux startup failure', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + spawnSystemSshMock + .mockReturnValueOnce(createFailingSystemSshProcess(255)) + .mockImplementation(() => createSystemSshProcess()) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connectViaSystemSsh() + + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + expect(spawnSystemSshMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ configHost: 'fdpass-host' }), + { + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + } + ) + expect(spawnSystemSshMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ configHost: 'fdpass-host' }), + { + disableControlMaster: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + } + ) + }) + + it('kills delayed direct system SSH startup on disconnect and ignores late stdout', async () => { + const proc = createPendingSystemSshProcess() + spawnSystemSshMock.mockReturnValueOnce(proc) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), callbacks) + + const connectResult = conn.connectViaSystemSsh().catch((err: Error) => err) + for (let i = 0; i < 5 && spawnSystemSshMock.mock.calls.length === 0; i++) { + await Promise.resolve() + } + expect(spawnSystemSshMock).toHaveBeenCalledTimes(1) + + await conn.disconnect() + + expect(proc.kill).toHaveBeenCalled() + proc.stdout.emit('data', Buffer.from('ORCA-SYSTEM-SSH-READY')) + + await expect(connectResult).resolves.toMatchObject({ + message: 'SSH connection attempt was cancelled' + }) + expect(conn.getState().status).toBe('disconnected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'connected' }) + ) + }) + + it('treats delayed direct system SSH exit after disconnect as cancellation', async () => { + const proc = createPendingSystemSshProcess() + let capturedExit: ((exitCode: number | null) => void) | null = null + proc.onExit = vi.fn((handler: (exitCode: number | null) => void) => { + capturedExit = handler + }) + proc.kill = vi.fn(() => { + queueMicrotask(() => capturedExit?.(null)) + }) + spawnSystemSshMock.mockReturnValueOnce(proc) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), callbacks) + + const connectResult = conn.connectViaSystemSsh().catch((err: Error) => err) + for (let i = 0; i < 5 && spawnSystemSshMock.mock.calls.length === 0; i++) { + await Promise.resolve() + } + expect(spawnSystemSshMock).toHaveBeenCalledTimes(1) + + await conn.disconnect() + + const result = await connectResult + expect(result).toBeInstanceOf(Error) + if (!(result instanceof Error)) { + throw new Error('Expected direct system SSH startup to reject') + } + expect(result.message).toBe('SSH connection attempt was cancelled') + expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null }) + expect(conn.usesSystemSshTransport()).toBe(false) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'error' }) + ) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'connected' }) + ) + }) + + it('does not spawn direct system SSH after disconnect while OpenSSH config is resolving', async () => { + let resolveConfig!: (config: SshResolvedConfig | null) => void + vi.mocked(resolveWithSshG).mockReturnValueOnce( + new Promise((resolve) => { + resolveConfig = resolve + }) + ) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + const connectResult = conn.connectViaSystemSsh().catch((err: Error) => err) + await Promise.resolve() + await conn.disconnect() + resolveConfig(createResolvedConfig()) + + await expect(connectResult).resolves.toMatchObject({ + message: 'SSH connection attempt was cancelled' + }) + expect(spawnSystemSshMock).not.toHaveBeenCalled() + expect(conn.getState().status).toBe('disconnected') + }) + + it('does not spawn direct system SSH retry after cancellation between mux failure and retry', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + const firstProc = createPendingSystemSshProcess() + let conn!: SshConnection + firstProc.onExit = vi.fn((handler: (exitCode: number | null) => void) => { + queueMicrotask(() => { + handler(255) + void conn.disconnect() + }) + }) + spawnSystemSshMock + .mockReturnValueOnce(firstProc) + .mockImplementation(() => createSystemSshProcess()) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + const result = await conn.connectViaSystemSsh().catch((err: Error) => err) + + expect(result).toMatchObject({ message: 'SSH connection attempt was cancelled' }) + expect(spawnSystemSshMock).toHaveBeenCalledTimes(1) + expect(conn.getState().status).toBe('disconnected') + }) }) describe('shouldUseSystemSshTransport', () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 56fa80bff54..bdc7543c5fc 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -5,13 +5,16 @@ import type { ChildProcess } from 'node:child_process' import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2' import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' import { + getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, uploadDirectoryViaSystemSsh, writeFileViaSystemSsh, + type SystemSshBuildArgsOptions, type SystemSshProcess } from './ssh-system-fallback' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' +import { removeControlSocketPath } from './ssh-control-socket' import { INITIAL_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY_MS, @@ -37,12 +40,21 @@ type SshRemoteFileOptions = { hostPlatform?: RemoteHostPlatform } +function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null { + if (!config) { + return null + } + return { ...config, identityFile: [...config.identityFile] } +} + export class SshConnection { private client: SshClient | null = null private proxyProcess: ChildProcess | null = null private systemSsh: SystemSshProcess | null = null private systemCommandChannels = new Set() private systemOperationAbortController = new AbortController() + private systemSshResolvedConfig: SshResolvedConfig | null = null + private systemSshControlMasterDisabledForSession = false private useSystemSshTransport = false private state: SshConnectionState private callbacks: SshConnectionCallbacks @@ -76,6 +88,9 @@ export class SshConnection { getTarget(): SshTarget { return { ...this.target } } + getSystemSshResolvedConfig(): SshResolvedConfig | null { + return cloneResolvedConfig(this.systemSshResolvedConfig) + } setCallbacks(callbacks: SshConnectionCallbacks): void { this.callbacks = callbacks @@ -186,7 +201,8 @@ export class SshConnection { } await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() }) } @@ -239,7 +255,8 @@ export class SshConnection { } await writeFileViaSystemSsh(this.target, remotePath, contents, { signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() }) } @@ -288,9 +305,11 @@ export class SshConnection { () => null ) if (shouldUseSystemSshTransport(this.target, resolved)) { - await this.doSystemSshProbe(connectGeneration) + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false const config = buildConnectConfig(this.target, resolved) @@ -325,9 +344,11 @@ export class SshConnection { try { // Why: on macOS, per-app network policy can block Orca's direct // TCP socket while the system OpenSSH binary is still allowed. - await this.doSystemSshProbe(connectGeneration) + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } catch { + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false this.useSystemSshTransport = false throw err } @@ -517,15 +538,163 @@ export class SshConnection { }) } catch (err) { this.useSystemSshTransport = false + this.systemSshResolvedConfig = null throw err } } + private async doSystemSshProbeWithControlMasterRetry( + connectGeneration: number, + resolved: SshResolvedConfig | null + ): Promise { + this.systemSshResolvedConfig = cloneResolvedConfig(resolved) + this.systemSshControlMasterDisabledForSession = false + const controlPath = getOrcaControlSocketPath(this.target, { + resolvedConfig: this.systemSshResolvedConfig + }) + try { + await this.doSystemSshProbe(connectGeneration) + } catch (err) { + if (!controlPath || this.disposed || connectGeneration !== this.connectGeneration) { + throw err + } + removeControlSocketPath(controlPath) + this.systemSshResolvedConfig = cloneResolvedConfig(resolved) + this.systemSshControlMasterDisabledForSession = true + try { + await this.doSystemSshProbe(connectGeneration) + } catch (retryErr) { + this.systemSshControlMasterDisabledForSession = false + throw retryErr + } + } + } + + private async spawnSystemSshWithControlMasterRetry( + controlPath: string | null, + connectGeneration: number + ): Promise { + try { + return await this.spawnAndWaitForSystemSsh(connectGeneration) + } catch (err) { + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + if (!controlPath) { + throw err + } + removeControlSocketPath(controlPath) + this.systemSshControlMasterDisabledForSession = true + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + try { + return await this.spawnAndWaitForSystemSsh(connectGeneration) + } catch (retryErr) { + if (this.isCurrentConnectAttempt(connectGeneration)) { + this.systemSshControlMasterDisabledForSession = false + } + throw retryErr + } + } + } + + private async spawnAndWaitForSystemSsh(connectGeneration: number): Promise { + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + const proc = spawnSystemSsh(this.target, this.getSystemSshBuildArgsOptions()) + this.systemSsh = proc + let settled = false + await new Promise((resolve, reject) => { + let timeout: ReturnType + const clearCurrentProcess = (): void => { + if (this.systemSsh === proc) { + this.systemSsh = null + } + } + const cleanup = (): void => { + clearTimeout(timeout) + proc.stdout.off('data', onReady) + } + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + cleanup() + callback() + } + const cancelStartup = (): void => { + clearCurrentProcess() + proc.kill() + reject(this.createCancelledConnectAttemptError()) + } + const onReady = (): void => { + // Why: direct system SSH startup has the same late-ready race as ssh2; + // disconnect/reconnect must own the generation before state can flip. + if (!this.isCurrentConnectAttempt(connectGeneration)) { + settle(cancelStartup) + return + } + settle(resolve) + } + timeout = setTimeout(() => { + settle(() => { + clearCurrentProcess() + proc.kill() + reject(new Error('System SSH connection timed out')) + }) + }, CONNECT_TIMEOUT_MS) + proc.stdout.once('data', onReady) + proc.onExit((code) => { + if (settled) { + return + } + settle(() => { + clearCurrentProcess() + if (!this.isCurrentConnectAttempt(connectGeneration)) { + reject(this.createCancelledConnectAttemptError()) + return + } + reject( + new Error( + code !== 0 + ? `System SSH exited with code ${code}` + : 'System SSH exited before producing output' + ) + ) + }) + }) + }) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + if (this.systemSsh === proc) { + this.systemSsh = null + } + proc.kill() + throw this.createCancelledConnectAttemptError() + } + return proc + } + + private isCurrentConnectAttempt(connectGeneration: number): boolean { + return !this.disposed && connectGeneration === this.connectGeneration + } + + private createCancelledConnectAttemptError(): Error { + return new Error('SSH connection attempt was cancelled') + } + private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel { + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + const commandOptions = + options === undefined && Object.keys(buildArgsOptions).length === 0 + ? undefined + : { ...options, ...buildArgsOptions } const channel = - options === undefined + commandOptions === undefined ? spawnSystemSshCommand(this.target, command) - : spawnSystemSshCommand(this.target, command, options) + : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) const cleanup = (): void => { this.systemCommandChannels.delete(channel) @@ -535,6 +704,17 @@ export class SshConnection { return channel } + private getSystemSshBuildArgsOptions(): SystemSshBuildArgsOptions { + const options: SystemSshBuildArgsOptions = {} + if (this.systemSshResolvedConfig) { + options.resolvedConfig = this.systemSshResolvedConfig + } + if (this.systemSshControlMasterDisabledForSession) { + options.disableControlMaster = true + } + return options + } + // Why: ssh2 may destroy the proxy socket on auth failure, so credential // retries need a fresh proxy process and Duplex stream. private respawnProxy( @@ -710,6 +890,8 @@ export class SshConnection { this.systemCommandChannels.clear() this.systemSsh?.kill() this.systemSsh = null + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false this.useSystemSshTransport = false } @@ -717,39 +899,34 @@ export class SshConnection { if (this.disposed) { throw new Error('Connection disposed') } + const connectGeneration = ++this.connectGeneration this.systemSsh?.kill() this.systemSsh = null + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false + this.useSystemSshTransport = false this.setState('connecting') try { - const proc = spawnSystemSsh(this.target) - this.systemSsh = proc - let settled = false - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - settled = true - proc.kill() - reject(new Error('System SSH connection timed out')) - }, CONNECT_TIMEOUT_MS) - proc.stdout.once('data', () => { - settled = true - clearTimeout(timeout) - resolve() - }) - proc.onExit((code) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - reject( - new Error( - code !== 0 - ? `System SSH exited with code ${code}` - : 'System SSH exited before producing output' - ) - ) - }) + const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( + () => null + ) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + this.systemSshResolvedConfig = cloneResolvedConfig(resolved) + const controlPath = getOrcaControlSocketPath(this.target, { + resolvedConfig: this.systemSshResolvedConfig }) + const proc = await this.spawnSystemSshWithControlMasterRetry(controlPath, connectGeneration) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + if (this.systemSsh === proc) { + this.systemSsh = null + } + proc.kill() + throw this.createCancelledConnectAttemptError() + } + this.systemSsh = proc + this.useSystemSshTransport = true this.setState('connected') // Why: register reconnection handler only after the initial handshake // succeeds. The onExit registered above guards with `settled` so it @@ -762,6 +939,12 @@ export class SshConnection { }) return proc } catch (err) { + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw err + } + this.useSystemSshTransport = false + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false this.setState('error', err instanceof Error ? err.message : String(err)) throw err } @@ -788,6 +971,8 @@ export class SshConnection { this.systemCommandChannels.clear() this.systemSsh?.kill() this.systemSsh = null + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false this.useSystemSshTransport = false this.setState('disconnected') } diff --git a/src/main/ssh/ssh-control-socket.test.ts b/src/main/ssh/ssh-control-socket.test.ts new file mode 100644 index 00000000000..98ae45f0d34 --- /dev/null +++ b/src/main/ssh/ssh-control-socket.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' + +const { lstatSyncMock, mkdirSyncMock, tmpdirMock } = vi.hoisted(() => ({ + lstatSyncMock: vi.fn(), + mkdirSyncMock: vi.fn(), + tmpdirMock: vi.fn() +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + lstatSync: lstatSyncMock, + mkdirSync: mkdirSyncMock + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + tmpdir: tmpdirMock + } +}) + +import { getControlSocketPath, type SystemSshResolvedConfig } from './ssh-control-socket' + +const CURRENT_UID = process.getuid?.() ?? 501 + +function createTarget(overrides?: Partial): SshTarget { + return { + id: 'target-1', + label: 'Test Server', + configHost: 'devpod', + host: '10.0.0.5', + port: 22, + username: 'deploy', + ...overrides + } +} + +function createResolved(overrides?: Partial): SystemSshResolvedConfig { + return { + hostname: '10.0.0.5', + port: 22, + user: 'deploy', + identityFile: ['/Users/me/.ssh/id_ed25519'], + forwardAgent: false, + identitiesOnly: true, + proxyUseFdpass: true, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +describe.skipIf(process.platform === 'win32')('getControlSocketPath', () => { + beforeEach(() => { + vi.unstubAllEnvs() + vi.stubEnv('XDG_RUNTIME_DIR', '') + tmpdirMock.mockReset() + tmpdirMock.mockReturnValue('/tmp') + mkdirSyncMock.mockReset() + lstatSyncMock.mockReset() + lstatSyncMock.mockReturnValue({ + isDirectory: () => true, + uid: CURRENT_UID, + mode: 0o40700 + }) + }) + + it('returns a stable short private socket path for the same effective target', () => { + const first = getControlSocketPath(createTarget(), createResolved()) + const second = getControlSocketPath(createTarget(), createResolved()) + + expect(first).toBe(second) + expect(first).toMatch(new RegExp(`/orca-ssh-${CURRENT_UID}/[0-9a-f]{16}$`)) + // Why: OpenSSH creates a temporary mux listener by appending a suffix first. + expect(first!.length).toBeLessThanOrEqual(90) + expect(mkdirSyncMock).toHaveBeenCalledWith(`/tmp/orca-ssh-${CURRENT_UID}`, { + recursive: true, + mode: 0o700 + }) + }) + + it('changes the path when a config-backed target resolves to a different host', () => { + const before = getControlSocketPath(createTarget({ host: '10.0.0.5' }), createResolved()) + const after = getControlSocketPath( + createTarget({ host: '10.0.0.9' }), + createResolved({ hostname: '10.0.0.9' }) + ) + + expect(after).not.toBe(before) + }) + + it('changes the path when fresh ssh config resolution changes the route', () => { + const before = getControlSocketPath( + createTarget(), + createResolved({ proxyCommand: 'ssh -W %h:%p old-bastion' }) + ) + const after = getControlSocketPath( + createTarget(), + createResolved({ proxyCommand: 'ssh -W %h:%p new-bastion' }) + ) + + expect(after).not.toBe(before) + }) + + it('uses XDG_RUNTIME_DIR before tmp when it is absolute and private', () => { + vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/501') + + const path = getControlSocketPath(createTarget(), createResolved()) + + expect(path).toMatch(/^\/run\/user\/501\/orca-ssh\/[0-9a-f]{16}$/) + }) + + it('falls back to non-multiplexed SSH when the control directory is unsafe', () => { + lstatSyncMock.mockReturnValue({ + isDirectory: () => false, + uid: CURRENT_UID, + mode: 0o40700 + }) + + expect(getControlSocketPath(createTarget(), createResolved())).toBeNull() + }) +}) diff --git a/src/main/ssh/ssh-control-socket.ts b/src/main/ssh/ssh-control-socket.ts new file mode 100644 index 00000000000..5cbd0fdec5a --- /dev/null +++ b/src/main/ssh/ssh-control-socket.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto' +import { lstatSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join as pathJoin } from 'node:path' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './ssh-config-parser' + +export type SystemSshResolvedConfig = Pick< + SshResolvedConfig, + | 'hostname' + | 'port' + | 'user' + | 'identityFile' + | 'identityAgent' + | 'identitiesOnly' + | 'forwardAgent' + | 'proxyCommand' + | 'proxyJump' + | 'proxyUseFdpass' + | 'controlMaster' + | 'controlPath' + | 'controlPersist' +> + +const OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET = 18 +const UNIX_SOCKET_PATH_LIMIT = process.platform === 'darwin' ? 104 : 108 +const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_SOCKET_SUFFIX_BUDGET + +export function getControlSocketPath( + target: SshTarget, + resolvedConfig?: SystemSshResolvedConfig | null +): string | null { + if (process.platform === 'win32') { + return null + } + const uid = process.getuid?.() + if (uid === undefined) { + return null + } + + const dir = findControlSocketDirectory(uid) + if (!dir) { + return null + } + + // Why: include both persisted target fields and fresh ssh -G output so a + // live ControlPersist master is not reused after config-backed routes change. + const key = JSON.stringify({ + target: { + id: target.id, + configHost: target.configHost || '', + host: target.host || '', + port: target.port || 22, + user: target.username || '', + proxyCommand: target.proxyCommand || '', + jumpHost: target.jumpHost || '', + identityFile: target.identityFile || '', + identityAgent: target.identityAgent || '', + identitiesOnly: target.identitiesOnly || false + }, + resolved: normalizeResolvedConfig(resolvedConfig) + }) + const hash = createHash('sha256').update(key).digest('hex').slice(0, 16) + const socketPath = pathJoin(dir, hash) + return socketPath.length <= CONTROL_SOCKET_PATH_MAX_LENGTH ? socketPath : null +} + +export function removeControlSocketPath(socketPath: string): void { + try { + rmSync(socketPath, { force: true }) + } catch { + // Best-effort stale socket cleanup; the retry can still use `-S none`. + } +} + +function findControlSocketDirectory(uid: number): string | null { + const candidates = getControlSocketDirectoryCandidates(uid) + for (const dir of candidates) { + if (ensurePrivateDirectory(dir, uid)) { + return dir + } + } + return null +} + +function getControlSocketDirectoryCandidates(uid: number): string[] { + const candidates: string[] = [] + const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR + if (xdgRuntimeDir && isAbsolute(xdgRuntimeDir)) { + candidates.push(pathJoin(xdgRuntimeDir, 'orca-ssh')) + } + candidates.push(pathJoin(tmpdir(), `orca-ssh-${uid}`)) + return candidates +} + +function ensurePrivateDirectory(dir: string, uid: number): boolean { + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + // Why: mkdir mode is ignored for pre-existing dirs; lstat rejects symlink + // swaps and the owner/perms check avoids exposing the mux socket cross-user. + const st = lstatSync(dir) + return st.isDirectory() && st.uid === uid && (st.mode & 0o77) === 0 + } catch { + return false + } +} + +function normalizeResolvedConfig( + resolvedConfig: SystemSshResolvedConfig | null | undefined +): Record | null { + if (!resolvedConfig) { + return null + } + return { + hostname: resolvedConfig.hostname || '', + port: resolvedConfig.port || 22, + user: resolvedConfig.user || '', + identityFile: resolvedConfig.identityFile ?? [], + identityAgent: resolvedConfig.identityAgent || '', + identitiesOnly: resolvedConfig.identitiesOnly || false, + forwardAgent: resolvedConfig.forwardAgent || false, + proxyCommand: resolvedConfig.proxyCommand || '', + proxyJump: resolvedConfig.proxyJump || '', + proxyUseFdpass: resolvedConfig.proxyUseFdpass || false, + controlMaster: resolvedConfig.controlMaster || 'no', + controlPath: resolvedConfig.controlPath || '', + controlPersist: resolvedConfig.controlPersist || 'no' + } +} diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts new file mode 100644 index 00000000000..a1b951cf7a2 --- /dev/null +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -0,0 +1,116 @@ +import { execFile } from 'node:child_process' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' + +export type SshResolvedConfig = { + hostname: string + user?: string + port: number + identityFile: string[] + identityAgent?: string + identitiesOnly: boolean + forwardAgent: boolean + proxyCommand?: string + proxyUseFdpass: boolean + proxyJump?: string + controlMaster: string + controlPath?: string + controlPersist: string +} + +const SSH_G_TIMEOUT_MS = 5000 + +// Why: `ssh -G ` asks OpenSSH for the effective config, including +// Include/Match/wildcard inheritance, without reimplementing OpenSSH matching. +export function resolveWithSshG(host: string): Promise { + return new Promise((resolve) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + resolve(null) + }, SSH_G_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: '--' prevents host labels starting with '-' from becoming SSH flags. + // execFile's timeout only signals ssh; keep the null fallback for stuck callbacks. + try { + child = execFile('ssh', ['-G', '--', host], { timeout: SSH_G_TIMEOUT_MS }, (err, stdout) => { + if (err) { + settle(() => resolve(null)) + return + } + settle(() => resolve(parseSshGOutput(stdout))) + }) + } catch { + settle(() => resolve(null)) + } + }) +} + +export function parseSshGOutput(stdout: string): SshResolvedConfig { + const map = new Map() + const identityFiles: string[] = [] + + for (const line of stdout.split('\n')) { + const spaceIdx = line.indexOf(' ') + if (spaceIdx === -1) { + continue + } + const key = line.substring(0, spaceIdx).toLowerCase() + const value = line.substring(spaceIdx + 1).trim() + if (key === 'identityfile') { + identityFiles.push(resolveSshConfigHomePath(value)) + } else { + map.set(key, value) + } + } + + return buildSshResolvedConfig(map, identityFiles) +} + +function buildSshResolvedConfig( + map: Map, + identityFiles: string[] +): SshResolvedConfig { + // Why: `ssh -G` outputs `proxycommand none` / `proxyjump none` when no + // proxy is configured. Treating "none" as real would spawn bad commands. + const rawProxy = map.get('proxycommand') + const proxyCommand = rawProxy && rawProxy !== 'none' ? rawProxy : undefined + const rawJump = map.get('proxyjump') + const proxyJump = rawJump && rawJump !== 'none' ? rawJump : undefined + const rawIdentityAgent = map.get('identityagent') + const identityAgent = rawIdentityAgent ? resolveSshConfigHomePath(rawIdentityAgent) : undefined + const rawControlPath = map.get('controlpath') + const controlPath = + rawControlPath && rawControlPath !== 'none' + ? resolveSshConfigHomePath(rawControlPath) + : undefined + + return { + hostname: map.get('hostname') ?? '', + user: map.get('user') || undefined, + port: parseInt(map.get('port') ?? '22', 10), + identityFile: identityFiles, + identityAgent, + identitiesOnly: map.get('identitiesonly') === 'yes', + forwardAgent: map.get('forwardagent') === 'yes', + proxyCommand, + proxyUseFdpass: map.get('proxyusefdpass') === 'yes', + proxyJump, + controlMaster: map.get('controlmaster') ?? 'no', + controlPath, + controlPersist: map.get('controlpersist') ?? 'no' + } +} diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 699de30469b..31e26a5bc40 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -42,6 +42,7 @@ function createSystemSshConn() { return { getClient: vi.fn().mockReturnValue(null), usesSystemSshTransport: vi.fn().mockReturnValue(true), + getSystemSshResolvedConfig: vi.fn().mockReturnValue(null), getTarget: vi.fn().mockReturnValue({ id: 'target-1', label: 'container', @@ -164,6 +165,38 @@ describe('SshPortForwardManager', () => { expect(manager.listForwards('conn-1')).toHaveLength(1) }) + it('passes resolved OpenSSH config to system SSH port forwards', async () => { + const forward = createFakeSystemSshForward() + startSystemSshPortForwardProcessMock.mockReturnValue(forward) + const conn = createSystemSshConn() + conn.getSystemSshResolvedConfig.mockReturnValue({ + hostname: 'resolved.example.com', + port: 2222, + user: 'vscode', + identityFile: ['/home/user/.ssh/work'], + forwardAgent: false, + identitiesOnly: true, + proxyUseFdpass: true, + controlMaster: 'no', + controlPersist: 'no' + }) + + await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080) + + expect(startSystemSshPortForwardProcessMock).toHaveBeenCalledWith( + conn.getTarget(), + 3000, + '127.0.0.1', + 8080, + { + resolvedConfig: expect.objectContaining({ + hostname: 'resolved.example.com', + proxyUseFdpass: true + }) + } + ) + }) + it('does not register a system SSH forward when startup fails', async () => { const forward = createFakeSystemSshForward() forward.waitForStartup.mockRejectedValue(new Error('bind: Address already in use')) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index b98d6d083f2..5bec0bb00ad 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -33,6 +33,7 @@ import { import { spawnSystemSshPortForward } from './system-ssh-forward-process' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' +import type { SystemSshResolvedConfig } from './ssh-control-socket' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' @@ -52,6 +53,28 @@ function createTarget(overrides?: Partial): SshTarget { } } +function createResolvedConfig( + overrides?: Partial +): SystemSshResolvedConfig { + return { + hostname: 'example.com', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +function expectNoOrcaControlMasterArgs(args: string[]): void { + expect(args).not.toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(false) + expect(args).not.toContain('ControlPersist=300') +} + type EventedProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType @@ -243,6 +266,123 @@ describe('spawnSystemSsh', () => { expect(args).not.toContain('ProxyCommand=ignored') }) + it('passes explicit options for manual targets with implicit configHost', () => { + const args = buildSshArgs( + createTarget({ + source: 'manual', + configHost: '127.0.0.1', + host: '127.0.0.1', + port: 2222, + identityFile: '/tmp/orca-docker-key', + identitiesOnly: true + }) + ) + + expect(args).toEqual(expect.arrayContaining(['-p', '2222', '-i', '/tmp/orca-docker-key'])) + expect(args).toContain('IdentitiesOnly=yes') + expect(args).toContain('deploy@127.0.0.1') + }) + + it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { + resolvedConfig: createResolvedConfig({ + controlMaster: 'auto', + controlPath: '/Users/me/.ssh/cm/%r@%h:%p', + controlPersist: '10m' + }) + }) + + expectNoOrcaControlMasterArgs(args) + expect(args).not.toContain('-S') + expect(args).toContain('deploy@workbox') + }) + + it('injects Orca ControlMaster flags when ssh config only sets ControlPersist', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { + resolvedConfig: createResolvedConfig({ + controlMaster: 'no', + controlPersist: '10m' + }) + }) + + expect(args).toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') + expect(args).not.toContain('-S') + }) + + it('injects Orca ControlMaster flags when ssh config only sets ControlPath', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { + resolvedConfig: createResolvedConfig({ + controlMaster: 'no', + controlPath: '/Users/me/.ssh/cm/%r@%h:%p' + }) + }) + + expect(args).toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') + expect(args).not.toContain('-S') + }) + + it('injects Orca ControlMaster flags when ssh config omits ControlPath', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { + resolvedConfig: createResolvedConfig({ + controlMaster: 'auto' + }) + }) + + expect(args).toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') + expect(args).not.toContain('-S') + }) + + it('does not inject Orca ControlMaster flags for unresolved ssh-config targets', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' })) + + expectNoOrcaControlMasterArgs(args) + expect(args).not.toContain('-S') + expect(args).toContain('deploy@workbox') + }) + + it('does not inject Orca ControlMaster flags for unresolved legacy config aliases', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', host: 'resolved.example.com' })) + + expectNoOrcaControlMasterArgs(args) + expect(args).not.toContain('-S') + expect(args).toContain('deploy@workbox') + }) + + it('can inject Orca ControlMaster flags for ssh-config targets with resolved config', () => { + const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { + resolvedConfig: createResolvedConfig() + }) + + expect(args).toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') + expect(args).not.toContain('-S') + }) + + it('forces standalone SSH when target connection reuse is disabled', () => { + const args = buildSshArgs(createTarget({ systemSshConnectionReuse: false })) + const standaloneControlIdx = args.indexOf('-S') + + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + expectNoOrcaControlMasterArgs(args) + }) + + it('adds keepalive options to Orca-owned ControlMaster connections', () => { + const args = buildSshArgs(createTarget(), { resolvedConfig: createResolvedConfig() }) + + expect(args).toContain('ControlMaster=auto') + expect(args).toContain('ControlPersist=300') + expect(args).toContain('ServerAliveInterval=15') + expect(args).toContain('ServerAliveCountMax=3') + }) + it('spawns a remote command through the system ssh target', () => { spawnSystemSshCommand(createTarget({ configHost: 'fdpass-host' }), 'echo hello') @@ -256,20 +396,29 @@ describe('spawnSystemSsh', () => { it('spawns port forwards before the ssh destination terminator', () => { spawnSystemSshPortForward(createTarget({ configHost: 'fdpass-host' }), 5173, '127.0.0.1', 3000) + const args = spawnMock.mock.calls[0][1] as string[] + const terminatorIdx = args.indexOf('--') + const forwardFlagIdx = args.indexOf('-N') + const localForwardIdx = args.indexOf('-L') + const exitOnForwardFailureIdx = args.indexOf('ExitOnForwardFailure=yes') + const standaloneControlIdx = args.indexOf('-S') + + expect(terminatorIdx).toBeGreaterThan(-1) + expect(forwardFlagIdx).toBeGreaterThan(-1) + expect(localForwardIdx).toBeGreaterThan(-1) + expect(exitOnForwardFailureIdx).toBeGreaterThan(-1) + // Why: -N and -L must appear before -- or OpenSSH treats them as remote command args. + expect(forwardFlagIdx).toBeLessThan(terminatorIdx) + expect(localForwardIdx).toBeLessThan(terminatorIdx) + expect(args[exitOnForwardFailureIdx - 1]).toBe('-o') + expect(exitOnForwardFailureIdx).toBeLessThan(terminatorIdx) + expect(standaloneControlIdx).toBe(-1) + expectNoOrcaControlMasterArgs(args) + expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') + expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, - [ - '-o', - 'BatchMode=no', - '-T', - '-N', - '-o', - 'ExitOnForwardFailure=yes', - '-L', - '127.0.0.1:5173:127.0.0.1:3000', - '--', - 'deploy@fdpass-host' - ], + expect.any(Array), expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }) ) }) @@ -325,6 +474,22 @@ describe('spawnSystemSsh', () => { expect(proc.stderr.listenerCount('data')).toBe(0) }) + it('forces standalone SSH for POSIX file writes when requested', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeFileViaSystemSsh(createTarget(), '/tmp/file', 'contents', { + disableControlMaster: true + }) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + const standaloneControlIdx = args.indexOf('-S') + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + }) + it('writes files to Windows system SSH targets with PowerShell stdin bytes', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) @@ -346,6 +511,26 @@ describe('spawnSystemSsh', () => { expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8')) }) + it('forces standalone SSH for Windows file writes when requested', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const hostPlatform = getRemoteHostPlatform('win32-x64') + + const promise = writeFileViaSystemSsh( + createTarget(), + 'C:/Users/me/.orca-remote/relay/.version', + '0.1.0', + { hostPlatform, disableControlMaster: true } + ) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + const standaloneControlIdx = args.indexOf('-S') + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + }) + it('uploads directories to Windows system SSH targets in one PowerShell batch', async () => { const localDir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) writeFileSync(join(localDir, 'relay.js'), 'console.log("relay")') @@ -390,6 +575,32 @@ describe('spawnSystemSsh', () => { ) }) + it('forces standalone SSH for Windows upload packages when requested', async () => { + const localDir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) + writeFileSync(join(localDir, 'relay.js'), 'console.log("relay")') + spawnMock.mockImplementation(() => { + const proc = createEventedProcess() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc + }) + + try { + await uploadDirectoryViaSystemSsh( + createTarget(), + localDir, + 'C:/Users/me/.orca-remote/relay', + { hostPlatform: getRemoteHostPlatform('win32-x64'), disableControlMaster: true } + ) + } finally { + rmSync(localDir, { recursive: true, force: true }) + } + + const args = spawnMock.mock.calls[0][1] as string[] + const standaloneControlIdx = args.indexOf('-S') + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + }) + it('throws when no system ssh is found', () => { existsSyncMock.mockReturnValue(false) expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts index d3d2f4fd730..01c3d978c72 100644 --- a/src/main/ssh/ssh-system-fallback.ts +++ b/src/main/ssh/ssh-system-fallback.ts @@ -1,602 +1,8 @@ -/* eslint-disable max-lines -- Why: system-ssh process wrapping and fallback file operations share cleanup contracts. */ -import { spawn, type ChildProcess } from 'node:child_process' -import { constants } from 'node:fs' -import { existsSync } from 'node:fs' -import { lstat, open, readdir } from 'node:fs/promises' -import { join as pathJoin } from 'node:path' -import { Duplex } from 'node:stream' -import { pipeline } from 'node:stream/promises' -import type { ClientChannel } from 'ssh2' -import type { SshTarget } from '../../shared/ssh-types' -import { wrapRemoteCommandForPosixShell, shellEscape } from './ssh-connection-utils' -import type { SshExecOptions } from './ssh-connection-utils' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' - -const SYSTEM_SSH_PATHS = - process.platform === 'win32' - ? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe'] - : ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] - -export type SystemSshProcess = { - stdin: NodeJS.WritableStream - stdout: NodeJS.ReadableStream - stderr: NodeJS.ReadableStream - kill: () => void - onExit: (cb: (code: number | null) => void) => void - pid: number | undefined -} - -type SystemSshCommandChannel = ClientChannel & { - _process?: ChildProcess -} - -type SystemSshOperationOptions = { - signal?: AbortSignal - hostPlatform?: RemoteHostPlatform -} - -/** - * Find the system ssh binary path. Returns null if not found. - */ -export function findSystemSsh(): string | null { - if (process.env.ORCA_SYSTEM_SSH_PATH) { - return process.env.ORCA_SYSTEM_SSH_PATH - } - for (const candidate of SYSTEM_SSH_PATHS) { - if (existsSync(candidate)) { - return candidate - } - } - return null -} - -/** - * Spawn a system ssh process connecting to the given target. - * Used when ssh2 cannot handle the auth method (FIDO2, ControlMaster). - * - * The returned process's stdin/stdout are used as the transport for - * the relay's JSON-RPC protocol, exactly like an ssh2 channel. - */ -export function spawnSystemSsh(target: SshTarget): SystemSshProcess { - const sshPath = findSystemSsh() - if (!sshPath) { - throw new Error( - 'No system ssh binary found. Install OpenSSH to use FIDO2 keys or ControlMaster.' - ) - } - - const args = buildSshArgs(target) - const proc = spawn(sshPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - - return wrapChildProcess(proc) -} - -export function spawnSystemSshCommand( - target: SshTarget, - command: string, - options?: SshExecOptions -): ClientChannel { - const sshPath = findSystemSsh() - if (!sshPath) { - throw new Error( - 'No system ssh binary found. Install OpenSSH to use ProxyUseFdpass, FIDO2 keys, or ControlMaster.' - ) - } - - const remoteCommand = - options?.wrapCommand === false ? command : wrapRemoteCommandForPosixShell(command) - const proc = spawn(sshPath, [...buildSshArgs(target), remoteCommand], { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) - return wrapCommandProcess(proc) -} - -export async function uploadDirectoryViaSystemSsh( - target: SshTarget, - localDir: string, - remoteDir: string, - options?: SystemSshOperationOptions -): Promise { - throwIfAborted(options?.signal) - if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - await uploadDirectoryViaSystemSshWindows(target, localDir, remoteDir, options) - return - } - - const sshPath = findSystemSsh() - if (!sshPath) { - throw new Error('No system ssh binary found. Install OpenSSH to use system SSH transport.') - } - - const tarCreate = spawn('tar', ['-czf', '-', '-C', localDir, '.'], { - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) - const remoteCommand = `mkdir -p ${shellEscape(remoteDir)} && tar -xzf - -C ${shellEscape(remoteDir)}` - const sshExtract = spawn( - sshPath, - [...buildSshArgs(target), wrapRemoteCommandForPosixShell(remoteCommand)], - { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - } - ) - - let tarResult: ProcessResult | null = null - let sshResult: ProcessResult | null = null - try { - ;[tarResult, sshResult] = await awaitWithSystemSshAbort( - options?.signal, - () => { - killProcess(tarCreate) - killProcess(sshExtract) - }, - Promise.all([ - waitForProcess(tarCreate, 'local tar relay upload'), - waitForProcess(sshExtract, 'system ssh relay upload'), - pipeline(tarCreate.stdout!, sshExtract.stdin!) - ]).then(([tar, ssh]) => [tar, ssh] as const) - ) - } catch (err) { - killProcess(tarCreate) - killProcess(sshExtract) - throw err - } - - if (tarResult?.stderr.trim()) { - console.warn(`[ssh-system] ${tarResult.label} stderr: ${tarResult.stderr.trim()}`) - } - if (sshResult?.stderr.trim()) { - console.warn(`[ssh-system] ${sshResult.label} stderr: ${sshResult.stderr.trim()}`) - } -} - -export async function writeFileViaSystemSsh( - target: SshTarget, - remotePath: string, - contents: string, - options?: SystemSshOperationOptions -): Promise { - throwIfAborted(options?.signal) - if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - await writeBufferViaSystemSshWindows( - target, - remotePath, - Buffer.from(contents, 'utf-8'), - options - ) - return - } - - const channel = spawnSystemSshCommand(target, `cat > ${shellEscape(remotePath)}`) - const closePromise = awaitWithSystemSshAbort( - options?.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options?.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise -} - -async function uploadDirectoryViaSystemSshWindows( - target: SshTarget, - localDir: string, - remoteDir: string, - options: SystemSshOperationOptions -): Promise { - const hostPlatform = options.hostPlatform - if (!hostPlatform) { - throw new Error('Windows system SSH upload requires a remote host platform') - } - const entries = await collectWindowsUploadEntries( - localDir, - remoteDir, - hostPlatform, - options.signal - ) - await writeWindowsUploadPackageViaSystemSsh(target, entries, options) -} - -type WindowsUploadEntry = - | { - kind: 'directory' - path: string - } - | { - kind: 'file' - path: string - contentsBase64: string - } - -async function collectWindowsUploadEntries( - localDir: string, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal: AbortSignal | undefined -): Promise { - const entries: WindowsUploadEntry[] = [{ kind: 'directory', path: remoteDir }] - const dirEntries = await readdir(localDir, { withFileTypes: true }) - for (const entry of dirEntries) { - throwIfAborted(signal) - const localPath = pathJoin(localDir, entry.name) - const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) - const statResult = await lstat(localPath) - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - if (statResult.isDirectory()) { - entries.push( - ...(await collectWindowsUploadEntries(localPath, remotePath, hostPlatform, signal)) - ) - continue - } - const buffer = await readLocalUploadFile(localPath, statResult) - entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) - } - return entries -} - -async function writeWindowsUploadPackageViaSystemSsh( - target: SshTarget, - entries: WindowsUploadEntry[], - options: SystemSshOperationOptions -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsUploadPackageCommand(), { - wrapCommand: false - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, 'windows relay upload') - ) - if (!options.signal?.aborted) { - channel.stdin.end(JSON.stringify(entries)) - } - await closePromise -} - -async function readLocalUploadFile( - localPath: string, - statResult: Awaited> -): Promise { - const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) - try { - const openedStat = await handle.stat() - if ( - !openedStat.isFile() || - openedStat.size !== statResult.size || - (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || - (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) - ) { - throw new Error(`File changed during upload: ${localPath}`) - } - return await handle.readFile() - } finally { - await handle.close() - } -} - -async function writeBufferViaSystemSshWindows( - target: SshTarget, - remotePath: string, - contents: Buffer, - options: SystemSshOperationOptions -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), { - wrapCommand: false - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise -} - -function makeWindowsWriteFileCommand(remotePath: string): string { - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - `$path = ${powerShellLiteral(remotePath)}`, - '$parent = [System.IO.Path]::GetDirectoryName($path)', - 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', - '$inputStream = [Console]::OpenStandardInput()', - '$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', - 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' - ].join('; ') - ) -} - -function makeWindowsUploadPackageCommand(): string { - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - '$json = [Console]::In.ReadToEnd()', - 'if ([string]::IsNullOrWhiteSpace($json)) { return }', - '$items = $json | ConvertFrom-Json', - 'foreach ($item in @($items)) {', - ' $path = [string]$item.path', - ' if ($item.kind -eq "directory") {', - ' $null = [System.IO.Directory]::CreateDirectory($path)', - ' continue', - ' }', - ' $parent = [System.IO.Path]::GetDirectoryName($path)', - ' if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', - ' [System.IO.File]::WriteAllBytes($path, [Convert]::FromBase64String([string]$item.contentsBase64))', - '}' - ].join('; ') - ) -} - -export function buildSshArgs(target: SshTarget): string[] { - const args: string[] = [] - - args.push('-o', 'BatchMode=no') - // Forward stdin/stdout for relay communication - args.push('-T') - - // Why: configHost preserves OpenSSH-only directives such as ProxyUseFdpass. - // Passing resolved host/port/proxy flags would bypass the user's Host block. - const useConfigHost = Boolean(target.configHost) - - if (!useConfigHost && target.port !== 22) { - args.push('-p', String(target.port)) - } - - if (!useConfigHost && target.identityFile) { - args.push('-i', target.identityFile) - } - - if (!useConfigHost && target.identityAgent) { - args.push('-o', `IdentityAgent=${target.identityAgent}`) - } - - if (!useConfigHost && target.identitiesOnly) { - args.push('-o', 'IdentitiesOnly=yes') - } - - if (!useConfigHost && target.jumpHost) { - args.push('-J', target.jumpHost) - } - - if (!useConfigHost && target.proxyCommand) { - args.push('-o', `ProxyCommand=${target.proxyCommand}`) - } - - const host = target.configHost || target.host - const userHost = target.username ? `${target.username}@${host}` : host - args.push('--', userHost) - - return args -} - -function wrapChildProcess(proc: ChildProcess): SystemSshProcess { - return { - stdin: proc.stdin!, - stdout: proc.stdout!, - stderr: proc.stderr!, - pid: proc.pid, - kill: () => { - try { - proc.kill('SIGTERM') - } catch { - // Process may already be dead - } - }, - onExit: (cb) => { - proc.on('exit', (code) => cb(code)) - } - } -} - -function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { - const duplex = new Duplex({ - read() {}, - write(chunk, encoding, cb) { - proc.stdin!.write(chunk, encoding, cb) - } - }) - const channel = duplex as unknown as SystemSshCommandChannel - - const mutableChannel = channel as unknown as { - stdin: NodeJS.WritableStream - stderr: NodeJS.ReadableStream - _process?: ChildProcess - close: () => void - } - mutableChannel.stdin = proc.stdin! - mutableChannel.stderr = proc.stderr! - mutableChannel._process = proc - mutableChannel.close = () => { - try { - proc.kill('SIGTERM') - } catch { - // Process may already be dead - } - } - - const cleanupProcessListeners = (): void => { - proc.stdout!.off('data', onStdoutData) - proc.stdout!.off('end', onStdoutEnd) - proc.off('exit', onExit) - proc.off('close', onClose) - proc.off('error', onProcessError) - proc.stdin!.off('error', onStreamError) - proc.stdout!.off('error', onStreamError) - } - const fail = (err: Error): void => { - cleanupProcessListeners() - duplex.destroy(err) - } - const onStdoutData = (data: Buffer): void => { - duplex.push(data) - } - const onStdoutEnd = (): void => { - duplex.push(null) - } - const onExit = (code: number | null, signal?: NodeJS.Signals | null): void => { - channel.emit('exit', code, signal) - } - const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { - cleanupProcessListeners() - channel.emit('close', code, signal) - } - const onProcessError = (err: Error): void => { - fail(err) - } - const onStreamError = (err: Error): void => { - fail(err) - } - - proc.stdout!.on('data', onStdoutData) - proc.stdout!.on('end', onStdoutEnd) - proc.on('exit', onExit) - proc.on('close', onClose) - proc.on('error', onProcessError) - proc.stdin!.on('error', onStreamError) - proc.stdout!.on('error', onStreamError) - - return channel -} - -function waitForChannelClose(channel: SystemSshCommandChannel, label: string): Promise { - return new Promise((resolve, reject) => { - let stderr = '' - const cleanup = (): void => { - channel.stderr.off('data', onStderrData) - channel.off('error', onError) - channel.off('close', onClose) - } - const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { - cleanup() - fn(val as never) - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') - } - const onError = (err: Error): void => { - settle(reject, err) - } - const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { - if (code !== 0) { - const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) - return - } - settle(resolve) - } - - channel.stderr.on('data', onStderrData) - channel.on('error', onError) - channel.on('close', onClose) - }) -} - -type ProcessResult = { label: string; stderr: string } - -function waitForProcess(proc: ChildProcess, label: string): Promise { - return new Promise((resolve, reject) => { - let stderr = '' - const cleanup = (): void => { - proc.stderr?.off('data', onStderrData) - proc.off('error', onError) - proc.off('close', onClose) - } - const settle = (fn: typeof resolve | typeof reject, val: ProcessResult | Error): void => { - cleanup() - fn(val as never) - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') - } - const onError = (err: Error): void => { - settle(reject, err) - } - const onClose = (code: number | null): void => { - if (code !== 0) { - settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) - return - } - settle(resolve, { label, stderr }) - } - - proc.stderr?.on('data', onStderrData) - proc.on('error', onError) - proc.on('close', onClose) - }) -} - -function killProcess(proc: ChildProcess): void { - if (proc.exitCode !== null || proc.killed) { - return - } - try { - proc.kill('SIGTERM') - } catch { - // Process may already be dead - } -} - -async function awaitWithSystemSshAbort( - signal: AbortSignal | undefined, - abortChildren: () => void, - operation: Promise -): Promise { - if (!signal) { - return operation - } - let abortReject: ((error: Error) => void) | null = null - let suppressLateOperationError = false - const abortPromise = new Promise((_resolve, reject) => { - abortReject = reject - }) - const abort = (): void => { - // Why: abort is connection teardown; do not wait for stubborn system ssh/tar - // children to emit close after we've already signaled them. - abortChildren() - suppressLateOperationError = true - abortReject?.(createAbortError()) - } - signal.addEventListener('abort', abort, { once: true }) - if (signal.aborted) { - abort() - } - try { - return await Promise.race([ - operation.catch((error: unknown) => { - if (suppressLateOperationError) { - return new Promise(() => {}) - } - throw error - }), - abortPromise - ]) - } finally { - signal.removeEventListener('abort', abort) - } -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (!signal?.aborted) { - return - } - throw createAbortError() -} - -function createAbortError(): Error & { name: string } { - const error = new Error('System SSH operation was cancelled') as Error & { name: string } - error.name = 'AbortError' - return error -} +export { findSystemSsh } from './system-ssh-binary' +export { + buildSshArgs, + getOrcaControlSocketPath, + type SystemSshBuildArgsOptions +} from './system-ssh-args' +export { spawnSystemSsh, spawnSystemSshCommand, type SystemSshProcess } from './system-ssh-command' +export { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './system-ssh-file-transfer' diff --git a/src/main/ssh/system-ssh-args.ts b/src/main/ssh/system-ssh-args.ts new file mode 100644 index 00000000000..8e812a0b68b --- /dev/null +++ b/src/main/ssh/system-ssh-args.ts @@ -0,0 +1,161 @@ +import type { SshTarget } from '../../shared/ssh-types' +import { getControlSocketPath, type SystemSshResolvedConfig } from './ssh-control-socket' + +export type SystemSshBuildArgsOptions = { + resolvedConfig?: SystemSshResolvedConfig | null + disableControlMaster?: boolean + suppressOrcaControlMaster?: boolean +} + +export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] { + const args: string[] = [] + + args.push('-o', 'BatchMode=no') + // Forward stdin/stdout for relay communication + args.push('-T') + + // Why: ControlMaster multiplexes all SSH exec commands over a single connection, + // eliminating the ~9s handshake overhead per command. Without this, each + // spawnSystemSshCommand call opens a new TCP connection. + const forceDisableControlMaster = + options?.disableControlMaster === true || target.systemSshConnectionReuse === false + const controlPath = getOrcaControlSocketPath(target, options) + if (forceDisableControlMaster) { + // Why: muxed OpenSSH forwards remain registered on the master after the + // client exits. Also honors the per-target compatibility opt-out even if + // a broad Host * ssh_config block enables multiplexing. + args.push('-S', 'none') + } else if (controlPath) { + args.push('-o', 'ControlMaster=auto') + args.push('-o', `ControlPath=${controlPath}`) + // Why: keep master alive 300s after last command so rapid reconnects + // (e.g. on tab focus) skip re-handshake without holding a process open. + args.push('-o', 'ControlPersist=300') + args.push('-o', 'ServerAliveInterval=15') + args.push('-o', 'ServerAliveCountMax=3') + } + + const useConfigHost = shouldUseOpenSshConfigHost(target) + + if (!useConfigHost && target.port !== 22) { + args.push('-p', String(target.port)) + } + + if (!useConfigHost && target.identityFile) { + args.push('-i', target.identityFile) + } + + if (!useConfigHost && target.identityAgent) { + args.push('-o', `IdentityAgent=${target.identityAgent}`) + } + + if (!useConfigHost && target.identitiesOnly) { + args.push('-o', 'IdentitiesOnly=yes') + } + + if (!useConfigHost && target.jumpHost) { + args.push('-J', target.jumpHost) + } + + if (!useConfigHost && target.proxyCommand) { + args.push('-o', `ProxyCommand=${target.proxyCommand}`) + } + + const host = target.configHost || target.host + const userHost = target.username ? `${target.username}@${host}` : host + args.push('--', userHost) + + return args +} + +export function getOrcaControlSocketPath( + target: SshTarget, + options?: SystemSshBuildArgsOptions +): string | null { + if (shouldDisableOrcaControlMaster(target, options)) { + return null + } + return getControlSocketPath(target, options?.resolvedConfig) +} + +export function getSystemSshBuildArgsFromOperationOptions( + options: SystemSshBuildArgsOptions | undefined +): SystemSshBuildArgsOptions | undefined { + const buildArgsOptions: SystemSshBuildArgsOptions = {} + if (options?.resolvedConfig !== undefined) { + buildArgsOptions.resolvedConfig = options.resolvedConfig + } + if (options?.disableControlMaster === true) { + buildArgsOptions.disableControlMaster = true + } + if (options?.suppressOrcaControlMaster === true) { + buildArgsOptions.suppressOrcaControlMaster = true + } + return Object.keys(buildArgsOptions).length === 0 ? undefined : buildArgsOptions +} + +function shouldDisableOrcaControlMaster( + target: SshTarget, + options?: SystemSshBuildArgsOptions +): boolean { + // Why: unresolved ssh_config aliases could otherwise share one Orca socket + // while OpenSSH routes them through mutable HostName/ProxyJump settings. + const unresolvedConfigBackedTarget = + isOpenSshConfigBackedTarget(target) && options?.resolvedConfig == null + return ( + options?.disableControlMaster === true || + options?.suppressOrcaControlMaster === true || + target.systemSshConnectionReuse === false || + unresolvedConfigBackedTarget || + hasUserConfiguredControlMaster(options?.resolvedConfig) + ) +} + +function hasUserConfiguredControlMaster( + resolvedConfig: SystemSshResolvedConfig | null | undefined +): boolean { + if (!resolvedConfig) { + return false + } + // Why: ControlPersist/ControlPath alone can reuse a master someone else + // created, but they do not create the setup-burst master Orca needs. + return ( + hasEnabledControlMaster(resolvedConfig.controlMaster) && + hasEnabledControlPath(resolvedConfig.controlPath) + ) +} + +function hasEnabledControlMaster(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return ( + normalized != null && + normalized !== '' && + normalized !== '0' && + normalized !== 'no' && + normalized !== 'false' + ) +} + +function hasEnabledControlPath(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return normalized != null && normalized !== '' && normalized !== 'none' +} + +function shouldUseOpenSshConfigHost(target: SshTarget): boolean { + if (!target.configHost) { + return false + } + return isOpenSshConfigBackedTarget(target) +} + +function isOpenSshConfigBackedTarget(target: SshTarget): boolean { + if (target.source === 'ssh-config') { + return true + } + if (target.source === 'manual') { + return false + } + // Why: legacy imported aliases have a distinct configHost; manual targets + // historically stored configHost=host and still need explicit -p/-i args. + return Boolean(target.configHost && target.configHost !== target.host) +} diff --git a/src/main/ssh/system-ssh-binary.ts b/src/main/ssh/system-ssh-binary.ts new file mode 100644 index 00000000000..579b483392c --- /dev/null +++ b/src/main/ssh/system-ssh-binary.ts @@ -0,0 +1,21 @@ +import { existsSync } from 'node:fs' + +const SYSTEM_SSH_PATHS = + process.platform === 'win32' + ? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe'] + : ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] + +/** + * Find the system ssh binary path. Returns null if not found. + */ +export function findSystemSsh(): string | null { + if (process.env.ORCA_SYSTEM_SSH_PATH) { + return process.env.ORCA_SYSTEM_SSH_PATH + } + for (const candidate of SYSTEM_SSH_PATHS) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts new file mode 100644 index 00000000000..e3051201ac4 --- /dev/null +++ b/src/main/ssh/system-ssh-command.ts @@ -0,0 +1,159 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { Duplex } from 'node:stream' +import type { ClientChannel } from 'ssh2' +import type { SshTarget } from '../../shared/ssh-types' +import { wrapRemoteCommandForPosixShell, type SshExecOptions } from './ssh-connection-utils' +import { buildSshArgs, type SystemSshBuildArgsOptions } from './system-ssh-args' +import { findSystemSsh } from './system-ssh-binary' + +export type SystemSshProcess = { + stdin: NodeJS.WritableStream + stdout: NodeJS.ReadableStream + stderr: NodeJS.ReadableStream + kill: () => void + onExit: (cb: (code: number | null) => void) => void + pid: number | undefined +} + +export type SystemSshCommandChannel = ClientChannel & { + _process?: ChildProcess +} + +type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions + +/** + * Spawn a system ssh process connecting to the given target. + * Used when ssh2 cannot handle the auth method (FIDO2, ControlMaster). + * + * The returned process's stdin/stdout are used as the transport for + * the relay's JSON-RPC protocol, exactly like an ssh2 channel. + */ +export function spawnSystemSsh( + target: SshTarget, + options?: SystemSshBuildArgsOptions +): SystemSshProcess { + const sshPath = findSystemSsh() + if (!sshPath) { + throw new Error( + 'No system ssh binary found. Install OpenSSH to use FIDO2 keys or ControlMaster.' + ) + } + + const args = buildSshArgs(target, options) + const proc = spawn(sshPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + + return wrapChildProcess(proc) +} + +export function spawnSystemSshCommand( + target: SshTarget, + command: string, + options?: SystemSshCommandOptions +): SystemSshCommandChannel { + const sshPath = findSystemSsh() + if (!sshPath) { + throw new Error( + 'No system ssh binary found. Install OpenSSH to use ProxyUseFdpass, FIDO2 keys, or ControlMaster.' + ) + } + + const remoteCommand = + options?.wrapCommand === false ? command : wrapRemoteCommandForPosixShell(command) + const proc = spawn(sshPath, [...buildSshArgs(target, options), remoteCommand], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + return wrapCommandProcess(proc) +} + +function wrapChildProcess(proc: ChildProcess): SystemSshProcess { + return { + stdin: proc.stdin!, + stdout: proc.stdout!, + stderr: proc.stderr!, + pid: proc.pid, + kill: () => { + try { + proc.kill('SIGTERM') + } catch { + // Process may already be dead + } + }, + onExit: (cb) => { + proc.on('exit', (code) => cb(code)) + } + } +} + +function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { + const duplex = new Duplex({ + read() {}, + write(chunk, encoding, cb) { + proc.stdin!.write(chunk, encoding, cb) + } + }) + const channel = duplex as unknown as SystemSshCommandChannel + + const mutableChannel = channel as unknown as { + stdin: NodeJS.WritableStream + stderr: NodeJS.ReadableStream + _process?: ChildProcess + close: () => void + } + mutableChannel.stdin = proc.stdin! + mutableChannel.stderr = proc.stderr! + mutableChannel._process = proc + mutableChannel.close = () => { + try { + proc.kill('SIGTERM') + } catch { + // Process may already be dead + } + } + + const cleanupProcessListeners = (): void => { + proc.stdout!.off('data', onStdoutData) + proc.stdout!.off('end', onStdoutEnd) + proc.off('exit', onExit) + proc.off('close', onClose) + proc.off('error', onProcessError) + proc.stdin!.off('error', onStreamError) + proc.stdout!.off('error', onStreamError) + } + const fail = (err: Error): void => { + cleanupProcessListeners() + duplex.destroy(err) + } + const onStdoutData = (data: Buffer): void => { + duplex.push(data) + } + const onStdoutEnd = (): void => { + duplex.push(null) + } + const onExit = (code: number | null, signal?: NodeJS.Signals | null): void => { + channel.emit('exit', code, signal) + } + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { + cleanupProcessListeners() + channel.emit('close', code, signal) + } + const onProcessError = (err: Error): void => { + fail(err) + } + const onStreamError = (err: Error): void => { + fail(err) + } + + proc.stdout!.on('data', onStdoutData) + proc.stdout!.on('end', onStdoutEnd) + proc.on('exit', onExit) + proc.on('close', onClose) + proc.on('error', onProcessError) + proc.stdin!.on('error', onStreamError) + proc.stdout!.on('error', onStreamError) + + return channel +} diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts new file mode 100644 index 00000000000..345ab690920 --- /dev/null +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -0,0 +1,279 @@ +import { spawn } from 'node:child_process' +import { constants } from 'node:fs' +import { lstat, open, readdir } from 'node:fs/promises' +import { join as pathJoin } from 'node:path' +import { pipeline } from 'node:stream/promises' +import type { SshTarget } from '../../shared/ssh-types' +import { shellEscape, wrapRemoteCommandForPosixShell } from './ssh-connection-utils' +import { findSystemSsh } from './system-ssh-binary' +import { + buildSshArgs, + getSystemSshBuildArgsFromOperationOptions, + type SystemSshBuildArgsOptions +} from './system-ssh-args' +import { spawnSystemSshCommand } from './system-ssh-command' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + awaitWithSystemSshAbort, + killProcess, + throwIfAborted, + waitForChannelClose, + waitForProcess, + type ProcessResult +} from './system-ssh-operation-lifecycle' + +type SystemSshOperationOptions = SystemSshBuildArgsOptions & { + signal?: AbortSignal + hostPlatform?: RemoteHostPlatform +} + +export async function uploadDirectoryViaSystemSsh( + target: SshTarget, + localDir: string, + remoteDir: string, + options?: SystemSshOperationOptions +): Promise { + throwIfAborted(options?.signal) + if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { + await uploadDirectoryViaSystemSshWindows(target, localDir, remoteDir, options) + return + } + + const sshPath = findSystemSsh() + if (!sshPath) { + throw new Error('No system ssh binary found. Install OpenSSH to use system SSH transport.') + } + + const tarCreate = spawn('tar', ['-czf', '-', '-C', localDir, '.'], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + const remoteCommand = `mkdir -p ${shellEscape(remoteDir)} && tar -xzf - -C ${shellEscape(remoteDir)}` + const sshExtract = spawn( + sshPath, + [...buildSshArgs(target, options), wrapRemoteCommandForPosixShell(remoteCommand)], + { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + } + ) + + let tarResult: ProcessResult | null = null + let sshResult: ProcessResult | null = null + try { + ;[tarResult, sshResult] = await awaitWithSystemSshAbort( + options?.signal, + () => { + killProcess(tarCreate) + killProcess(sshExtract) + }, + Promise.all([ + waitForProcess(tarCreate, 'local tar relay upload'), + waitForProcess(sshExtract, 'system ssh relay upload'), + pipeline(tarCreate.stdout!, sshExtract.stdin!) + ]).then(([tar, ssh]) => [tar, ssh] as const) + ) + } catch (err) { + killProcess(tarCreate) + killProcess(sshExtract) + throw err + } + + if (tarResult?.stderr.trim()) { + console.warn(`[ssh-system] ${tarResult.label} stderr: ${tarResult.stderr.trim()}`) + } + if (sshResult?.stderr.trim()) { + console.warn(`[ssh-system] ${sshResult.label} stderr: ${sshResult.stderr.trim()}`) + } +} + +export async function writeFileViaSystemSsh( + target: SshTarget, + remotePath: string, + contents: string, + options?: SystemSshOperationOptions +): Promise { + throwIfAborted(options?.signal) + if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { + await writeBufferViaSystemSshWindows( + target, + remotePath, + Buffer.from(contents, 'utf-8'), + options + ) + return + } + + const channel = spawnSystemSshCommand( + target, + `cat > ${shellEscape(remotePath)}`, + getSystemSshBuildArgsFromOperationOptions(options) + ) + const closePromise = awaitWithSystemSshAbort( + options?.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options?.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +async function uploadDirectoryViaSystemSshWindows( + target: SshTarget, + localDir: string, + remoteDir: string, + options: SystemSshOperationOptions +): Promise { + const hostPlatform = options.hostPlatform + if (!hostPlatform) { + throw new Error('Windows system SSH upload requires a remote host platform') + } + const entries = await collectWindowsUploadEntries( + localDir, + remoteDir, + hostPlatform, + options.signal + ) + await writeWindowsUploadPackageViaSystemSsh(target, entries, options) +} + +type WindowsUploadEntry = + | { + kind: 'directory' + path: string + } + | { + kind: 'file' + path: string + contentsBase64: string + } + +async function collectWindowsUploadEntries( + localDir: string, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal: AbortSignal | undefined +): Promise { + const entries: WindowsUploadEntry[] = [{ kind: 'directory', path: remoteDir }] + const dirEntries = await readdir(localDir, { withFileTypes: true }) + for (const entry of dirEntries) { + throwIfAborted(signal) + const localPath = pathJoin(localDir, entry.name) + const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + if (statResult.isDirectory()) { + entries.push( + ...(await collectWindowsUploadEntries(localPath, remotePath, hostPlatform, signal)) + ) + continue + } + const buffer = await readLocalUploadFile(localPath, statResult) + entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) + } + return entries +} + +async function writeWindowsUploadPackageViaSystemSsh( + target: SshTarget, + entries: WindowsUploadEntry[], + options: SystemSshOperationOptions +): Promise { + throwIfAborted(options.signal) + const channel = spawnSystemSshCommand(target, makeWindowsUploadPackageCommand(), { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, 'windows relay upload') + ) + if (!options.signal?.aborted) { + channel.stdin.end(JSON.stringify(entries)) + } + await closePromise +} + +async function readLocalUploadFile( + localPath: string, + statResult: Awaited> +): Promise { + const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== statResult.size || + (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || + (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + return await handle.readFile() + } finally { + await handle.close() + } +} + +async function writeBufferViaSystemSshWindows( + target: SshTarget, + remotePath: string, + contents: Buffer, + options: SystemSshOperationOptions +): Promise { + throwIfAborted(options.signal) + const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +function makeWindowsWriteFileCommand(remotePath: string): string { + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$parent = [System.IO.Path]::GetDirectoryName($path)', + 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', + '$inputStream = [Console]::OpenStandardInput()', + '$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' + ].join('; ') + ) +} + +function makeWindowsUploadPackageCommand(): string { + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + '$json = [Console]::In.ReadToEnd()', + 'if ([string]::IsNullOrWhiteSpace($json)) { return }', + '$items = $json | ConvertFrom-Json', + 'foreach ($item in @($items)) {', + ' $path = [string]$item.path', + ' if ($item.kind -eq "directory") {', + ' $null = [System.IO.Directory]::CreateDirectory($path)', + ' continue', + ' }', + ' $parent = [System.IO.Path]::GetDirectoryName($path)', + ' if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', + ' [System.IO.File]::WriteAllBytes($path, [Convert]::FromBase64String([string]$item.contentsBase64))', + '}' + ].join('; ') + ) +} diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index 847c9a4ed52..4178d0e1796 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -61,6 +61,12 @@ function createTarget(overrides?: Partial): SshTarget { } } +function expectNoOrcaControlMasterArgs(args: string[]): void { + expect(args).not.toContain('ControlMaster=auto') + expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(false) + expect(args).not.toContain('ControlPersist=300') +} + function createFakeProcess(): FakeChildProcess { const child = new EventEmitter() as FakeChildProcess child.stderr = new EventEmitter() @@ -112,24 +118,83 @@ describe('system SSH forward process', () => { spawnSystemSshPortForward(createTarget({ configHost: 'fdpass-host' }), 5173, '127.0.0.1', 3000) + const args = spawnMock.mock.calls[0][1] as string[] + const terminatorIdx = args.indexOf('--') + const forwardFlagIdx = args.indexOf('-N') + const localForwardIdx = args.indexOf('-L') + const exitOnForwardFailureIdx = args.indexOf('ExitOnForwardFailure=yes') + const standaloneControlIdx = args.indexOf('-S') + + expect(terminatorIdx).toBeGreaterThan(-1) + expect(forwardFlagIdx).toBeGreaterThan(-1) + expect(localForwardIdx).toBeGreaterThan(-1) + expect(exitOnForwardFailureIdx).toBeGreaterThan(-1) + // Why: -N and -L must appear before -- or OpenSSH treats them as remote command args. + expect(forwardFlagIdx).toBeLessThan(terminatorIdx) + expect(localForwardIdx).toBeLessThan(terminatorIdx) + expect(args[exitOnForwardFailureIdx - 1]).toBe('-o') + expect(exitOnForwardFailureIdx).toBeLessThan(terminatorIdx) + expect(standaloneControlIdx).toBe(-1) + expectNoOrcaControlMasterArgs(args) + expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') + expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, - [ - '-o', - 'BatchMode=no', - '-T', - '-N', - '-o', - 'ExitOnForwardFailure=yes', - '-L', - '127.0.0.1:5173:127.0.0.1:3000', - '--', - 'deploy@fdpass-host' - ], + expect.any(Array), expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }) ) }) + it('suppresses Orca mux flags for port forwards without disabling ssh_config muxing', () => { + spawnMock.mockReturnValue(createFakeProcess()) + + spawnSystemSshPortForward(createTarget(), 5173, '127.0.0.1', 3000, { + resolvedConfig: { + hostname: 'example.com', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no' + } + }) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.indexOf('-S')).toBe(-1) + expectNoOrcaControlMasterArgs(args) + }) + + it('preserves user-configured muxing for port forwards', () => { + spawnMock.mockReturnValue(createFakeProcess()) + + spawnSystemSshPortForward( + createTarget({ configHost: 'workbox', source: 'ssh-config' }), + 5173, + '127.0.0.1', + 3000, + { + resolvedConfig: { + hostname: 'workbox.internal', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: false, + controlMaster: 'auto', + controlPath: '/Users/me/.ssh/cm/%r@%h:%p', + controlPersist: '10m' + } + } + ) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.indexOf('-S')).toBe(-1) + expectNoOrcaControlMasterArgs(args) + expect(args).toContain('deploy@workbox') + }) + it('preserves manual target port and identity options in the forwarded ssh command', () => { spawnMock.mockReturnValue(createFakeProcess()) diff --git a/src/main/ssh/system-ssh-forward-process.ts b/src/main/ssh/system-ssh-forward-process.ts index 88a27ec0fe8..c92d284d406 100644 --- a/src/main/ssh/system-ssh-forward-process.ts +++ b/src/main/ssh/system-ssh-forward-process.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from 'node:child_process' import { connect, createServer } from 'node:net' -import { buildSshArgs, findSystemSsh } from './ssh-system-fallback' +import { buildSshArgs, findSystemSsh, type SystemSshBuildArgsOptions } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750 @@ -18,7 +18,8 @@ export function spawnSystemSshPortForward( target: SshTarget, localPort: number, remoteHost: string, - remotePort: number + remotePort: number, + options?: SystemSshBuildArgsOptions ): ChildProcess { const sshPath = findSystemSsh() if (!sshPath) { @@ -27,7 +28,7 @@ export function spawnSystemSshPortForward( ) } - const args = buildSshArgs(target) + const args = buildSshArgs(target, { ...options, suppressOrcaControlMaster: true }) const destinationIndex = args.lastIndexOf('--') const forwardArgs = [ '-N', @@ -56,10 +57,11 @@ export function startSystemSshPortForwardProcess( target: SshTarget, localPort: number, remoteHost: string, - remotePort: number + remotePort: number, + options?: SystemSshBuildArgsOptions ): Promise { return assertLocalForwardPortAvailable(localPort).then(() => { - const process = spawnSystemSshPortForward(target, localPort, remoteHost, remotePort) + const process = spawnSystemSshPortForward(target, localPort, remoteHost, remotePort, options) return { process, waitForStartup: () => waitForSystemSshForwardStartup(process, localPort), diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts new file mode 100644 index 00000000000..76a2ae3fb12 --- /dev/null +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -0,0 +1,135 @@ +import type { ChildProcess } from 'node:child_process' +import type { SystemSshCommandChannel } from './system-ssh-command' + +export type ProcessResult = { label: string; stderr: string } + +export function waitForChannelClose( + channel: SystemSshCommandChannel, + label: string +): Promise { + return new Promise((resolve, reject) => { + let stderr = '' + const cleanup = (): void => { + channel.stderr.off('data', onStderrData) + channel.off('error', onError) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { + cleanup() + fn(val as never) + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onError = (err: Error): void => { + settle(reject, err) + } + const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { + if (code !== 0) { + const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` + settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + return + } + settle(resolve) + } + + channel.stderr.on('data', onStderrData) + channel.on('error', onError) + channel.on('close', onClose) + }) +} + +export function waitForProcess(proc: ChildProcess, label: string): Promise { + return new Promise((resolve, reject) => { + let stderr = '' + const cleanup = (): void => { + proc.stderr?.off('data', onStderrData) + proc.off('error', onError) + proc.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: ProcessResult | Error): void => { + cleanup() + fn(val as never) + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onError = (err: Error): void => { + settle(reject, err) + } + const onClose = (code: number | null): void => { + if (code !== 0) { + settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) + return + } + settle(resolve, { label, stderr }) + } + + proc.stderr?.on('data', onStderrData) + proc.on('error', onError) + proc.on('close', onClose) + }) +} + +export function killProcess(proc: ChildProcess): void { + if (proc.exitCode !== null || proc.killed) { + return + } + try { + proc.kill('SIGTERM') + } catch { + // Process may already be dead + } +} + +export async function awaitWithSystemSshAbort( + signal: AbortSignal | undefined, + abortChildren: () => void, + operation: Promise +): Promise { + if (!signal) { + return operation + } + let abortReject: ((error: Error) => void) | null = null + let suppressLateOperationError = false + const abortPromise = new Promise((_resolve, reject) => { + abortReject = reject + }) + const abort = (): void => { + // Why: abort is connection teardown; do not wait for stubborn system ssh/tar + // children to emit close after we've already signaled them. + abortChildren() + suppressLateOperationError = true + abortReject?.(createAbortError()) + } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + } + try { + return await Promise.race([ + operation.catch((error: unknown) => { + if (suppressLateOperationError) { + return new Promise(() => {}) + } + throw error + }), + abortPromise + ]) + } finally { + signal.removeEventListener('abort', abort) + } +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) { + return + } + throw createAbortError() +} + +function createAbortError(): Error & { name: string } { + const error = new Error('System SSH operation was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error +} diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts index ee61919809d..74227f67cfe 100644 --- a/src/main/ssh/system-ssh-port-forward-provider.ts +++ b/src/main/ssh/system-ssh-port-forward-provider.ts @@ -16,12 +16,21 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { async start(conn: SshConnection, options: PortForwardStartOptions): Promise { const target = conn.getTarget() - const forward = await startSystemSshPortForwardProcess( - target, - options.localPort, - options.remoteHost, - options.remotePort - ) + const resolvedConfig = conn.getSystemSshResolvedConfig() + const forward = resolvedConfig + ? await startSystemSshPortForwardProcess( + target, + options.localPort, + options.remoteHost, + options.remotePort, + { resolvedConfig } + ) + : await startSystemSshPortForwardProcess( + target, + options.localPort, + options.remoteHost, + options.remotePort + ) await forward.waitForStartup() let stderr = '' diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index cc95ed9caa2..3a00df1d04e 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' -import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import type { SshTarget } from '../../../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -10,12 +10,8 @@ import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove' import { SshTargetCard } from './SshTargetCard' import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' -import { - getEditingTargetForSshTarget, - getSshTargetDraftConnectionFields, - isRelayGracePeriodValid, - parseRelayGracePeriodSeconds -} from './ssh-target-draft' +import { getEditingTargetForSshTarget } from './ssh-target-draft' +import { buildSshTargetSavePayload } from './ssh-target-save-payload' import { translate } from '@/i18n/i18n' export { getSshPaneSearchEntries } from './ssh-search' @@ -77,65 +73,16 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { }, [loadTargets]) const handleSave = async (): Promise => { - const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) - if (!host) { - toast.error( - translate( - 'auto.components.settings.SshPane.0e5aa04161', - 'Host or SSH config alias is required' - ) - ) + const savePayload = buildSshTargetSavePayload(form) + if (!savePayload.ok) { + toast.error(savePayload.error) return } - if (isNaN(port) || port < 1 || port > 65535) { - toast.error( - translate('auto.components.settings.SshPane.4db9afce1c', 'Port must be between 1 and 65535') - ) - return - } - - const graceSeconds = parseRelayGracePeriodSeconds(form) - if (!isRelayGracePeriodValid(form, graceSeconds)) { - toast.error( - translate( - 'auto.components.settings.SshPane.3879cbaa52', - 'Terminal timeout must be between 60 and {{value0}} seconds, or keep terminals alive until reset.', - { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } - ) - ) - return - } - - const identityFile = form.identityFile.trim() || undefined - const proxyCommand = form.proxyCommand.trim() || undefined - const jumpHost = form.jumpHost.trim() || undefined - - const target = { - label: form.label.trim() || (username ? `${username}@${host}` : configHost), - configHost, - host, - port, - username, - relayGracePeriodSeconds: graceSeconds, - ...(identityFile ? { identityFile } : {}), - ...(proxyCommand ? { proxyCommand } : {}), - ...(jumpHost ? { jumpHost } : {}) - } - try { await (editingId - ? // Why: an explicit edit takes ownership of the target, so mark it - // `manual` — otherwise the next ~/.ssh/config sync would silently - // revert the user's change back to the config value. Carry the - // optional fields even when cleared (undefined) so removing e.g. a - // config-derived ProxyCommand actually deletes it — updateTarget - // merges partially, so an omitted key would keep the stale value. - window.api.ssh.updateTarget({ - id: editingId, - updates: { ...target, identityFile, proxyCommand, jumpHost, source: 'manual' } - }) - : window.api.ssh.addTarget({ target })) + ? window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates }) + : window.api.ssh.addTarget({ target: savePayload.payload.target })) recordFeatureInteraction('ssh') if (!mountedRef.current) { return diff --git a/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx b/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx new file mode 100644 index 00000000000..0bdbd3d3505 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx @@ -0,0 +1,106 @@ +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SettingsSwitch } from './SettingsFormControls' +import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection' +import type { EditingTarget } from './ssh-target-draft' +import { translate } from '@/i18n/i18n' + +type SshTargetAdvancedConnectionSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + form: EditingTarget + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void +} + +export function SshTargetAdvancedConnectionSection({ + open, + onOpenChange, + form, + onFormChange +}: SshTargetAdvancedConnectionSectionProps): React.JSX.Element { + return ( + +
+ + onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.f42d844544', + 'e.g. cloudflared access ssh --hostname %h' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.3b01ca44a0', + 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' + )} +

+
+
+ + onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.11bcb4507a', + 'bastion.example.com' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.feae1d1e69', + 'Optional. Equivalent to ProxyJump / ssh -J.' + )} +

+
+
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.53e9aabfc0', + 'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + systemSshConnectionReuse: !f.systemSshConnectionReuse + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.8c922dffba', + 'Reuse SSH connection for faster setup' + )} + /> +
+
+ ) +} + +export function hasAdvancedConnectionValues(form: EditingTarget): boolean { + return ( + form.proxyCommand.trim().length > 0 || + form.jumpHost.trim().length > 0 || + !form.systemSshConnectionReuse + ) +} diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index df8cf36c071..fbba5918e50 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -1,12 +1,13 @@ +import { useEffect, useRef, useState } from 'react' import { FileKey } from 'lucide-react' -import { - DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../../../shared/ssh-types' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { + SshTargetAdvancedConnectionSection, + hasAdvancedConnectionValues +} from './SshTargetAdvancedConnectionSection' +import { SshTargetTerminalPersistenceSection } from './SshTargetTerminalPersistenceSection' import { applyParsedSshHostInput, type EditingTarget } from './ssh-target-draft' import { translate } from '@/i18n/i18n' export { EMPTY_FORM, type EditingTarget } from './ssh-target-draft' @@ -26,6 +27,20 @@ export function SshTargetForm({ onSave, onCancel }: SshTargetFormProps): React.JSX.Element { + const hasAdvancedConnectionFields = hasAdvancedConnectionValues(form) + const [advancedConnectionOpen, setAdvancedConnectionOpen] = useState(hasAdvancedConnectionFields) + const [terminalPersistenceOpen, setTerminalPersistenceOpen] = useState(true) + const lastEditingIdRef = useRef(editingId) + + useEffect(() => { + if (lastEditingIdRef.current === editingId) { + return + } + lastEditingIdRef.current = editingId + setAdvancedConnectionOpen(hasAdvancedConnectionFields) + setTerminalPersistenceOpen(true) + }, [editingId, hasAdvancedConnectionFields]) + return (
-
- - onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.f42d844544', - 'e.g. cloudflared access ssh --hostname %h' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.3b01ca44a0', - 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' - )} -

-
-
- - onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.11bcb4507a', - 'bastion.example.com' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.feae1d1e69', - 'Optional. Equivalent to ProxyJump / ssh -J.' - )} -

-
-
-
- -

- {translate( - 'auto.components.settings.SshTargetForm.137e88ce8d', - 'Remote terminals keep running after Orca disconnects from this host.' - )} -

-
- -
- - - onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value })) - } - placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)} - min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} - max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} - disabled={form.relayKeepAliveUntilReset} - /> -
-

- {translate( - 'auto.components.settings.SshTargetForm.1b19b00e93', - 'Bounded timeouts must be between 60 seconds and 7 days.' - )} -

-
+ +
diff --git a/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx b/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx new file mode 100644 index 00000000000..13d9b3e784e --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx @@ -0,0 +1,50 @@ +import { ChevronDown } from 'lucide-react' +import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' +import { cn } from '@/lib/utils' + +type SshTargetFormCollapsibleSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + children: React.ReactNode +} + +export function SshTargetFormCollapsibleSection({ + open, + onOpenChange, + title, + description, + children +}: SshTargetFormCollapsibleSectionProps): React.JSX.Element { + return ( + + + + + +
{children}
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx b/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx new file mode 100644 index 00000000000..0306bc3d3d8 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx @@ -0,0 +1,98 @@ +import { + DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS +} from '../../../../shared/ssh-types' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SettingsSwitch } from './SettingsFormControls' +import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection' +import type { EditingTarget } from './ssh-target-draft' +import { translate } from '@/i18n/i18n' + +type SshTargetTerminalPersistenceSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + form: EditingTarget + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void +} + +export function SshTargetTerminalPersistenceSection({ + open, + onOpenChange, + form, + onFormChange +}: SshTargetTerminalPersistenceSectionProps): React.JSX.Element { + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.b574994adc', + 'Use End Remote Terminals or Reset Relay when you want to stop them.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.71fc546097', + 'Keep terminals alive until reset' + )} + /> +
+
+ + onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))} + placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)} + min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} + max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} + disabled={form.relayKeepAliveUntilReset} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.1b19b00e93', + 'Bounded timeouts must be between 60 seconds and 7 days.' + )} +

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/ssh-target-draft.test.ts b/src/renderer/src/components/settings/ssh-target-draft.test.ts index 8be60068d6f..5c988b29638 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.test.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.test.ts @@ -157,6 +157,7 @@ describe('getSshTargetDraftConnectionFields', () => { describe('getEditingTargetForSshTarget', () => { it('defaults new SSH targets to keep terminals alive until reset', () => { expect(EMPTY_FORM.relayKeepAliveUntilReset).toBe(true) + expect(EMPTY_FORM.relayGracePeriodSeconds).toBe('86400') expect(parseRelayGracePeriodSeconds(EMPTY_FORM)).toBe(0) }) @@ -202,6 +203,19 @@ describe('getEditingTargetForSshTarget', () => { }) }) + it('preserves explicit system SSH connection reuse opt-outs while editing', () => { + const draft = getEditingTargetForSshTarget({ + id: 'ssh-1', + label: 'Restricted appliance', + host: 'appliance.example.com', + port: 22, + username: 'admin', + systemSshConnectionReuse: false + }) + + expect(draft.systemSshConnectionReuse).toBe(false) + }) + it('uses the default persistence for targets without an explicit grace period', () => { const draft = getEditingTargetForSshTarget({ id: 'ssh-1', @@ -212,6 +226,21 @@ describe('getEditingTargetForSshTarget', () => { }) expect(draft.relayKeepAliveUntilReset).toBe(true) + expect(draft.relayGracePeriodSeconds).toBe('86400') expect(parseRelayGracePeriodSeconds(draft)).toBe(0) }) + + it('preserves explicit bounded relay grace periods when editing', () => { + const draft = getEditingTargetForSshTarget({ + id: 'ssh-1', + label: 'Bounded server', + host: 'server.example.com', + port: 22, + username: 'deploy', + relayGracePeriodSeconds: 600 + }) + + expect(draft.relayKeepAliveUntilReset).toBe(false) + expect(draft.relayGracePeriodSeconds).toBe('600') + }) }) diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index bac2dc100a3..a33e6eb5abc 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -15,6 +15,7 @@ export type EditingTarget = { identityFile: string proxyCommand: string jumpHost: string + systemSshConnectionReuse: boolean relayGracePeriodSeconds: string relayKeepAliveUntilReset: boolean } @@ -28,6 +29,7 @@ export const EMPTY_FORM: EditingTarget = { identityFile: '', proxyCommand: '', jumpHost: '', + systemSshConnectionReuse: true, relayGracePeriodSeconds: String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS), relayKeepAliveUntilReset: DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS === 0 } @@ -45,6 +47,7 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { identityFile: target.identityFile ?? '', proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', + systemSshConnectionReuse: target.systemSshConnectionReuse !== false, relayGracePeriodSeconds: String( target.relayGracePeriodSeconds === 0 ? DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts new file mode 100644 index 00000000000..11ee0c4888e --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { EMPTY_FORM } from './ssh-target-draft' +import { buildSshTargetSavePayload } from './ssh-target-save-payload' + +describe('buildSshTargetSavePayload', () => { + it('rejects empty hosts', () => { + const result = buildSshTargetSavePayload({ ...EMPTY_FORM, host: '' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Host or SSH config alias is required') + } + }) + + it('omits default SSH connection reuse from new targets but clears it on update', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + label: 'Production', + host: 'prod.example.com', + username: 'deploy', + port: '2202' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.payload.target).toMatchObject({ + label: 'Production', + configHost: 'prod.example.com', + host: 'prod.example.com', + port: 2202, + username: 'deploy', + relayGracePeriodSeconds: 0 + }) + expect(result.payload.target).not.toHaveProperty('systemSshConnectionReuse') + expect(result.payload.updates).toMatchObject({ + source: 'manual', + identityFile: undefined, + proxyCommand: undefined, + jumpHost: undefined, + systemSshConnectionReuse: undefined + }) + }) + + it('persists explicit SSH connection reuse opt-outs and bounded relay timeouts', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'appliance.example.com', + username: 'admin', + identityFile: '~/.ssh/appliance', + proxyCommand: 'cloudflared access ssh --hostname %h', + jumpHost: 'bastion.example.com', + systemSshConnectionReuse: false, + relayKeepAliveUntilReset: false, + relayGracePeriodSeconds: '600' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.payload.target).toMatchObject({ + label: 'admin@appliance.example.com', + host: 'appliance.example.com', + relayGracePeriodSeconds: 600, + identityFile: '~/.ssh/appliance', + proxyCommand: 'cloudflared access ssh --hostname %h', + jumpHost: 'bastion.example.com', + systemSshConnectionReuse: false + }) + expect(result.payload.updates).toMatchObject({ + source: 'manual', + systemSshConnectionReuse: false + }) + }) + + it('rejects invalid bounded relay timeouts', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'appliance.example.com', + relayKeepAliveUntilReset: false, + relayGracePeriodSeconds: '59' + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Terminal timeout') + } + }) +}) diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts new file mode 100644 index 00000000000..e12a7a9df92 --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -0,0 +1,87 @@ +import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { + getSshTargetDraftConnectionFields, + isRelayGracePeriodValid, + parseRelayGracePeriodSeconds, + type EditingTarget +} from './ssh-target-draft' +import { translate } from '../../i18n/i18n' + +type SshTargetSavePayload = { + target: Omit + updates: Partial> +} + +type SshTargetSavePayloadResult = + | { ok: true; payload: SshTargetSavePayload } + | { ok: false; error: string } + +export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePayloadResult { + const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) + if (!host) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.0e5aa04161', + 'Host or SSH config alias is required' + ) + } + } + + if (isNaN(port) || port < 1 || port > 65535) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.4db9afce1c', + 'Port must be between 1 and 65535' + ) + } + } + + const graceSeconds = parseRelayGracePeriodSeconds(form) + if (!isRelayGracePeriodValid(form, graceSeconds)) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.3879cbaa52', + 'Terminal timeout must be between 60 and {{value0}} seconds, or keep terminals alive until reset.', + { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } + ) + } + } + + const identityFile = form.identityFile.trim() || undefined + const proxyCommand = form.proxyCommand.trim() || undefined + const jumpHost = form.jumpHost.trim() || undefined + const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false + + const target: Omit = { + label: form.label.trim() || (username ? `${username}@${host}` : configHost), + configHost, + host, + port, + username, + relayGracePeriodSeconds: graceSeconds, + ...(identityFile ? { identityFile } : {}), + ...(proxyCommand ? { proxyCommand } : {}), + ...(jumpHost ? { jumpHost } : {}), + ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) + } + + return { + ok: true, + payload: { + target, + updates: { + ...target, + // Why: updateTarget merges partially, so explicit undefined values are + // required to clear optional fields inherited from ~/.ssh/config. + identityFile, + proxyCommand, + jumpHost, + systemSshConnectionReuse, + source: 'manual' + } + } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2fd3e185eb4..0695879260a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6400,7 +6400,13 @@ "9518545cb6": "Add Target", "a62b4cb39a": "Save Changes", "29af933cd5": "New SSH Target", - "f2331ce599": "Edit SSH Target" + "f2331ce599": "Edit SSH Target", + "7c13f58c91": "Until reset", + "55c56cf2c7": "Timeout after disconnect (seconds)", + "4a342f44c1": "Advanced Connection", + "e9609ddca6": "Proxy, jump host, and connection reuse", + "8c922dffba": "Reuse SSH connection for faster setup", + "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions." }, "TasksPane": { "3a72b9745e": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d004befd435..282c041e488 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6363,7 +6363,11 @@ "29af933cd5": "Nuevo destino SSH", "f2331ce599": "Editar destino SSH", "7c13f58c91": "Hasta restablecer", - "55c56cf2c7": "Tiempo de espera tras desconectar (segundos)" + "55c56cf2c7": "Tiempo de espera tras desconectar (segundos)", + "4a342f44c1": "Conexión avanzada", + "e9609ddca6": "Proxy, host de salto y reutilización de conexión", + "8c922dffba": "Reutilizar la conexión SSH para configurar más rápido", + "53e9aabfc0": "Usa multiplexación de OpenSSH cuando está disponible. Desactívalo para hosts con restricciones SSH personalizadas." }, "TasksPane": { "3a72b9745e": "Elija qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 04c946b7f2b..db058eddd76 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6385,7 +6385,11 @@ "29af933cd5": "新規 SSH ターゲット", "f2331ce599": "SSH ターゲットの編集", "7c13f58c91": "リセットまで", - "55c56cf2c7": "切断後のタイムアウト(秒)" + "55c56cf2c7": "切断後のタイムアウト(秒)", + "4a342f44c1": "詳細接続", + "e9609ddca6": "プロキシ、ジャンプホスト、接続の再利用", + "8c922dffba": "より速いセットアップのため SSH 接続を再利用", + "53e9aabfc0": "利用可能な場合は OpenSSH の多重化を使用します。独自の SSH 制限があるホストではオフにしてください。" }, "TasksPane": { "3a72b9745e": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f55504f64f3..3c120d72f13 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6348,7 +6348,11 @@ "29af933cd5": "새로운 SSH 대상", "f2331ce599": "SSH 대상 편집", "7c13f58c91": "재설정할 때까지", - "55c56cf2c7": "연결 해제 후 시간 제한(초)" + "55c56cf2c7": "연결 해제 후 시간 제한(초)", + "4a342f44c1": "고급 연결", + "e9609ddca6": "프록시, 점프 호스트, 연결 재사용", + "8c922dffba": "더 빠른 설정을 위해 SSH 연결 재사용", + "53e9aabfc0": "사용 가능한 경우 OpenSSH 멀티플렉싱을 사용합니다. 사용자 지정 SSH 제한이 있는 호스트에서는 끄세요." }, "TasksPane": { "3a72b9745e": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 88a2b63808d..7510e6718e5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6348,7 +6348,11 @@ "29af933cd5": "新的 SSH 目标", "f2331ce599": "编辑 SSH 目标", "7c13f58c91": "直到重置", - "55c56cf2c7": "断开连接后的超时时间(秒)" + "55c56cf2c7": "断开连接后的超时时间(秒)", + "4a342f44c1": "高级连接", + "e9609ddca6": "代理、跳板主机和连接复用", + "8c922dffba": "复用 SSH 连接以加快设置", + "53e9aabfc0": "可用时使用 OpenSSH 多路复用。对于有自定义 SSH 限制的主机,请关闭此项。" }, "TasksPane": { "3a72b9745e": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index d10d24ed395..a6b4fa55e8d 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -46,6 +46,9 @@ export type SshTarget = { /** Port forwards to auto-restore on connect/reconnect. Persisted so * forwards survive app restarts. */ portForwards?: SavedPortForward[] + /** Reuse a system OpenSSH connection across setup commands. Undefined means + * enabled; false is an explicit per-target compatibility opt-out. */ + systemSshConnectionReuse?: boolean } export type SavedPortForward = { From 347f44485e2cbb08943005f48d6ebbb781151657 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:14:06 -0700 Subject: [PATCH 013/233] Prevent stale terminal pixels on tab reveal (#7058) Co-authored-by: Orca --- .../terminal-visibility-resume.test.ts | 106 +++ .../terminal-visibility-resume.ts | 4 +- .../terminal-webgl-atlas-recovery.test.ts | 78 ++ .../terminal-webgl-atlas-recovery.ts | 65 +- tests/e2e/terminal-recovery-reset-counters.ts | 66 ++ .../e2e/terminal-recovery-timeout-stretch.ts | 38 + .../terminal-tab-switch-overlap-repro.spec.ts | 830 ++++++++++++++++++ 7 files changed, 1180 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts create mode 100644 tests/e2e/terminal-recovery-reset-counters.ts create mode 100644 tests/e2e/terminal-recovery-timeout-stretch.ts create mode 100644 tests/e2e/terminal-tab-switch-overlap-repro.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts new file mode 100644 index 00000000000..d90a894e112 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -0,0 +1,106 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resumeTerminalVisibility } from './terminal-visibility-resume' + +const mocks = vi.hoisted(() => ({ + enforceTerminalCurrentScrollIntent: vi.fn(), + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + flushTerminalOutput: vi.fn(), + focusActivePane: vi.fn(), + requestTerminalBacklogRecovery: vi.fn(), + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalVisibilityWebglRecovery: vi.fn() +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + resetAndRefreshAllTerminalWebglAtlases: mocks.resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: mocks.flushTerminalOutput, + requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery +})) + +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent +})) + +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: mocks.fitAndFocusPanes, + fitPanes: mocks.fitPanes, + focusActivePane: mocks.focusActivePane +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalVisibilityWebglRecovery: mocks.scheduleTerminalVisibilityWebglRecovery +})) + +describe('resumeTerminalVisibility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createManager(): PaneManager { + return { + getPanes: vi.fn(() => [{ terminal: 'first-terminal' }, { terminal: 'second-terminal' }]), + resumeRendering: vi.fn() + } as unknown as PaneManager + } + + function resume( + options: { + isActive?: boolean + shouldUseLightTabResume?: boolean + wasVisible?: boolean + } = {} + ): { + captureViewportPositions: ReturnType + manager: PaneManager + withSuppressedScrollTracking: ReturnType + } { + const manager = createManager() + const captureViewportPositions = vi.fn(() => new Map()) + const withSuppressedScrollTracking = vi.fn((callback: () => void) => callback()) + + resumeTerminalVisibility({ + manager, + isActive: options.isActive ?? true, + wasVisible: options.wasVisible ?? false, + shouldUseLightTabResume: options.shouldUseLightTabResume ?? true, + captureViewportPositions, + withSuppressedScrollTracking + }) + + return { captureViewportPositions, manager, withSuppressedScrollTracking } + } + + it('uses visibility-owned WebGL recovery on light regular-tab resume', () => { + const { captureViewportPositions, manager, withSuppressedScrollTracking } = resume() + + expect(captureViewportPositions).toHaveBeenCalledWith(true) + expect(withSuppressedScrollTracking).toHaveBeenCalledTimes(1) + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('first-terminal') + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('second-terminal') + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(mocks.scheduleTerminalVisibilityWebglRecovery).toHaveBeenCalledTimes(1) + expect(mocks.resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2) + }) + + it('preserves direct reset and refresh on heavy visibility resume', () => { + const { manager } = resume({ shouldUseLightTabResume: false }) + + expect(mocks.scheduleTerminalVisibilityWebglRecovery).not.toHaveBeenCalled() + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledTimes(2) + expect(mocks.flushTerminalOutput).toHaveBeenCalledTimes(2) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(mocks.resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 408f8afe952..566f607d4d3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -7,7 +7,7 @@ import { } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' -import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' +import { scheduleTerminalVisibilityWebglRecovery } from './terminal-webgl-atlas-recovery' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -62,7 +62,7 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - scheduleTerminalWebglAtlasRecovery() + scheduleTerminalVisibilityWebglRecovery() if (isActive) { focusActivePane(manager) } diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 4d09bddbc67..995bdc6d6be 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,6 +5,7 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import { scheduleImagePasteWebglAtlasRecovery, + scheduleTerminalVisibilityWebglRecovery, scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -178,4 +179,81 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6) }) + + it('does not coalesce visibility recovery behind in-flight terminal-output recovery', () => { + vi.useFakeTimers() + const rafCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + ) + const manager = registerManager() + + scheduleTerminalWebglAtlasRecovery() + expect(rafCallbacks).toHaveLength(1) + rafCallbacks.shift()?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(120) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2) + + scheduleTerminalVisibilityWebglRecovery() + scheduleTerminalVisibilityWebglRecovery() + + expect(rafCallbacks).toHaveLength(1) + rafCallbacks.shift()?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(120) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4) + vi.advanceTimersByTime(380) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6) + + scheduleTerminalVisibilityWebglRecovery() + expect(rafCallbacks).toHaveLength(1) + rafCallbacks.shift()?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(7) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(7) + vi.advanceTimersByTime(500) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(9) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(9) + }) + + it('allows another first-frame tab reveal while coalescing the delayed tail', () => { + vi.useFakeTimers() + const rafCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + ) + const manager = registerManager() + + scheduleTerminalVisibilityWebglRecovery() + expect(rafCallbacks).toHaveLength(1) + rafCallbacks.shift()?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + + scheduleTerminalVisibilityWebglRecovery() + expect(rafCallbacks).toHaveLength(1) + rafCallbacks.shift()?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(120) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(380) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 26c8dfa283e..728d4e95a0e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -1,8 +1,23 @@ import { resetAndRefreshAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' +import { e2eConfig } from '@/lib/e2e-config' const ATLAS_RECOVERY_DELAYS_MS = [120, 500] let terminalOutputRecoveryScheduled = false +let terminalVisibilityFrameRecoveryScheduled = false +let terminalVisibilityDelayedRecoveryScheduled = false + +type TerminalRecoveryTestWindow = Window & { + __terminalTabOverlapSuppressVisibilityRecovery?: boolean +} + +function isVisibilityRecoverySuppressedForTest(): boolean { + return ( + e2eConfig.exposeStore && + typeof window !== 'undefined' && + Boolean((window as TerminalRecoveryTestWindow).__terminalTabOverlapSuppressVisibilityRecovery) + ) +} function scheduleNextFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame === 'function') { @@ -22,13 +37,21 @@ function resetAtlasesAndRefreshPanes(): void { } } -function scheduleAtlasRecoveryBurst(onComplete?: () => void): void { - scheduleNextFrame(() => resetAtlasesAndRefreshPanes()) +type AtlasRecoveryBurstCallbacks = { + onComplete?: () => void + onFirstReset?: () => void +} + +function scheduleAtlasRecoveryBurst(callbacks: AtlasRecoveryBurstCallbacks = {}): void { + scheduleNextFrame(() => { + resetAtlasesAndRefreshPanes() + callbacks.onFirstReset?.() + }) for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) { globalThis.setTimeout(() => { resetAtlasesAndRefreshPanes() if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) { - onComplete?.() + callbacks.onComplete?.() } }, delayMs) } @@ -47,7 +70,39 @@ export function scheduleTerminalWebglAtlasRecovery(): void { terminalOutputRecoveryScheduled = true // Why: TUI redraw bursts can corrupt xterm's shared WebGL glyph atlas without // a context-loss event; coalesce resets so output storms do not queue timers. - scheduleAtlasRecoveryBurst(() => { - terminalOutputRecoveryScheduled = false + scheduleAtlasRecoveryBurst({ + onComplete: () => { + terminalOutputRecoveryScheduled = false + } }) } + +export function scheduleTerminalVisibilityWebglRecovery(): void { + if (isVisibilityRecoverySuppressedForTest()) { + return + } + if (terminalVisibilityFrameRecoveryScheduled) { + return + } + terminalVisibilityFrameRecoveryScheduled = true + // Why: tab reveal is a separate repaint boundary from hidden-output parsing, + // so an in-flight output recovery must not suppress the returned tab's repaint. + // The first-frame repaint is per reveal, while the delayed tail is coalesced + // so rapid tab cycling does not queue unbounded global refresh bursts. + scheduleNextFrame(() => { + resetAtlasesAndRefreshPanes() + terminalVisibilityFrameRecoveryScheduled = false + }) + if (terminalVisibilityDelayedRecoveryScheduled) { + return + } + terminalVisibilityDelayedRecoveryScheduled = true + for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) { + globalThis.setTimeout(() => { + resetAtlasesAndRefreshPanes() + if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) { + terminalVisibilityDelayedRecoveryScheduled = false + } + }, delayMs) + } +} diff --git a/tests/e2e/terminal-recovery-reset-counters.ts b/tests/e2e/terminal-recovery-reset-counters.ts new file mode 100644 index 00000000000..184dbb6b94f --- /dev/null +++ b/tests/e2e/terminal-recovery-reset-counters.ts @@ -0,0 +1,66 @@ +import type { Page } from '@stablyai/playwright-test' + +type RecoveryCounterWindow = Window & { + __terminalTabOverlapResetCounts?: Record + __terminalTabOverlapResetTimes?: Record + __terminalTabOverlapSuppressVisibilityRecovery?: boolean +} + +export async function instrumentTabResetCounters(page: Page, tabIds: string[]): Promise { + const instrumented = await page.evaluate((tabIds) => { + const counterWindow = window as RecoveryCounterWindow + counterWindow.__terminalTabOverlapResetCounts = Object.fromEntries( + tabIds.map((tabId) => [tabId, 0]) + ) + counterWindow.__terminalTabOverlapResetTimes = Object.fromEntries( + tabIds.map((tabId) => [tabId, []]) + ) + return tabIds.map((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager?.resetWebglTextureAtlases) { + return false + } + const originalReset = manager.resetWebglTextureAtlases.bind(manager) + manager.resetWebglTextureAtlases = () => { + const counts = counterWindow.__terminalTabOverlapResetCounts ?? {} + counts[tabId] = (counts[tabId] ?? 0) + 1 + counterWindow.__terminalTabOverlapResetCounts = counts + const times = counterWindow.__terminalTabOverlapResetTimes ?? {} + times[tabId] = [...(times[tabId] ?? []), performance.now()] + counterWindow.__terminalTabOverlapResetTimes = times + originalReset() + } + return true + }) + }, tabIds) + if (!instrumented.every(Boolean)) { + throw new Error(`could not instrument WebGL reset counters for tabs ${tabIds.join(', ')}`) + } +} + +export async function readTabResetCount(page: Page, tabId: string): Promise { + return page.evaluate( + (tabId) => (window as RecoveryCounterWindow).__terminalTabOverlapResetCounts?.[tabId] ?? 0, + tabId + ) +} + +export async function readTabResetSnapshot(page: Page, tabId: string) { + return page.evaluate((tabId) => { + const counterWindow = window as RecoveryCounterWindow + const times = counterWindow.__terminalTabOverlapResetTimes?.[tabId] ?? [] + return { + count: counterWindow.__terminalTabOverlapResetCounts?.[tabId] ?? 0, + latestAt: times.at(-1) ?? 0 + } + }, tabId) +} + +export async function setVisibilityRecoverySuppressed( + page: Page, + suppressed: boolean +): Promise { + await page.evaluate((suppressed) => { + ;(window as RecoveryCounterWindow).__terminalTabOverlapSuppressVisibilityRecovery = suppressed + }, suppressed) +} diff --git a/tests/e2e/terminal-recovery-timeout-stretch.ts b/tests/e2e/terminal-recovery-timeout-stretch.ts new file mode 100644 index 00000000000..e6a880b496d --- /dev/null +++ b/tests/e2e/terminal-recovery-timeout-stretch.ts @@ -0,0 +1,38 @@ +import type { Page } from '@stablyai/playwright-test' + +export const STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS = 3_000 + +type TimeoutStretchWindow = Window & { + __terminalTabOverlapOriginalSetTimeout?: typeof window.setTimeout +} + +export async function stretchFiveHundredMillisecondTimeouts(page: Page): Promise { + await page.evaluate((stretchedDelayMs) => { + const testWindow = window as TimeoutStretchWindow + if (testWindow.__terminalTabOverlapOriginalSetTimeout) { + return + } + const originalSetTimeout = window.setTimeout + const boundSetTimeout = originalSetTimeout.bind(window) + testWindow.__terminalTabOverlapOriginalSetTimeout = originalSetTimeout + // Why: terminal reveal recovery tests must prove reveal repaint, so the old + // hidden-output fallback is stretched out of the assertion window. + window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => + boundSetTimeout( + handler, + timeout === 500 ? stretchedDelayMs : timeout, + ...args + )) as typeof window.setTimeout + }, STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS) +} + +export async function restoreFiveHundredMillisecondTimeouts(page: Page): Promise { + await page.evaluate(() => { + const testWindow = window as TimeoutStretchWindow + if (!testWindow.__terminalTabOverlapOriginalSetTimeout) { + return + } + window.setTimeout = testWindow.__terminalTabOverlapOriginalSetTimeout + delete testWindow.__terminalTabOverlapOriginalSetTimeout + }) +} diff --git a/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts b/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts new file mode 100644 index 00000000000..16c28254b0a --- /dev/null +++ b/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts @@ -0,0 +1,830 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { PNG } from 'pngjs' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + restoreFiveHundredMillisecondTimeouts, + STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS, + stretchFiveHundredMillisecondTimeouts +} from './terminal-recovery-timeout-stretch' +import { + instrumentTabResetCounters, + readTabResetCount, + readTabResetSnapshot, + setVisibilityRecoverySuppressed +} from './terminal-recovery-reset-counters' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' + +const TAB_A_MARKER = 'ORCA_OVERLAP_REPRO_TAB_A_ONLY' +const TAB_B_MARKER = 'ORCA_OVERLAP_REPRO_TAB_B_ONLY' +const TAB_B_GLYPHS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' +const TAB_B_COLOR = { red: 255, green: 75, blue: 170 } +const VISUAL_OVERLAP_PROBE_CYCLES = 140 +const SIBLING_COLOR_PIXEL_FLOOR = 150 +const SIBLING_COLOR_PIXEL_DELTA = 100 +const REVEAL_RECOVERY_TIMEOUT_MS = 180 +const REAL_CLAUDE_PROBE_CYCLES = 160 +const REFRESH_REPAIR_DIFF_RATIO = 0.035 + +type TabIdentity = { + tabId: string + leafId: string + ptyId: string +} + +type TabBufferProbe = { + containsOwnMarker: boolean + containsSiblingMarker: boolean + visibleText: string +} + +type RefreshRepairProbe = { + diffRatio: number + diffPixels: number + bufferUnchanged: boolean + beforeText: string + afterText: string + beforeScreenshot: Buffer + afterScreenshot: Buffer +} + +type HiddenOutputWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } +} + +async function setTerminalGpuOn(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + const state = store?.getState() + if (!store || !state?.settings) { + throw new Error('Store unavailable') + } + store.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + }) +} + +async function ensureTwoTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + const result = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const existing = state.tabsByWorktree[worktreeId] ?? [] + const first = existing.find((tab) => tab.type === 'terminal') ?? state.createTab(worktreeId) + let second = existing.find((tab) => tab.type === 'terminal' && tab.id !== first.id) + if (!second) { + second = state.createTab(worktreeId, undefined, undefined, { activate: false }) + } + state.setActiveTab(first.id) + state.setActiveTabType('terminal') + return { firstTabId: first.id, secondTabId: second.id } + }, worktreeId) + await waitForTerminalManagerOnTab(page, result.firstTabId) + await waitForTerminalManagerOnTab(page, result.secondTabId) + return result +} + +async function ensureThreeTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string; thirdTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + const result = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const terminalTabs = (state.tabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.type === 'terminal' + ) + const first = terminalTabs[0] ?? state.createTab(worktreeId) + const second = + terminalTabs.find((tab) => tab.id !== first.id) ?? + state.createTab(worktreeId, undefined, undefined, { activate: false }) + const third = + terminalTabs.find((tab) => tab.id !== first.id && tab.id !== second.id) ?? + state.createTab(worktreeId, undefined, undefined, { activate: false }) + state.setActiveTab(first.id) + state.setActiveTabType('terminal') + return { firstTabId: first.id, secondTabId: second.id, thirdTabId: third.id } + }, worktreeId) + await waitForTerminalManagerOnTab(page, result.firstTabId) + await waitForTerminalManagerOnTab(page, result.secondTabId) + await waitForTerminalManagerOnTab(page, result.thirdTabId) + return result +} + +async function waitForTerminalManagerOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return Boolean(manager?.getActivePane?.() ?? manager?.getPanes?.()[0]) + }, tabId), + { timeout: 15_000, message: `terminal manager did not mount for ${tabId}` } + ) + .toBe(true) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const state = window.__store?.getState() + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { timeout: 5_000, message: `terminal tab ${tabId} did not become active` } + ) + .toBe(tabId) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + window.__paneManagers?.get(tabId)?.setTerminalGpuAcceleration?.('on') + }, tabId) + return page + .waitForFunction( + (tabId) => { + const diagnostics = window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((entry) => entry.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function readTabIdentity(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container.dataset.ptyId ?? null + }, tabId), + { timeout: 20_000, message: `terminal tab ${tabId} did not bind a PTY` } + ) + .not.toBeNull() + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + tabId, + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`terminal tab ${tabId} did not bind a PTY`) + } + return identity +} + +async function readTabBufferProbe( + page: Page, + tabId: string, + ownMarker: string, + siblingMarker: string +): Promise { + return page.evaluate( + ({ tabId, ownMarker, siblingMarker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`No terminal pane for ${tabId}`) + } + const activeBuffer = pane.terminal.buffer.active + const rows: string[] = [] + for (let row = 0; row < pane.terminal.rows; row += 1) { + rows.push(activeBuffer.getLine(activeBuffer.viewportY + row)?.translateToString(true) ?? '') + } + const visibleText = rows.join('\n') + return { + containsOwnMarker: visibleText.includes(ownMarker), + containsSiblingMarker: visibleText.includes(siblingMarker), + visibleText + } + }, + { tabId, ownMarker, siblingMarker } + ) +} + +async function startTuiStream( + page: Page, + ptyId: string, + scriptPath: string, + marker: string, + glyphs: string, + color: { red: number; green: number; blue: number }, + cadenceMs: number +): Promise { + const script = [ + `const marker=${JSON.stringify(marker)};`, + `const glyphs=${JSON.stringify(glyphs)};`, + `const color=${JSON.stringify(color)};`, + `const cadence=${cadenceMs};`, + 'let frame=0;', + 'function bar(width){return "█".repeat((frame % width) + 1) + "░".repeat(width - ((frame % width) + 1));}', + 'function paint(text){return `\\x1b[38;2;${color.red};${color.green};${color.blue}m${text}\\x1b[0m`; }', + 'function emit(){', + ' const lines=[];', + ' lines.push("\\x1b[?2026h");', + ' lines.push("\\x1b[?1049h");', + ' lines.push("\\x1b[2J\\x1b[H\\x1b[?25l");', + ' lines.push(paint(`╭────────────────────────────────────────────────────────────────────╮`));', + ' lines.push(paint(`│ ${marker} frame ${String(frame).padStart(4,"0")} ${bar(18)} │`));', + ' lines.push(paint(`├────────────────────────────────────────────────────────────────────┤`));', + ' for (let row=0; row<18; row++) {', + ' lines.push(paint(`│ row ${String(row).padStart(2,"0")} ${glyphs} ${bar(10)} │`));', + ' }', + ' lines.push(paint(`╰────────────────────────────────────────────────────────────────────╯`));', + ' lines.push("\\x1b[?25h\\x1b[?2026l");', + ' process.stdout.write(lines.join("\\r\\n"));', + ' frame += 1;', + '}', + 'emit();', + 'setInterval(emit, cadence);' + ].join('') + writeFileSync(scriptPath, script) + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) +} + +async function writeSparsePromptFrame( + page: Page, + ptyId: string, + scriptPath: string +): Promise { + const script = [ + `const marker=${JSON.stringify(TAB_A_MARKER)};`, + 'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25h");', + 'process.stdout.write(`${marker}\\r\\n`);', + 'process.stdout.write("Please make more to 5 more different random files. Explore orca a bit actually, then do it. BBB\\r\\n");' + ].join('') + writeFileSync(scriptPath, script) + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) +} + +async function writeDenseStaticFrame( + page: Page, + ptyId: string, + scriptPath: string, + marker: string +): Promise { + const script = [ + `const marker=${JSON.stringify(marker)};`, + 'const glyphs="ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~";', + 'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25l");', + 'for (let row = 0; row < 22; row += 1) {', + ' process.stdout.write(`${marker} row ${String(row).padStart(2, "0")} | ${glyphs} |\\r\\n`);', + '}' + ].join('') + writeFileSync(scriptPath, script) + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) +} + +async function injectHiddenTuiFrame( + page: Page, + identity: TabIdentity, + marker: string +): Promise { + const paneKey = `${identity.tabId}:${identity.leafId}` + const frame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H\x1b[?25l', + `╭────────────────────────────────────────────────────────╮`, + `│ ${marker} hidden atlas recovery frame │`, + `├────────────────────────────────────────────────────────┤`, + ...Array.from( + { length: 12 }, + (_, row) => `│ hidden row ${String(row).padStart(2, '0')} ███████████████████ │` + ), + `╰────────────────────────────────────────────────────────╯`, + '\x1b[?25h\x1b[?2026l' + ].join('\r\n') + const injected = await page.evaluate( + ({ paneKey, frame }) => + (window as HiddenOutputWindow).__terminalPtyDataInjection?.inject(paneKey, frame, { + seq: frame.length, + rawLength: frame.length + }) ?? false, + { paneKey, frame } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function corruptTabAtlas(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return 0 + } + const canvases = Array.from( + document.querySelectorAll(`[data-terminal-tab-id="${tabId}"] canvas`) + ) + const noise = new Uint8Array(64 * 64 * 4) + for (let index = 0; index < noise.length; index += 4) { + noise[index] = (index * 7) % 256 + noise[index + 1] = (index * 13) % 256 + noise[index + 2] = (index * 29) % 256 + noise[index + 3] = 255 + } + let corrupted = 0 + for (const canvas of canvases) { + const gl = + (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? + (canvas.getContext('webgl') as WebGLRenderingContext | null) + if (!gl) { + continue + } + const maxUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) as number + for (let unit = 0; unit < maxUnits; unit += 1) { + gl.activeTexture(gl.TEXTURE0 + unit) + if (!gl.getParameter(gl.TEXTURE_BINDING_2D)) { + continue + } + for (const [x, y] of [ + [0, 0], + [64, 0], + [128, 0], + [192, 0], + [0, 64], + [64, 64], + [128, 64], + [192, 64] + ]) { + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, noise) + if (gl.getError() === gl.NO_ERROR) { + corrupted += 1 + } + } + if (corrupted > 0) { + break + } + } + gl.activeTexture(gl.TEXTURE0) + if (corrupted > 0) { + break + } + } + pane.terminal.refresh(0, pane.terminal.rows - 1) + return corrupted + }, tabId) +} + +async function resetAndRefreshTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + manager?.resetWebglTextureAtlases?.() + manager?.refreshAllPanes?.() + }, tabId) + await waitForTwoAnimationFrames(page) +} + +async function waitForTwoAnimationFrames(page: Page): Promise { + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function captureTabScreen(page: Page, tabId: string): Promise { + const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() + await expect(screen).toBeVisible() + return screen.screenshot({ animations: 'disabled' }) +} + +function countColorPixels( + buffer: Buffer, + target: { red: number; green: number; blue: number } +): number { + const image = PNG.sync.read(buffer) + let pixels = 0 + for (let offset = 0; offset < image.data.length; offset += 4) { + const alpha = image.data[offset + 3] ?? 0 + if (alpha < 120) { + continue + } + const red = image.data[offset] ?? 0 + const green = image.data[offset + 1] ?? 0 + const blue = image.data[offset + 2] ?? 0 + const distance = + Math.abs(red - target.red) + Math.abs(green - target.green) + Math.abs(blue - target.blue) + if (distance <= 95) { + pixels += 1 + } + } + return pixels +} + +async function attachArtifact( + testInfo: TestInfo, + name: string, + body: Buffer | string +): Promise { + await testInfo.attach(name, { + body: typeof body === 'string' ? Buffer.from(body) : body, + contentType: typeof body === 'string' ? 'text/plain' : 'image/png' + }) +} + +async function refreshActiveTabAndCompare(page: Page, tabId: string): Promise { + const beforeText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText + const beforeScreenshot = await captureTabScreen(page, tabId) + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`No terminal pane for ${tabId}`) + } + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, tabId) + await waitForTwoAnimationFrames(page) + const afterScreenshot = await captureTabScreen(page, tabId) + const afterText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText + const diff = compareTerminalScreenshots(beforeScreenshot, afterScreenshot) + return { + diffRatio: diff.diffRatio, + diffPixels: diff.diffPixels, + bufferUnchanged: beforeText === afterText, + beforeText, + afterText, + beforeScreenshot, + afterScreenshot + } +} + +async function startClaudeEditSession( + page: Page, + ptyId: string, + marker: string, + filePrefix: string +): Promise { + const prompt = [ + `This is an Orca terminal renderer reproduction run. Marker: ${marker}.`, + 'Create or edit 10 small scratch files in the current repo.', + `Name them ${filePrefix}-01.txt through ${filePrefix}-10.txt.`, + 'After each file edit, run a tiny shell command that prints the marker and sleep 1 second.', + 'Keep working without asking questions. The visible terminal activity is the point.' + ].join(' ') + await sendToTerminal( + page, + ptyId, + `claude --model sonnet --effort low --dangerously-skip-permissions --permission-mode bypassPermissions ${JSON.stringify(prompt)}\r` + ) +} + +test.describe('Terminal tab switch visual overlap repro @headful', () => { + test('manual probe for visible sibling TUI pixels while tab buffers stay isolated', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip( + process.env.ORCA_E2E_VISUAL_OVERLAP_PROBE !== '1', + 'Set ORCA_E2E_VISUAL_OVERLAP_PROBE=1 to run the best-effort visual overlap probe' + ) + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await setTerminalGpuOn(orcaPage) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + const firstIdentity = await readTabIdentity(orcaPage, firstTabId) + + await activateTerminalTab(orcaPage, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + const secondIdentity = await readTabIdentity(orcaPage, secondTabId) + test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs') + + await writeSparsePromptFrame( + orcaPage, + firstIdentity.ptyId, + path.join(testRepoPath, 'orca-overlap-tab-a-sparse.mjs') + ) + await startTuiStream( + orcaPage, + secondIdentity.ptyId, + path.join(testRepoPath, 'orca-overlap-tab-b.mjs'), + TAB_B_MARKER, + TAB_B_GLYPHS, + TAB_B_COLOR, + 41 + ) + + await activateTerminalTab(orcaPage, firstTabId) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 20_000, + message: 'tab A sparse prompt frame did not reach the visible terminal' + }) + .toContain(TAB_A_MARKER) + const baselineA = await captureTabScreen(orcaPage, firstTabId) + const baselineAHasSiblingColor = countColorPixels(baselineA, TAB_B_COLOR) + + await activateTerminalTab(orcaPage, secondTabId) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 20_000, + message: 'tab B TUI stream did not reach the visible terminal' + }) + .toContain(`${TAB_B_MARKER} frame`) + const baselineB = await captureTabScreen(orcaPage, secondTabId) + const baselineBHasOwnColor = countColorPixels(baselineB, TAB_B_COLOR) + expect(baselineBHasOwnColor).toBeGreaterThan(500) + + const reports: string[] = [] + for (let cycle = 0; cycle < VISUAL_OVERLAP_PROBE_CYCLES; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.waitForTimeout(15) + await activateTerminalTab(orcaPage, firstTabId) + const activeTabId = (await getActiveTabId(orcaPage))! + const screenshot = await captureTabScreen(orcaPage, activeTabId) + const probe = await readTabBufferProbe(orcaPage, activeTabId, TAB_A_MARKER, TAB_B_MARKER) + if (!probe.containsOwnMarker || probe.containsSiblingMarker) { + reports.push( + `cycle ${cycle} buffer mismatch active=${activeTabId} own=${probe.containsOwnMarker} sibling=${probe.containsSiblingMarker}` + ) + await attachArtifact(testInfo, `buffer-mismatch-cycle-${cycle}.txt`, probe.visibleText) + break + } + const siblingColorPixels = countColorPixels(screenshot, TAB_B_COLOR) + if ( + siblingColorPixels > + Math.max(SIBLING_COLOR_PIXEL_FLOOR, baselineAHasSiblingColor + SIBLING_COLOR_PIXEL_DELTA) + ) { + reports.push( + `cycle ${cycle} visual sibling color active=${activeTabId} siblingPixels=${siblingColorPixels} baselineSiblingPixels=${baselineAHasSiblingColor} bufferSibling=${probe.containsSiblingMarker}` + ) + await attachArtifact(testInfo, `overlap-candidate-${cycle}.png`, screenshot) + await attachArtifact(testInfo, `overlap-buffer-${cycle}.txt`, probe.visibleText) + break + } + await orcaPage.waitForTimeout(20) + } + + await attachArtifact(testInfo, 'tab-a-baseline.png', baselineA) + await attachArtifact(testInfo, 'tab-b-baseline.png', baselineB) + expect( + reports, + reports.length > 0 + ? `active terminal showed sibling-colored pixels while buffers stayed isolated:\n${reports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('repairs returned tab pixels even while hidden-output recovery is in flight', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await setTerminalGpuOn(orcaPage) + + const { firstTabId, secondTabId, thirdTabId } = await ensureThreeTerminalTabs(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + const firstIdentity = await readTabIdentity(orcaPage, firstTabId) + await writeDenseStaticFrame( + orcaPage, + firstIdentity.ptyId, + path.join(testRepoPath, 'orca-overlap-static-a.mjs'), + 'ORCA_DETERMINISTIC_A' + ) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 20_000, + message: 'tab A static frame did not reach the visible terminal' + }) + .toContain('ORCA_DETERMINISTIC_A row 21') + await resetAndRefreshTab(orcaPage, firstTabId) + const baseline = await captureTabScreen(orcaPage, firstTabId) + + const activeCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId) + test.skip(activeCorruptedTiles === 0, 'Could not inject WebGL atlas corruption') + const activeCorruption = await captureTabScreen(orcaPage, firstTabId) + expect( + compareTerminalScreenshots(baseline, activeCorruption).matches, + 'atlas corruption precondition should visibly change active tab glyphs' + ).toBe(false) + await resetAndRefreshTab(orcaPage, firstTabId) + const restored = await captureTabScreen(orcaPage, firstTabId) + expect(compareTerminalScreenshots(baseline, restored).matches).toBe(true) + + await activateTerminalTab(orcaPage, thirdTabId) + const thirdWebgl = await waitForWebglOnTab(orcaPage, thirdTabId) + const thirdIdentity = await readTabIdentity(orcaPage, thirdTabId) + await activateTerminalTab(orcaPage, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + test.skip( + !firstWebgl || !secondWebgl || !thirdWebgl, + 'WebGL did not attach on all regular terminal tabs' + ) + await instrumentTabResetCounters(orcaPage, [firstTabId, secondTabId, thirdTabId]) + await stretchFiveHundredMillisecondTimeouts(orcaPage) + try { + await injectHiddenTuiFrame(orcaPage, thirdIdentity, 'ORCA_HIDDEN_OUTPUT_RECOVERY_IN_FLIGHT') + // Why: the regression is the output-recovery latch suppressing reveal + // recovery. Prove the hidden-output burst reached its rAF and 120ms passes + // before reveal, while its old 500ms fallback is stretched out of range. + await expect + .poll(() => readTabResetCount(orcaPage, thirdTabId), { + timeout: 2_000, + message: 'hidden-output recovery did not fire its first two reset passes' + }) + .toBeGreaterThanOrEqual(2) + } finally { + await restoreFiveHundredMillisecondTimeouts(orcaPage) + } + const hiddenOutputResetCount = await readTabResetCount(orcaPage, thirdTabId) + expect(hiddenOutputResetCount).toBeGreaterThanOrEqual(2) + expect( + hiddenOutputResetCount, + 'hidden-output fallback fired before the reveal precondition could be exercised' + ).toBeLessThanOrEqual(2) + const hiddenOutputResetWindow = await readTabResetSnapshot(orcaPage, thirdTabId) + const resetCountBeforeSuppressedReveal = await readTabResetCount(orcaPage, firstTabId) + expect(resetCountBeforeSuppressedReveal).toBeGreaterThanOrEqual(2) + expect( + resetCountBeforeSuppressedReveal, + 'hidden-output fallback fired before tab reveal' + ).toBeLessThanOrEqual(2) + let suppressedReveal: Buffer | null = null + try { + await setVisibilityRecoverySuppressed(orcaPage, true) + await activateTerminalTab(orcaPage, firstTabId) + await waitForTwoAnimationFrames(orcaPage) + const staleCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId) + expect(staleCorruptedTiles).toBeGreaterThan(0) + suppressedReveal = await captureTabScreen(orcaPage, firstTabId) + const suppressedRevealDiff = compareTerminalScreenshots(baseline, suppressedReveal) + expect( + suppressedRevealDiff.matches, + 'simulated stale returned-tab pixels must be visible when recovery is suppressed' + ).toBe(false) + await activateTerminalTab(orcaPage, secondTabId) + await waitForTwoAnimationFrames(orcaPage) + } finally { + await setVisibilityRecoverySuppressed(orcaPage, false) + } + const resetCountBeforeReveal = await readTabResetCount(orcaPage, firstTabId) + expect(resetCountBeforeReveal).toBe(resetCountBeforeSuppressedReveal) + expect(resetCountBeforeReveal).toBeGreaterThanOrEqual(2) + expect( + resetCountBeforeReveal, + 'hidden-output fallback fired before tab reveal' + ).toBeLessThanOrEqual(2) + const revealStartedAt = await orcaPage.evaluate(() => performance.now()) + expect( + revealStartedAt - hiddenOutputResetWindow.latestAt, + 'tab reveal started too close to the stretched hidden-output fallback to prove independent recovery' + ).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS - REVEAL_RECOVERY_TIMEOUT_MS) + await activateTerminalTab(orcaPage, firstTabId) + await expect + .poll(() => readTabResetCount(orcaPage, firstTabId), { + timeout: REVEAL_RECOVERY_TIMEOUT_MS, + message: 'tab reveal did not schedule an independent WebGL recovery reset' + }) + .toBeGreaterThan(resetCountBeforeReveal) + const resetAfterReveal = await readTabResetSnapshot(orcaPage, firstTabId) + expect( + resetAfterReveal.latestAt - revealStartedAt, + 'reveal recovery must beat the old hidden-output 500ms fallback reset' + ).toBeLessThan(REVEAL_RECOVERY_TIMEOUT_MS) + expect( + resetAfterReveal.latestAt - hiddenOutputResetWindow.latestAt, + 'post-reveal reset must occur before the hidden-output 500ms fallback could explain it' + ).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS) + await waitForTwoAnimationFrames(orcaPage) + const afterReveal = await captureTabScreen(orcaPage, firstTabId) + const afterRevealDiff = compareTerminalScreenshots(baseline, afterReveal) + await attachArtifact(testInfo, 'deterministic-baseline.png', baseline) + await attachArtifact( + testInfo, + 'deterministic-suppressed-reveal.png', + suppressedReveal ?? Buffer.from('') + ) + await attachArtifact(testInfo, 'deterministic-after-reveal.png', afterReveal) + await attachArtifact( + testInfo, + 'deterministic-reset-counts.txt', + `beforeReveal=${resetCountBeforeReveal}\nafterReveal=${resetAfterReveal.count}\nrevealDelayMs=${resetAfterReveal.latestAt - revealStartedAt}\nhiddenSecondResetToRevealMs=${revealStartedAt - hiddenOutputResetWindow.latestAt}\nstretchedHiddenOutputFallbackMs=${STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS}\n` + ) + expect( + afterRevealDiff.matches, + `returned tab still had stale WebGL pixels after reveal: ${afterRevealDiff.diffPixels} px (${(afterRevealDiff.diffRatio * 100).toFixed(2)}%)` + ).toBe(true) + }) + + test('real Claude sessions do not leave refresh-repairable stale pixels', async ({ + orcaPage + }, testInfo) => { + test.skip( + process.env.ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO !== '1', + 'Set ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO=1 to spend real Claude Code tokens on this repro' + ) + test.setTimeout(420_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await setTerminalGpuOn(orcaPage) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + const firstIdentity = await readTabIdentity(orcaPage, firstTabId) + await activateTerminalTab(orcaPage, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + const secondIdentity = await readTabIdentity(orcaPage, secondTabId) + test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs') + + await startClaudeEditSession( + orcaPage, + firstIdentity.ptyId, + 'ORCA_REAL_CLAUDE_A', + 'orca-real-claude-a' + ) + await startClaudeEditSession( + orcaPage, + secondIdentity.ptyId, + 'ORCA_REAL_CLAUDE_B', + 'orca-real-claude-b' + ) + + const reports: string[] = [] + for (let cycle = 0; cycle < REAL_CLAUDE_PROBE_CYCLES; cycle += 1) { + const tabId = cycle % 2 === 0 ? firstTabId : secondTabId + await activateTerminalTab(orcaPage, tabId) + await orcaPage.waitForTimeout(120) + const repair = await refreshActiveTabAndCompare(orcaPage, tabId) + if (repair.bufferUnchanged && repair.diffRatio > REFRESH_REPAIR_DIFF_RATIO) { + reports.push( + `cycle ${cycle} tab=${tabId} refresh changed ${(repair.diffRatio * 100).toFixed(2)}% of pixels without buffer change (${repair.diffPixels} px)` + ) + await attachArtifact( + testInfo, + `real-claude-before-refresh-${cycle}.png`, + repair.beforeScreenshot + ) + await attachArtifact( + testInfo, + `real-claude-after-refresh-${cycle}.png`, + repair.afterScreenshot + ) + await attachArtifact(testInfo, `real-claude-buffer-${cycle}.txt`, repair.beforeText) + break + } + await orcaPage.waitForTimeout(500) + } + + expect( + reports, + reports.length > 0 + ? `real Claude terminal surface had refresh-repairable stale pixels:\n${reports.join('\n')}` + : undefined + ).toEqual([]) + }) +}) From d6578af833ddad7704be86070c959eeddb23b4f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:14:59 +0000 Subject: [PATCH 014/233] release: v1.4.117-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c74923b1295..ef098f102a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.117-rc.0", + "version": "1.4.117-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 7d3944c8ed347c2129137ee6c1b04eb3c1335b57 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:51:46 -0700 Subject: [PATCH 015/233] Revert "Prevent stale terminal pixels on tab reveal" (#7073) --- .../terminal-visibility-resume.test.ts | 106 --- .../terminal-visibility-resume.ts | 4 +- .../terminal-webgl-atlas-recovery.test.ts | 78 -- .../terminal-webgl-atlas-recovery.ts | 65 +- tests/e2e/terminal-recovery-reset-counters.ts | 66 -- .../e2e/terminal-recovery-timeout-stretch.ts | 38 - .../terminal-tab-switch-overlap-repro.spec.ts | 830 ------------------ 7 files changed, 7 insertions(+), 1180 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts delete mode 100644 tests/e2e/terminal-recovery-reset-counters.ts delete mode 100644 tests/e2e/terminal-recovery-timeout-stretch.ts delete mode 100644 tests/e2e/terminal-tab-switch-overlap-repro.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts deleted file mode 100644 index d90a894e112..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resumeTerminalVisibility } from './terminal-visibility-resume' - -const mocks = vi.hoisted(() => ({ - enforceTerminalCurrentScrollIntent: vi.fn(), - fitAndFocusPanes: vi.fn(), - fitPanes: vi.fn(), - flushTerminalOutput: vi.fn(), - focusActivePane: vi.fn(), - requestTerminalBacklogRecovery: vi.fn(), - resetAndRefreshAllTerminalWebglAtlases: vi.fn(), - scheduleTerminalVisibilityWebglRecovery: vi.fn() -})) - -vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ - resetAndRefreshAllTerminalWebglAtlases: mocks.resetAndRefreshAllTerminalWebglAtlases -})) - -vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ - flushTerminalOutput: mocks.flushTerminalOutput, - requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery -})) - -vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent -})) - -vi.mock('./pane-helpers', () => ({ - fitAndFocusPanes: mocks.fitAndFocusPanes, - fitPanes: mocks.fitPanes, - focusActivePane: mocks.focusActivePane -})) - -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalVisibilityWebglRecovery: mocks.scheduleTerminalVisibilityWebglRecovery -})) - -describe('resumeTerminalVisibility', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - function createManager(): PaneManager { - return { - getPanes: vi.fn(() => [{ terminal: 'first-terminal' }, { terminal: 'second-terminal' }]), - resumeRendering: vi.fn() - } as unknown as PaneManager - } - - function resume( - options: { - isActive?: boolean - shouldUseLightTabResume?: boolean - wasVisible?: boolean - } = {} - ): { - captureViewportPositions: ReturnType - manager: PaneManager - withSuppressedScrollTracking: ReturnType - } { - const manager = createManager() - const captureViewportPositions = vi.fn(() => new Map()) - const withSuppressedScrollTracking = vi.fn((callback: () => void) => callback()) - - resumeTerminalVisibility({ - manager, - isActive: options.isActive ?? true, - wasVisible: options.wasVisible ?? false, - shouldUseLightTabResume: options.shouldUseLightTabResume ?? true, - captureViewportPositions, - withSuppressedScrollTracking - }) - - return { captureViewportPositions, manager, withSuppressedScrollTracking } - } - - it('uses visibility-owned WebGL recovery on light regular-tab resume', () => { - const { captureViewportPositions, manager, withSuppressedScrollTracking } = resume() - - expect(captureViewportPositions).toHaveBeenCalledWith(true) - expect(withSuppressedScrollTracking).toHaveBeenCalledTimes(1) - expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('first-terminal') - expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('second-terminal') - expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() - expect(manager.resumeRendering).not.toHaveBeenCalled() - expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() - expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(mocks.scheduleTerminalVisibilityWebglRecovery).toHaveBeenCalledTimes(1) - expect(mocks.resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() - expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) - expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2) - }) - - it('preserves direct reset and refresh on heavy visibility resume', () => { - const { manager } = resume({ shouldUseLightTabResume: false }) - - expect(mocks.scheduleTerminalVisibilityWebglRecovery).not.toHaveBeenCalled() - expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledTimes(2) - expect(mocks.flushTerminalOutput).toHaveBeenCalledTimes(2) - expect(manager.resumeRendering).toHaveBeenCalledTimes(1) - expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) - expect(mocks.resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) - expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 566f607d4d3..408f8afe952 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -7,7 +7,7 @@ import { } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' -import { scheduleTerminalVisibilityWebglRecovery } from './terminal-webgl-atlas-recovery' +import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -62,7 +62,7 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - scheduleTerminalVisibilityWebglRecovery() + scheduleTerminalWebglAtlasRecovery() if (isActive) { focusActivePane(manager) } diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 995bdc6d6be..4d09bddbc67 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,7 +5,6 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import { scheduleImagePasteWebglAtlasRecovery, - scheduleTerminalVisibilityWebglRecovery, scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -179,81 +178,4 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6) }) - - it('does not coalesce visibility recovery behind in-flight terminal-output recovery', () => { - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - expect(rafCallbacks).toHaveLength(1) - rafCallbacks.shift()?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(120) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2) - - scheduleTerminalVisibilityWebglRecovery() - scheduleTerminalVisibilityWebglRecovery() - - expect(rafCallbacks).toHaveLength(1) - rafCallbacks.shift()?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - vi.advanceTimersByTime(120) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6) - - scheduleTerminalVisibilityWebglRecovery() - expect(rafCallbacks).toHaveLength(1) - rafCallbacks.shift()?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(7) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(7) - vi.advanceTimersByTime(500) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(9) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(9) - }) - - it('allows another first-frame tab reveal while coalescing the delayed tail', () => { - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - scheduleTerminalVisibilityWebglRecovery() - expect(rafCallbacks).toHaveLength(1) - rafCallbacks.shift()?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - - scheduleTerminalVisibilityWebglRecovery() - expect(rafCallbacks).toHaveLength(1) - rafCallbacks.shift()?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2) - - vi.advanceTimersByTime(120) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 728d4e95a0e..26c8dfa283e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -1,23 +1,8 @@ import { resetAndRefreshAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -import { e2eConfig } from '@/lib/e2e-config' const ATLAS_RECOVERY_DELAYS_MS = [120, 500] let terminalOutputRecoveryScheduled = false -let terminalVisibilityFrameRecoveryScheduled = false -let terminalVisibilityDelayedRecoveryScheduled = false - -type TerminalRecoveryTestWindow = Window & { - __terminalTabOverlapSuppressVisibilityRecovery?: boolean -} - -function isVisibilityRecoverySuppressedForTest(): boolean { - return ( - e2eConfig.exposeStore && - typeof window !== 'undefined' && - Boolean((window as TerminalRecoveryTestWindow).__terminalTabOverlapSuppressVisibilityRecovery) - ) -} function scheduleNextFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame === 'function') { @@ -37,21 +22,13 @@ function resetAtlasesAndRefreshPanes(): void { } } -type AtlasRecoveryBurstCallbacks = { - onComplete?: () => void - onFirstReset?: () => void -} - -function scheduleAtlasRecoveryBurst(callbacks: AtlasRecoveryBurstCallbacks = {}): void { - scheduleNextFrame(() => { - resetAtlasesAndRefreshPanes() - callbacks.onFirstReset?.() - }) +function scheduleAtlasRecoveryBurst(onComplete?: () => void): void { + scheduleNextFrame(() => resetAtlasesAndRefreshPanes()) for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) { globalThis.setTimeout(() => { resetAtlasesAndRefreshPanes() if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) { - callbacks.onComplete?.() + onComplete?.() } }, delayMs) } @@ -70,39 +47,7 @@ export function scheduleTerminalWebglAtlasRecovery(): void { terminalOutputRecoveryScheduled = true // Why: TUI redraw bursts can corrupt xterm's shared WebGL glyph atlas without // a context-loss event; coalesce resets so output storms do not queue timers. - scheduleAtlasRecoveryBurst({ - onComplete: () => { - terminalOutputRecoveryScheduled = false - } + scheduleAtlasRecoveryBurst(() => { + terminalOutputRecoveryScheduled = false }) } - -export function scheduleTerminalVisibilityWebglRecovery(): void { - if (isVisibilityRecoverySuppressedForTest()) { - return - } - if (terminalVisibilityFrameRecoveryScheduled) { - return - } - terminalVisibilityFrameRecoveryScheduled = true - // Why: tab reveal is a separate repaint boundary from hidden-output parsing, - // so an in-flight output recovery must not suppress the returned tab's repaint. - // The first-frame repaint is per reveal, while the delayed tail is coalesced - // so rapid tab cycling does not queue unbounded global refresh bursts. - scheduleNextFrame(() => { - resetAtlasesAndRefreshPanes() - terminalVisibilityFrameRecoveryScheduled = false - }) - if (terminalVisibilityDelayedRecoveryScheduled) { - return - } - terminalVisibilityDelayedRecoveryScheduled = true - for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) { - globalThis.setTimeout(() => { - resetAtlasesAndRefreshPanes() - if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) { - terminalVisibilityDelayedRecoveryScheduled = false - } - }, delayMs) - } -} diff --git a/tests/e2e/terminal-recovery-reset-counters.ts b/tests/e2e/terminal-recovery-reset-counters.ts deleted file mode 100644 index 184dbb6b94f..00000000000 --- a/tests/e2e/terminal-recovery-reset-counters.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' - -type RecoveryCounterWindow = Window & { - __terminalTabOverlapResetCounts?: Record - __terminalTabOverlapResetTimes?: Record - __terminalTabOverlapSuppressVisibilityRecovery?: boolean -} - -export async function instrumentTabResetCounters(page: Page, tabIds: string[]): Promise { - const instrumented = await page.evaluate((tabIds) => { - const counterWindow = window as RecoveryCounterWindow - counterWindow.__terminalTabOverlapResetCounts = Object.fromEntries( - tabIds.map((tabId) => [tabId, 0]) - ) - counterWindow.__terminalTabOverlapResetTimes = Object.fromEntries( - tabIds.map((tabId) => [tabId, []]) - ) - return tabIds.map((tabId) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager?.resetWebglTextureAtlases) { - return false - } - const originalReset = manager.resetWebglTextureAtlases.bind(manager) - manager.resetWebglTextureAtlases = () => { - const counts = counterWindow.__terminalTabOverlapResetCounts ?? {} - counts[tabId] = (counts[tabId] ?? 0) + 1 - counterWindow.__terminalTabOverlapResetCounts = counts - const times = counterWindow.__terminalTabOverlapResetTimes ?? {} - times[tabId] = [...(times[tabId] ?? []), performance.now()] - counterWindow.__terminalTabOverlapResetTimes = times - originalReset() - } - return true - }) - }, tabIds) - if (!instrumented.every(Boolean)) { - throw new Error(`could not instrument WebGL reset counters for tabs ${tabIds.join(', ')}`) - } -} - -export async function readTabResetCount(page: Page, tabId: string): Promise { - return page.evaluate( - (tabId) => (window as RecoveryCounterWindow).__terminalTabOverlapResetCounts?.[tabId] ?? 0, - tabId - ) -} - -export async function readTabResetSnapshot(page: Page, tabId: string) { - return page.evaluate((tabId) => { - const counterWindow = window as RecoveryCounterWindow - const times = counterWindow.__terminalTabOverlapResetTimes?.[tabId] ?? [] - return { - count: counterWindow.__terminalTabOverlapResetCounts?.[tabId] ?? 0, - latestAt: times.at(-1) ?? 0 - } - }, tabId) -} - -export async function setVisibilityRecoverySuppressed( - page: Page, - suppressed: boolean -): Promise { - await page.evaluate((suppressed) => { - ;(window as RecoveryCounterWindow).__terminalTabOverlapSuppressVisibilityRecovery = suppressed - }, suppressed) -} diff --git a/tests/e2e/terminal-recovery-timeout-stretch.ts b/tests/e2e/terminal-recovery-timeout-stretch.ts deleted file mode 100644 index e6a880b496d..00000000000 --- a/tests/e2e/terminal-recovery-timeout-stretch.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' - -export const STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS = 3_000 - -type TimeoutStretchWindow = Window & { - __terminalTabOverlapOriginalSetTimeout?: typeof window.setTimeout -} - -export async function stretchFiveHundredMillisecondTimeouts(page: Page): Promise { - await page.evaluate((stretchedDelayMs) => { - const testWindow = window as TimeoutStretchWindow - if (testWindow.__terminalTabOverlapOriginalSetTimeout) { - return - } - const originalSetTimeout = window.setTimeout - const boundSetTimeout = originalSetTimeout.bind(window) - testWindow.__terminalTabOverlapOriginalSetTimeout = originalSetTimeout - // Why: terminal reveal recovery tests must prove reveal repaint, so the old - // hidden-output fallback is stretched out of the assertion window. - window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => - boundSetTimeout( - handler, - timeout === 500 ? stretchedDelayMs : timeout, - ...args - )) as typeof window.setTimeout - }, STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS) -} - -export async function restoreFiveHundredMillisecondTimeouts(page: Page): Promise { - await page.evaluate(() => { - const testWindow = window as TimeoutStretchWindow - if (!testWindow.__terminalTabOverlapOriginalSetTimeout) { - return - } - window.setTimeout = testWindow.__terminalTabOverlapOriginalSetTimeout - delete testWindow.__terminalTabOverlapOriginalSetTimeout - }) -} diff --git a/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts b/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts deleted file mode 100644 index 16c28254b0a..00000000000 --- a/tests/e2e/terminal-tab-switch-overlap-repro.spec.ts +++ /dev/null @@ -1,830 +0,0 @@ -import { writeFileSync } from 'node:fs' -import path from 'node:path' -import type { Page, TestInfo } from '@stablyai/playwright-test' -import { PNG } from 'pngjs' -import { test, expect } from './helpers/orca-app' -import { - ensureTerminalVisible, - getActiveTabId, - getActiveWorktreeId, - waitForActiveWorktree, - waitForSessionReady -} from './helpers/store' -import { - getTerminalContent, - sendToTerminal, - waitForActiveTerminalManager -} from './helpers/terminal' -import { - restoreFiveHundredMillisecondTimeouts, - STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS, - stretchFiveHundredMillisecondTimeouts -} from './terminal-recovery-timeout-stretch' -import { - instrumentTabResetCounters, - readTabResetCount, - readTabResetSnapshot, - setVisibilityRecoverySuppressed -} from './terminal-recovery-reset-counters' -import { compareTerminalScreenshots } from './terminal-screenshot-diff' - -const TAB_A_MARKER = 'ORCA_OVERLAP_REPRO_TAB_A_ONLY' -const TAB_B_MARKER = 'ORCA_OVERLAP_REPRO_TAB_B_ONLY' -const TAB_B_GLYPHS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' -const TAB_B_COLOR = { red: 255, green: 75, blue: 170 } -const VISUAL_OVERLAP_PROBE_CYCLES = 140 -const SIBLING_COLOR_PIXEL_FLOOR = 150 -const SIBLING_COLOR_PIXEL_DELTA = 100 -const REVEAL_RECOVERY_TIMEOUT_MS = 180 -const REAL_CLAUDE_PROBE_CYCLES = 160 -const REFRESH_REPAIR_DIFF_RATIO = 0.035 - -type TabIdentity = { - tabId: string - leafId: string - ptyId: string -} - -type TabBufferProbe = { - containsOwnMarker: boolean - containsSiblingMarker: boolean - visibleText: string -} - -type RefreshRepairProbe = { - diffRatio: number - diffPixels: number - bufferUnchanged: boolean - beforeText: string - afterText: string - beforeScreenshot: Buffer - afterScreenshot: Buffer -} - -type HiddenOutputWindow = Window & { - __terminalPtyDataInjection?: { - inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean - } -} - -async function setTerminalGpuOn(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - if (!store || !state?.settings) { - throw new Error('Store unavailable') - } - store.setState({ - settings: { - ...state.settings, - terminalGpuAcceleration: 'on' - } - }) - }) -} - -async function ensureTwoTerminalTabs( - page: Page -): Promise<{ firstTabId: string; secondTabId: string }> { - const worktreeId = (await getActiveWorktreeId(page))! - const result = await page.evaluate((worktreeId) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const state = store.getState() - const existing = state.tabsByWorktree[worktreeId] ?? [] - const first = existing.find((tab) => tab.type === 'terminal') ?? state.createTab(worktreeId) - let second = existing.find((tab) => tab.type === 'terminal' && tab.id !== first.id) - if (!second) { - second = state.createTab(worktreeId, undefined, undefined, { activate: false }) - } - state.setActiveTab(first.id) - state.setActiveTabType('terminal') - return { firstTabId: first.id, secondTabId: second.id } - }, worktreeId) - await waitForTerminalManagerOnTab(page, result.firstTabId) - await waitForTerminalManagerOnTab(page, result.secondTabId) - return result -} - -async function ensureThreeTerminalTabs( - page: Page -): Promise<{ firstTabId: string; secondTabId: string; thirdTabId: string }> { - const worktreeId = (await getActiveWorktreeId(page))! - const result = await page.evaluate((worktreeId) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const state = store.getState() - const terminalTabs = (state.tabsByWorktree[worktreeId] ?? []).filter( - (tab) => tab.type === 'terminal' - ) - const first = terminalTabs[0] ?? state.createTab(worktreeId) - const second = - terminalTabs.find((tab) => tab.id !== first.id) ?? - state.createTab(worktreeId, undefined, undefined, { activate: false }) - const third = - terminalTabs.find((tab) => tab.id !== first.id && tab.id !== second.id) ?? - state.createTab(worktreeId, undefined, undefined, { activate: false }) - state.setActiveTab(first.id) - state.setActiveTabType('terminal') - return { firstTabId: first.id, secondTabId: second.id, thirdTabId: third.id } - }, worktreeId) - await waitForTerminalManagerOnTab(page, result.firstTabId) - await waitForTerminalManagerOnTab(page, result.secondTabId) - await waitForTerminalManagerOnTab(page, result.thirdTabId) - return result -} - -async function waitForTerminalManagerOnTab(page: Page, tabId: string): Promise { - await expect - .poll( - () => - page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - return Boolean(manager?.getActivePane?.() ?? manager?.getPanes?.()[0]) - }, tabId), - { timeout: 15_000, message: `terminal manager did not mount for ${tabId}` } - ) - .toBe(true) -} - -async function activateTerminalTab(page: Page, tabId: string): Promise { - await page.evaluate((tabId) => { - const state = window.__store?.getState() - state?.setActiveTab(tabId) - state?.setActiveTabType('terminal') - }, tabId) - await expect - .poll( - () => - page - .locator(`[data-testid="sortable-tab"][data-active="true"]`) - .getAttribute('data-tab-id'), - { timeout: 5_000, message: `terminal tab ${tabId} did not become active` } - ) - .toBe(tabId) -} - -async function waitForWebglOnTab(page: Page, tabId: string): Promise { - await page.evaluate((tabId) => { - window.__paneManagers?.get(tabId)?.setTerminalGpuAcceleration?.('on') - }, tabId) - return page - .waitForFunction( - (tabId) => { - const diagnostics = window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? [] - return diagnostics.some((entry) => entry.hasWebgl) - }, - tabId, - { timeout: 15_000 } - ) - .then(() => true) - .catch(() => false) -} - -async function readTabIdentity(page: Page, tabId: string): Promise { - await expect - .poll( - () => - page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - return pane?.container.dataset.ptyId ?? null - }, tabId), - { timeout: 20_000, message: `terminal tab ${tabId} did not bind a PTY` } - ) - .not.toBeNull() - const identity = await page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - return null - } - return { - tabId, - leafId: pane.container.dataset.leafId ?? null, - ptyId: pane.container.dataset.ptyId ?? null - } - }, tabId) - if (!identity?.leafId || !identity.ptyId) { - throw new Error(`terminal tab ${tabId} did not bind a PTY`) - } - return identity -} - -async function readTabBufferProbe( - page: Page, - tabId: string, - ownMarker: string, - siblingMarker: string -): Promise { - return page.evaluate( - ({ tabId, ownMarker, siblingMarker }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error(`No terminal pane for ${tabId}`) - } - const activeBuffer = pane.terminal.buffer.active - const rows: string[] = [] - for (let row = 0; row < pane.terminal.rows; row += 1) { - rows.push(activeBuffer.getLine(activeBuffer.viewportY + row)?.translateToString(true) ?? '') - } - const visibleText = rows.join('\n') - return { - containsOwnMarker: visibleText.includes(ownMarker), - containsSiblingMarker: visibleText.includes(siblingMarker), - visibleText - } - }, - { tabId, ownMarker, siblingMarker } - ) -} - -async function startTuiStream( - page: Page, - ptyId: string, - scriptPath: string, - marker: string, - glyphs: string, - color: { red: number; green: number; blue: number }, - cadenceMs: number -): Promise { - const script = [ - `const marker=${JSON.stringify(marker)};`, - `const glyphs=${JSON.stringify(glyphs)};`, - `const color=${JSON.stringify(color)};`, - `const cadence=${cadenceMs};`, - 'let frame=0;', - 'function bar(width){return "█".repeat((frame % width) + 1) + "░".repeat(width - ((frame % width) + 1));}', - 'function paint(text){return `\\x1b[38;2;${color.red};${color.green};${color.blue}m${text}\\x1b[0m`; }', - 'function emit(){', - ' const lines=[];', - ' lines.push("\\x1b[?2026h");', - ' lines.push("\\x1b[?1049h");', - ' lines.push("\\x1b[2J\\x1b[H\\x1b[?25l");', - ' lines.push(paint(`╭────────────────────────────────────────────────────────────────────╮`));', - ' lines.push(paint(`│ ${marker} frame ${String(frame).padStart(4,"0")} ${bar(18)} │`));', - ' lines.push(paint(`├────────────────────────────────────────────────────────────────────┤`));', - ' for (let row=0; row<18; row++) {', - ' lines.push(paint(`│ row ${String(row).padStart(2,"0")} ${glyphs} ${bar(10)} │`));', - ' }', - ' lines.push(paint(`╰────────────────────────────────────────────────────────────────────╯`));', - ' lines.push("\\x1b[?25h\\x1b[?2026l");', - ' process.stdout.write(lines.join("\\r\\n"));', - ' frame += 1;', - '}', - 'emit();', - 'setInterval(emit, cadence);' - ].join('') - writeFileSync(scriptPath, script) - await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) -} - -async function writeSparsePromptFrame( - page: Page, - ptyId: string, - scriptPath: string -): Promise { - const script = [ - `const marker=${JSON.stringify(TAB_A_MARKER)};`, - 'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25h");', - 'process.stdout.write(`${marker}\\r\\n`);', - 'process.stdout.write("Please make more to 5 more different random files. Explore orca a bit actually, then do it. BBB\\r\\n");' - ].join('') - writeFileSync(scriptPath, script) - await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) -} - -async function writeDenseStaticFrame( - page: Page, - ptyId: string, - scriptPath: string, - marker: string -): Promise { - const script = [ - `const marker=${JSON.stringify(marker)};`, - 'const glyphs="ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~";', - 'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25l");', - 'for (let row = 0; row < 22; row += 1) {', - ' process.stdout.write(`${marker} row ${String(row).padStart(2, "0")} | ${glyphs} |\\r\\n`);', - '}' - ].join('') - writeFileSync(scriptPath, script) - await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) -} - -async function injectHiddenTuiFrame( - page: Page, - identity: TabIdentity, - marker: string -): Promise { - const paneKey = `${identity.tabId}:${identity.leafId}` - const frame = [ - '\x1b[?2026h', - '\x1b[?1049h', - '\x1b[2J\x1b[H\x1b[?25l', - `╭────────────────────────────────────────────────────────╮`, - `│ ${marker} hidden atlas recovery frame │`, - `├────────────────────────────────────────────────────────┤`, - ...Array.from( - { length: 12 }, - (_, row) => `│ hidden row ${String(row).padStart(2, '0')} ███████████████████ │` - ), - `╰────────────────────────────────────────────────────────╯`, - '\x1b[?25h\x1b[?2026l' - ].join('\r\n') - const injected = await page.evaluate( - ({ paneKey, frame }) => - (window as HiddenOutputWindow).__terminalPtyDataInjection?.inject(paneKey, frame, { - seq: frame.length, - rawLength: frame.length - }) ?? false, - { paneKey, frame } - ) - if (!injected) { - throw new Error(`No terminal PTY data injector registered for ${paneKey}`) - } -} - -async function corruptTabAtlas(page: Page, tabId: string): Promise { - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - return 0 - } - const canvases = Array.from( - document.querySelectorAll(`[data-terminal-tab-id="${tabId}"] canvas`) - ) - const noise = new Uint8Array(64 * 64 * 4) - for (let index = 0; index < noise.length; index += 4) { - noise[index] = (index * 7) % 256 - noise[index + 1] = (index * 13) % 256 - noise[index + 2] = (index * 29) % 256 - noise[index + 3] = 255 - } - let corrupted = 0 - for (const canvas of canvases) { - const gl = - (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? - (canvas.getContext('webgl') as WebGLRenderingContext | null) - if (!gl) { - continue - } - const maxUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) as number - for (let unit = 0; unit < maxUnits; unit += 1) { - gl.activeTexture(gl.TEXTURE0 + unit) - if (!gl.getParameter(gl.TEXTURE_BINDING_2D)) { - continue - } - for (const [x, y] of [ - [0, 0], - [64, 0], - [128, 0], - [192, 0], - [0, 64], - [64, 64], - [128, 64], - [192, 64] - ]) { - gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, noise) - if (gl.getError() === gl.NO_ERROR) { - corrupted += 1 - } - } - if (corrupted > 0) { - break - } - } - gl.activeTexture(gl.TEXTURE0) - if (corrupted > 0) { - break - } - } - pane.terminal.refresh(0, pane.terminal.rows - 1) - return corrupted - }, tabId) -} - -async function resetAndRefreshTab(page: Page, tabId: string): Promise { - await page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - manager?.resetWebglTextureAtlases?.() - manager?.refreshAllPanes?.() - }, tabId) - await waitForTwoAnimationFrames(page) -} - -async function waitForTwoAnimationFrames(page: Page): Promise { - await page.evaluate( - () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) - ) -} - -async function captureTabScreen(page: Page, tabId: string): Promise { - const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() - await expect(screen).toBeVisible() - return screen.screenshot({ animations: 'disabled' }) -} - -function countColorPixels( - buffer: Buffer, - target: { red: number; green: number; blue: number } -): number { - const image = PNG.sync.read(buffer) - let pixels = 0 - for (let offset = 0; offset < image.data.length; offset += 4) { - const alpha = image.data[offset + 3] ?? 0 - if (alpha < 120) { - continue - } - const red = image.data[offset] ?? 0 - const green = image.data[offset + 1] ?? 0 - const blue = image.data[offset + 2] ?? 0 - const distance = - Math.abs(red - target.red) + Math.abs(green - target.green) + Math.abs(blue - target.blue) - if (distance <= 95) { - pixels += 1 - } - } - return pixels -} - -async function attachArtifact( - testInfo: TestInfo, - name: string, - body: Buffer | string -): Promise { - await testInfo.attach(name, { - body: typeof body === 'string' ? Buffer.from(body) : body, - contentType: typeof body === 'string' ? 'text/plain' : 'image/png' - }) -} - -async function refreshActiveTabAndCompare(page: Page, tabId: string): Promise { - const beforeText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText - const beforeScreenshot = await captureTabScreen(page, tabId) - await page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error(`No terminal pane for ${tabId}`) - } - pane.terminal.refresh(0, pane.terminal.rows - 1) - }, tabId) - await waitForTwoAnimationFrames(page) - const afterScreenshot = await captureTabScreen(page, tabId) - const afterText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText - const diff = compareTerminalScreenshots(beforeScreenshot, afterScreenshot) - return { - diffRatio: diff.diffRatio, - diffPixels: diff.diffPixels, - bufferUnchanged: beforeText === afterText, - beforeText, - afterText, - beforeScreenshot, - afterScreenshot - } -} - -async function startClaudeEditSession( - page: Page, - ptyId: string, - marker: string, - filePrefix: string -): Promise { - const prompt = [ - `This is an Orca terminal renderer reproduction run. Marker: ${marker}.`, - 'Create or edit 10 small scratch files in the current repo.', - `Name them ${filePrefix}-01.txt through ${filePrefix}-10.txt.`, - 'After each file edit, run a tiny shell command that prints the marker and sleep 1 second.', - 'Keep working without asking questions. The visible terminal activity is the point.' - ].join(' ') - await sendToTerminal( - page, - ptyId, - `claude --model sonnet --effort low --dangerously-skip-permissions --permission-mode bypassPermissions ${JSON.stringify(prompt)}\r` - ) -} - -test.describe('Terminal tab switch visual overlap repro @headful', () => { - test('manual probe for visible sibling TUI pixels while tab buffers stay isolated', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - test.skip( - process.env.ORCA_E2E_VISUAL_OVERLAP_PROBE !== '1', - 'Set ORCA_E2E_VISUAL_OVERLAP_PROBE=1 to run the best-effort visual overlap probe' - ) - test.setTimeout(180_000) - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - await setTerminalGpuOn(orcaPage) - - const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) - await activateTerminalTab(orcaPage, firstTabId) - const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) - const firstIdentity = await readTabIdentity(orcaPage, firstTabId) - - await activateTerminalTab(orcaPage, secondTabId) - const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) - const secondIdentity = await readTabIdentity(orcaPage, secondTabId) - test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs') - - await writeSparsePromptFrame( - orcaPage, - firstIdentity.ptyId, - path.join(testRepoPath, 'orca-overlap-tab-a-sparse.mjs') - ) - await startTuiStream( - orcaPage, - secondIdentity.ptyId, - path.join(testRepoPath, 'orca-overlap-tab-b.mjs'), - TAB_B_MARKER, - TAB_B_GLYPHS, - TAB_B_COLOR, - 41 - ) - - await activateTerminalTab(orcaPage, firstTabId) - await expect - .poll(() => getTerminalContent(orcaPage, 12_000), { - timeout: 20_000, - message: 'tab A sparse prompt frame did not reach the visible terminal' - }) - .toContain(TAB_A_MARKER) - const baselineA = await captureTabScreen(orcaPage, firstTabId) - const baselineAHasSiblingColor = countColorPixels(baselineA, TAB_B_COLOR) - - await activateTerminalTab(orcaPage, secondTabId) - await expect - .poll(() => getTerminalContent(orcaPage, 12_000), { - timeout: 20_000, - message: 'tab B TUI stream did not reach the visible terminal' - }) - .toContain(`${TAB_B_MARKER} frame`) - const baselineB = await captureTabScreen(orcaPage, secondTabId) - const baselineBHasOwnColor = countColorPixels(baselineB, TAB_B_COLOR) - expect(baselineBHasOwnColor).toBeGreaterThan(500) - - const reports: string[] = [] - for (let cycle = 0; cycle < VISUAL_OVERLAP_PROBE_CYCLES; cycle += 1) { - await activateTerminalTab(orcaPage, secondTabId) - await orcaPage.waitForTimeout(15) - await activateTerminalTab(orcaPage, firstTabId) - const activeTabId = (await getActiveTabId(orcaPage))! - const screenshot = await captureTabScreen(orcaPage, activeTabId) - const probe = await readTabBufferProbe(orcaPage, activeTabId, TAB_A_MARKER, TAB_B_MARKER) - if (!probe.containsOwnMarker || probe.containsSiblingMarker) { - reports.push( - `cycle ${cycle} buffer mismatch active=${activeTabId} own=${probe.containsOwnMarker} sibling=${probe.containsSiblingMarker}` - ) - await attachArtifact(testInfo, `buffer-mismatch-cycle-${cycle}.txt`, probe.visibleText) - break - } - const siblingColorPixels = countColorPixels(screenshot, TAB_B_COLOR) - if ( - siblingColorPixels > - Math.max(SIBLING_COLOR_PIXEL_FLOOR, baselineAHasSiblingColor + SIBLING_COLOR_PIXEL_DELTA) - ) { - reports.push( - `cycle ${cycle} visual sibling color active=${activeTabId} siblingPixels=${siblingColorPixels} baselineSiblingPixels=${baselineAHasSiblingColor} bufferSibling=${probe.containsSiblingMarker}` - ) - await attachArtifact(testInfo, `overlap-candidate-${cycle}.png`, screenshot) - await attachArtifact(testInfo, `overlap-buffer-${cycle}.txt`, probe.visibleText) - break - } - await orcaPage.waitForTimeout(20) - } - - await attachArtifact(testInfo, 'tab-a-baseline.png', baselineA) - await attachArtifact(testInfo, 'tab-b-baseline.png', baselineB) - expect( - reports, - reports.length > 0 - ? `active terminal showed sibling-colored pixels while buffers stayed isolated:\n${reports.join('\n')}` - : undefined - ).toEqual([]) - }) - - test('repairs returned tab pixels even while hidden-output recovery is in flight', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - await setTerminalGpuOn(orcaPage) - - const { firstTabId, secondTabId, thirdTabId } = await ensureThreeTerminalTabs(orcaPage) - await activateTerminalTab(orcaPage, firstTabId) - const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) - const firstIdentity = await readTabIdentity(orcaPage, firstTabId) - await writeDenseStaticFrame( - orcaPage, - firstIdentity.ptyId, - path.join(testRepoPath, 'orca-overlap-static-a.mjs'), - 'ORCA_DETERMINISTIC_A' - ) - await expect - .poll(() => getTerminalContent(orcaPage, 12_000), { - timeout: 20_000, - message: 'tab A static frame did not reach the visible terminal' - }) - .toContain('ORCA_DETERMINISTIC_A row 21') - await resetAndRefreshTab(orcaPage, firstTabId) - const baseline = await captureTabScreen(orcaPage, firstTabId) - - const activeCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId) - test.skip(activeCorruptedTiles === 0, 'Could not inject WebGL atlas corruption') - const activeCorruption = await captureTabScreen(orcaPage, firstTabId) - expect( - compareTerminalScreenshots(baseline, activeCorruption).matches, - 'atlas corruption precondition should visibly change active tab glyphs' - ).toBe(false) - await resetAndRefreshTab(orcaPage, firstTabId) - const restored = await captureTabScreen(orcaPage, firstTabId) - expect(compareTerminalScreenshots(baseline, restored).matches).toBe(true) - - await activateTerminalTab(orcaPage, thirdTabId) - const thirdWebgl = await waitForWebglOnTab(orcaPage, thirdTabId) - const thirdIdentity = await readTabIdentity(orcaPage, thirdTabId) - await activateTerminalTab(orcaPage, secondTabId) - const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) - test.skip( - !firstWebgl || !secondWebgl || !thirdWebgl, - 'WebGL did not attach on all regular terminal tabs' - ) - await instrumentTabResetCounters(orcaPage, [firstTabId, secondTabId, thirdTabId]) - await stretchFiveHundredMillisecondTimeouts(orcaPage) - try { - await injectHiddenTuiFrame(orcaPage, thirdIdentity, 'ORCA_HIDDEN_OUTPUT_RECOVERY_IN_FLIGHT') - // Why: the regression is the output-recovery latch suppressing reveal - // recovery. Prove the hidden-output burst reached its rAF and 120ms passes - // before reveal, while its old 500ms fallback is stretched out of range. - await expect - .poll(() => readTabResetCount(orcaPage, thirdTabId), { - timeout: 2_000, - message: 'hidden-output recovery did not fire its first two reset passes' - }) - .toBeGreaterThanOrEqual(2) - } finally { - await restoreFiveHundredMillisecondTimeouts(orcaPage) - } - const hiddenOutputResetCount = await readTabResetCount(orcaPage, thirdTabId) - expect(hiddenOutputResetCount).toBeGreaterThanOrEqual(2) - expect( - hiddenOutputResetCount, - 'hidden-output fallback fired before the reveal precondition could be exercised' - ).toBeLessThanOrEqual(2) - const hiddenOutputResetWindow = await readTabResetSnapshot(orcaPage, thirdTabId) - const resetCountBeforeSuppressedReveal = await readTabResetCount(orcaPage, firstTabId) - expect(resetCountBeforeSuppressedReveal).toBeGreaterThanOrEqual(2) - expect( - resetCountBeforeSuppressedReveal, - 'hidden-output fallback fired before tab reveal' - ).toBeLessThanOrEqual(2) - let suppressedReveal: Buffer | null = null - try { - await setVisibilityRecoverySuppressed(orcaPage, true) - await activateTerminalTab(orcaPage, firstTabId) - await waitForTwoAnimationFrames(orcaPage) - const staleCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId) - expect(staleCorruptedTiles).toBeGreaterThan(0) - suppressedReveal = await captureTabScreen(orcaPage, firstTabId) - const suppressedRevealDiff = compareTerminalScreenshots(baseline, suppressedReveal) - expect( - suppressedRevealDiff.matches, - 'simulated stale returned-tab pixels must be visible when recovery is suppressed' - ).toBe(false) - await activateTerminalTab(orcaPage, secondTabId) - await waitForTwoAnimationFrames(orcaPage) - } finally { - await setVisibilityRecoverySuppressed(orcaPage, false) - } - const resetCountBeforeReveal = await readTabResetCount(orcaPage, firstTabId) - expect(resetCountBeforeReveal).toBe(resetCountBeforeSuppressedReveal) - expect(resetCountBeforeReveal).toBeGreaterThanOrEqual(2) - expect( - resetCountBeforeReveal, - 'hidden-output fallback fired before tab reveal' - ).toBeLessThanOrEqual(2) - const revealStartedAt = await orcaPage.evaluate(() => performance.now()) - expect( - revealStartedAt - hiddenOutputResetWindow.latestAt, - 'tab reveal started too close to the stretched hidden-output fallback to prove independent recovery' - ).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS - REVEAL_RECOVERY_TIMEOUT_MS) - await activateTerminalTab(orcaPage, firstTabId) - await expect - .poll(() => readTabResetCount(orcaPage, firstTabId), { - timeout: REVEAL_RECOVERY_TIMEOUT_MS, - message: 'tab reveal did not schedule an independent WebGL recovery reset' - }) - .toBeGreaterThan(resetCountBeforeReveal) - const resetAfterReveal = await readTabResetSnapshot(orcaPage, firstTabId) - expect( - resetAfterReveal.latestAt - revealStartedAt, - 'reveal recovery must beat the old hidden-output 500ms fallback reset' - ).toBeLessThan(REVEAL_RECOVERY_TIMEOUT_MS) - expect( - resetAfterReveal.latestAt - hiddenOutputResetWindow.latestAt, - 'post-reveal reset must occur before the hidden-output 500ms fallback could explain it' - ).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS) - await waitForTwoAnimationFrames(orcaPage) - const afterReveal = await captureTabScreen(orcaPage, firstTabId) - const afterRevealDiff = compareTerminalScreenshots(baseline, afterReveal) - await attachArtifact(testInfo, 'deterministic-baseline.png', baseline) - await attachArtifact( - testInfo, - 'deterministic-suppressed-reveal.png', - suppressedReveal ?? Buffer.from('') - ) - await attachArtifact(testInfo, 'deterministic-after-reveal.png', afterReveal) - await attachArtifact( - testInfo, - 'deterministic-reset-counts.txt', - `beforeReveal=${resetCountBeforeReveal}\nafterReveal=${resetAfterReveal.count}\nrevealDelayMs=${resetAfterReveal.latestAt - revealStartedAt}\nhiddenSecondResetToRevealMs=${revealStartedAt - hiddenOutputResetWindow.latestAt}\nstretchedHiddenOutputFallbackMs=${STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS}\n` - ) - expect( - afterRevealDiff.matches, - `returned tab still had stale WebGL pixels after reveal: ${afterRevealDiff.diffPixels} px (${(afterRevealDiff.diffRatio * 100).toFixed(2)}%)` - ).toBe(true) - }) - - test('real Claude sessions do not leave refresh-repairable stale pixels', async ({ - orcaPage - }, testInfo) => { - test.skip( - process.env.ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO !== '1', - 'Set ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO=1 to spend real Claude Code tokens on this repro' - ) - test.setTimeout(420_000) - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - await setTerminalGpuOn(orcaPage) - - const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) - await activateTerminalTab(orcaPage, firstTabId) - const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) - const firstIdentity = await readTabIdentity(orcaPage, firstTabId) - await activateTerminalTab(orcaPage, secondTabId) - const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) - const secondIdentity = await readTabIdentity(orcaPage, secondTabId) - test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs') - - await startClaudeEditSession( - orcaPage, - firstIdentity.ptyId, - 'ORCA_REAL_CLAUDE_A', - 'orca-real-claude-a' - ) - await startClaudeEditSession( - orcaPage, - secondIdentity.ptyId, - 'ORCA_REAL_CLAUDE_B', - 'orca-real-claude-b' - ) - - const reports: string[] = [] - for (let cycle = 0; cycle < REAL_CLAUDE_PROBE_CYCLES; cycle += 1) { - const tabId = cycle % 2 === 0 ? firstTabId : secondTabId - await activateTerminalTab(orcaPage, tabId) - await orcaPage.waitForTimeout(120) - const repair = await refreshActiveTabAndCompare(orcaPage, tabId) - if (repair.bufferUnchanged && repair.diffRatio > REFRESH_REPAIR_DIFF_RATIO) { - reports.push( - `cycle ${cycle} tab=${tabId} refresh changed ${(repair.diffRatio * 100).toFixed(2)}% of pixels without buffer change (${repair.diffPixels} px)` - ) - await attachArtifact( - testInfo, - `real-claude-before-refresh-${cycle}.png`, - repair.beforeScreenshot - ) - await attachArtifact( - testInfo, - `real-claude-after-refresh-${cycle}.png`, - repair.afterScreenshot - ) - await attachArtifact(testInfo, `real-claude-buffer-${cycle}.txt`, repair.beforeText) - break - } - await orcaPage.waitForTimeout(500) - } - - expect( - reports, - reports.length > 0 - ? `real Claude terminal surface had refresh-repairable stale pixels:\n${reports.join('\n')}` - : undefined - ).toEqual([]) - }) -}) From 7f1e2804365cd242353ceaebd21ded0e72e1dd59 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:04:13 -0700 Subject: [PATCH 016/233] Cut fseventsd load: fix macOS exclusion cap, drop wide always-on FSEvents streams (#7068) * Cut fseventsd load: fix macOS exclusion cap, drop wide always-on FSEvents streams - @parcel/watcher maps plain-path ignores to FSEventStreamSetExclusionPaths, but macOS caps exclusions at 8 per stream and fails closed. Orca passed 9, silently disabling ALL daemon-side exclusions: fseventsd delivered every node_modules/.git event to each per-worktree explorer stream (measured 16x client CPU). Cap plain paths at 8 (extras demoted to userspace globs) via a shared filesystem-watcher-ignore module, also unifying the runtime file-watcher host list so identical roots share one native stream. - worktree-base-directory-watcher subscribed recursive FSEvents streams over every repo's ENTIRE workspace root (all worktrees) and whole common .git (objects included) with glob-only ignores (zero daemon-side exclusion), just to observe a few shallow paths. Replace with a 2s readdir/stat poller (zero fseventsd clients, measured 70x less delivery CPU); SSH targets keep provider.watch unchanged. - serve-sim state watcher held an app-lifetime fs.watch on $TMPDIR (the noisiest dir on the system); its existing 250ms existence poll makes the parent watch redundant, so drop it. Co-authored-by: Orca * Eliminate base-poller idle cost: mtime-gated ticks + narrow macOS worktree-metadata stream - base targets: tick is now one stat of each gate dir (root + nested containers); the readdir + marker fan-out runs only when a gate dir changed, with a 30s ungated backstop and a bounded pending-marker recheck list. Idle tick 600us -> 1.1us at 146 worktrees. - git-common targets on macOS: replace the 2s entry sweep with one narrow native stream on /.git/worktrees (tiny, rare-churn tree): zero idle syscalls and external worktree add/remove/HEAD detection back to sub-second. Re-arms via existence poll across git worktree prune deleting/recreating the dir. Win/Linux keep the listing poll (no fseventsd there; a Windows dir handle could block prune). - split git-common logic into worktree-git-common-watch.ts (max-lines). Co-authored-by: Orca --------- Co-authored-by: Orca --- .../emulator/serve-sim-state-watcher.test.ts | 2 +- src/main/emulator/serve-sim-state-watcher.ts | 20 +- .../ipc/filesystem-watcher-ignore.test.ts | 39 +++ src/main/ipc/filesystem-watcher-ignore.ts | 36 ++ src/main/ipc/filesystem-watcher.ts | 25 +- .../worktree-base-directory-poller.test.ts | 312 ++++++++++++++++++ .../ipc/worktree-base-directory-poller.ts | 263 +++++++++++++++ .../worktree-base-directory-watcher.test.ts | 58 ++-- .../ipc/worktree-base-directory-watcher.ts | 22 +- src/main/ipc/worktree-git-common-watch.ts | 233 +++++++++++++ src/main/runtime/file-watcher-host.ts | 21 +- 11 files changed, 944 insertions(+), 87 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-ignore.test.ts create mode 100644 src/main/ipc/filesystem-watcher-ignore.ts create mode 100644 src/main/ipc/worktree-base-directory-poller.test.ts create mode 100644 src/main/ipc/worktree-base-directory-poller.ts create mode 100644 src/main/ipc/worktree-git-common-watch.ts diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 69f6d8f9b2c..006531dff20 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -34,7 +34,7 @@ describe('ServeSimStateWatcher', () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) cleanupPaths.push(parentDir) const stateDir = join(parentDir, 'serve-sim') - const watcher = new ServeSimStateWatcher({ parentDir, stateDir }) + const watcher = new ServeSimStateWatcher({ stateDir }) const events: ServeSimStateDetectedEvent[] = [] watcher.bindPty('pty-1', 'worktree-1') diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index d1ff49f8d67..f250fe7f7b9 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -68,19 +68,16 @@ function helperInstanceKey(info: ServeSimHelperInfo): string { export class ServeSimStateWatcher { private readonly stateDir: string - private readonly parentDir: string private readonly ptyToWorktree = new Map() private readonly ptyBuffers = new Map() private readonly seenExternalKeys = new Set() private readonly orcaManagedHelperKeys = new Set() private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>() - private parentWatcher: FSWatcher | null = null private stateWatcher: FSWatcher | null = null private stateDirPoll: ReturnType | null = null - constructor(options: { stateDir?: string; parentDir?: string } = {}) { + constructor(options: { stateDir?: string } = {}) { this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR - this.parentDir = options.parentDir ?? tmpdir() } onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { @@ -156,25 +153,20 @@ export class ServeSimStateWatcher { } start(): void { - if (this.parentWatcher || this.stateWatcher) { + if (this.stateDirPoll || this.stateWatcher) { return } try { // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. - // Watch the parent tmpdir and attach to serve-sim/ when it appears (or watch it directly if present). + // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR + // registers a permanent FSEvents client on the system's highest-churn + // directory, while an existence poll costs the daemon nothing. this.attachStateDirWatch() if (this.stateWatcher) { this.scanExistingStateFiles() return } - this.parentWatcher = watch(this.parentDir, (_event, filename) => { - if (!filename || String(filename) !== 'serve-sim') { - return - } - this.attachStateDirWatch() - this.scanExistingStateFiles() - }) this.stateDirPoll = setInterval(() => { this.attachStateDirWatch() this.scanExistingStateFiles() @@ -186,12 +178,10 @@ export class ServeSimStateWatcher { } stop(): void { - this.parentWatcher?.close() this.stateWatcher?.close() if (this.stateDirPoll) { clearInterval(this.stateDirPoll) } - this.parentWatcher = null this.stateWatcher = null this.stateDirPoll = null this.ptyToWorktree.clear() diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts new file mode 100644 index 00000000000..6784143e013 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT, + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOption +} from './filesystem-watcher-ignore' + +const realPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + +describe('buildParcelWatcherIgnoreOption', () => { + afterEach(() => { + setPlatform(realPlatform) + }) + + it('keeps at most 8 plain paths on macOS so FSEventStreamSetExclusionPaths succeeds', () => { + setPlatform('darwin') + const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) + // @parcel/watcher passes non-glob entries to FSEventStreamSetExclusionPaths, + // which rejects the WHOLE set past 8 paths — silently disabling daemon-side + // exclusion of node_modules/.git churn. + const plainPaths = option.filter((entry) => !entry.includes('*')) + expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + // Every ignore dir must still be covered, as a path or as a glob. + for (const dir of WATCHER_IGNORE_DIRS) { + expect( + option.some((entry) => entry === dir || entry === `**/${dir}` || entry === `**/${dir}/**`) + ).toBe(true) + } + }) + + it('passes the plain list through on other platforms', () => { + setPlatform('linux') + expect(buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)).toEqual(WATCHER_IGNORE_DIRS) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts new file mode 100644 index 00000000000..61339afb3e4 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -0,0 +1,36 @@ +// Canonical ignore list for recursive worktree watchers (mirrors VS Code's +// predefined recursive-watch excludes). Shared by the explorer watcher and the +// runtime file-watcher host so every @parcel/watcher subscription gets the +// same high-churn exclusions. +export const WATCHER_IGNORE_DIRS: string[] = [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + 'target', + '.venv', + '__pycache__' +] + +// Why: macOS FSEventStreamSetExclusionPaths accepts at most 8 paths and fails +// closed — one entry over the cap and @parcel/watcher silently loses ALL +// daemon-side exclusions, so fseventsd delivers every node_modules/.git event +// to this process (measured ~29x client CPU plus daemon-side delivery load). +// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote +// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is +// therefore meaningful: the first 8 get true daemon-side exclusion on macOS. +export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 + +export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] { + if (process.platform !== 'darwin') { + return [...ignoreDirs] + } + return [ + ...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT), + ...ignoreDirs + .slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + .flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) + ] +} diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 922f7211f49..51cbfbaefc6 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -13,24 +13,11 @@ import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' - -// ── Ignore patterns ────────────────────────────────────────────────── -// Why: high-churn directories are suppressed at the native watcher level -// so events never leave the OS kernel. This list is separate from the -// File Explorer display filter (which only hides rows). Directories like -// `dist` and `build` remain visible in the tree but will not auto-refresh. - -const WATCHER_IGNORE_DIRS: string[] = [ - '.git', - 'node_modules', - 'dist', - 'build', - '.next', - '.cache', - '__pycache__', - 'target', - '.venv' -] +// Why: high-churn directories are suppressed at the native watcher level so +// events never leave the OS/daemon. This list is separate from the File +// Explorer display filter (which only hides rows). Directories like `dist` +// and `build` remain visible in the tree but will not auto-refresh. +import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOption } from './filesystem-watcher-ignore' // ── Debounce helpers ───────────────────────────────────────────────── @@ -309,7 +296,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise = {} +): WorktreeBaseWatchTarget { + const repoConfig: WorktreeBaseRepoWatchConfig = { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false, + ...config + } + return { + key: `${kind}:local:${path}`, + kind, + path, + repos: new Map([[repoConfig.repoId, repoConfig]]) + } +} + +async function waitForEvents( + events: WorktreeBasePollEvent[][], + predicate: (flat: WorktreeBasePollEvent[]) => boolean +): Promise { + await vi.waitFor( + () => { + if (!predicate(events.flat())) { + throw new Error('expected poll events not observed yet') + } + }, + { timeout: 5_000, interval: 20 } + ) + return events.flat() +} + +describe('worktree base directory poller', () => { + const cleanups: (() => Promise)[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-base-poller-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + // Why: macOS tmpdir lives behind the /var -> /private/var symlink and + // native watcher events report resolved paths; production targets are + // realpath'd the same way (canonicalizeExistingPath). + return realpath(root) + } + + it('emits a .git marker create and a worktree delete for flat layouts', async () => { + const root = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktree = join(root, 'external-1') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + const afterCreate = await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + expect( + afterCreate.filter((event) => event.type === 'create' && event.path.endsWith('.git')) + ).toHaveLength(1) + + await rm(worktree, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === worktree) + ) + }) + + it('emits the marker only after it appears for slow checkouts', async () => { + const root = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktree = join(root, 'external-2') + await mkdir(worktree) + // Give the poller time to observe the marker-less dir first. + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 3)) + expect(received.flat()).toHaveLength(0) + + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + }) + + it('scans nested repo containers for nested layouts', async () => { + const root = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root, { nestWorkspaces: true }) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktree = join(root, 'project', 'external-3') + await mkdir(worktree, { recursive: true }) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git')) + ) + }) + + it('skips full scans while the gate dirs are untouched', async () => { + const root = await makeRoot() + const worktree = join(root, 'external-idle') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + const received: WorktreeBasePollEvent[][] = [] + const fullScans: number[] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, onFullScan: () => fullScans.push(Date.now()) } + ) + cleanups.push(() => poller.unsubscribe()) + + // ~8 idle ticks: under the backstop cadence, so the gate should skip + // every full scan (each tick is just gate-dir stats). + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 8)) + expect(fullScans).toHaveLength(0) + expect(received.flat()).toHaveLength(0) + + // Touching the root (new entry) flips the gate and triggers a scan. + await mkdir(join(root, 'external-new')) + await writeFile(join(root, 'external-new', '.git'), 'gitdir: elsewhere') + await waitForEvents(received, (flat) => + flat.some( + (event) => event.type === 'create' && event.path === join(root, 'external-new', '.git') + ) + ) + expect(fullScans.length).toBeGreaterThan(0) + }) + + it('reports git-common worktrees metadata adds, updates, and removals via polling', async () => { + const commonDir = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + // Force the non-darwin poll path so this test is deterministic on all CI. + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + const entry = join(commonDir, 'worktrees', 'external-4') + await mkdir(entry, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === entry) + ) + + // HEAD/gitdir metadata writes land as new files in the entry dir, which + // bumps the entry dir mtime the poller compares. + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === entry) + ) + + await rm(entry, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === entry) + ) + }) + + it('emits deletes for all known worktrees when the root vanishes', async () => { + const root = await makeRoot() + const worktree = join(root, 'external-5') + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS } + ) + cleanups.push(() => poller.unsubscribe()) + + await rm(root, { recursive: true, force: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === worktree) + ) + }) + + describe.runIf(process.platform === 'darwin')('macOS narrow git-common stream', () => { + it('delivers instant add/update/remove without polling', async () => { + const commonDir = await makeRoot() + await mkdir(join(commonDir, 'worktrees')) + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'darwin' } + ) + cleanups.push(() => poller.unsubscribe()) + + const entry = join(commonDir, 'worktrees', 'wt-a') + await mkdir(entry) + await waitForEvents(received, (flat) => flat.some((event) => event.path === entry)) + + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.path === join(entry, 'HEAD')) + ) + + await rm(entry, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === entry) + ) + }) + + it('arms via existence polling when the worktrees dir appears later', async () => { + const commonDir = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'darwin' } + ) + cleanups.push(() => poller.unsubscribe()) + + const worktreesDir = join(commonDir, 'worktrees') + await mkdir(worktreesDir) + // The dir appearing is itself surfaced (a first worktree was added). + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === worktreesDir) + ) + + // And the narrow stream is live afterwards: entry adds are seen. + const entry = join(worktreesDir, 'wt-b') + await mkdir(entry) + await waitForEvents(received, (flat) => flat.some((event) => event.path === entry)) + }) + + it('keeps watching across worktrees dir delete and recreate', async () => { + const commonDir = await makeRoot() + await mkdir(join(commonDir, 'worktrees')) + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'darwin' } + ) + cleanups.push(() => poller.unsubscribe()) + + // Simulate `git worktree prune` removing the empty dir, then a new add + // recreating it. The stream re-arms via the existence poll; the repo + // gets notified on recreate, and subsequent entries are seen live. + const worktreesDir = join(commonDir, 'worktrees') + await rm(worktreesDir, { recursive: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + await mkdir(join(worktreesDir, 'wt-c'), { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === worktreesDir) + ) + + const laterEntry = join(worktreesDir, 'wt-d') + await mkdir(laterEntry) + await waitForEvents(received, (flat) => flat.some((event) => event.path === laterEntry)) + }) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts new file mode 100644 index 00000000000..3f4fc67571b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -0,0 +1,263 @@ +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import { startGitCommonWatch } from './worktree-git-common-watch' + +export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } + +export type WorktreeBaseSubscription = { unsubscribe: () => Promise } + +export type WorktreeBasePollerOptions = { + pollIntervalMs?: number + platform?: NodeJS.Platform + /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ + onFullScan?: () => void +} + +// Why: these targets used to be recursive FSEvents subscriptions spanning the +// entire workspace root (every worktree's full tree) and the repo's whole +// common .git (objects included), forcing fseventsd to deliver all of that +// churn to Orca just to observe a handful of shallow paths. The replacements +// register at most one tiny-scope native stream (macOS git-common) and +// otherwise poll with a dir-mtime gate, so idle cost is a couple of stat +// calls per tick. 2s is fast enough for external `git worktree add/remove`; +// Orca's own worktree operations notify the renderer directly. +export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000 + +// Why: the mtime gate is an optimization, not a correctness boundary — some +// filesystems have coarse dir timestamps, and pending `.git` markers expire. +// A periodic ungated scan guarantees eventual convergence. +export const WORKTREE_BASE_BACKSTOP_TICKS = 15 + +// Why: a `.git` completion marker lands within moments of its worktree dir +// (git writes it before populating the checkout). Dirs that never get one are +// not worktrees; stop re-statting them after this many ticks and let the +// backstop scan cover the pathological case. +const PENDING_MARKER_MAX_TICKS = 300 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // dirs whose listing determines the candidate set: the root plus any + // nested repo containers. Their stat signatures gate the next full scan. + gateDirs: string[] +} + +// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested +// repo's container, mirroring what worktree-base-directory-event-filter +// matches: `/.git` completion markers and `` deletions. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + const configs = [...repos.values()] + const includeFlat = configs.some((config) => !config.nestWorkspaces) + const nestedRepoNames = new Set( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + let rootEntries + try { + rootEntries = await readdir(rootPath, { withFileTypes: true }) + } catch { + // Root vanished: an empty snapshot diffs into delete events for every + // previously-known worktree dir, matching the old watcher's error path. + return { markers, gateDirs } + } + + const candidates: string[] = [] + for (const entry of rootEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + let subEntries + try { + subEntries = await readdir(entryPath, { withFileTypes: true }) + } catch { + subEntries = [] + } + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + for (const dir of candidates) { + markers.set(dir, await hasGitMarker(dir)) + } + return { markers, gateDirs } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, marker] of next.markers) { + if (marker && prev.markers.get(dir) !== true) { + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + for (const dir of prev.markers.keys()) { + if (!next.markers.has(dir)) { + events.push({ type: 'delete', path: dir }) + } + } + return events +} + +async function startBasePoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + onFullScan?: () => void +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotBase(target.path, getRepos()) + let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + // dir → tick when first seen without a `.git` marker + const pendingMarkers = new Map() + for (const [dir, marker] of snapshot.markers) { + if (!marker) { + pendingMarkers.set(dir, 0) + } + } + + const fullScan = async (): Promise => { + onFullScan?.() + const next = await snapshotBase(target.path, getRepos()) + const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) + if (disposed) { + return + } + const events = diffBase(snapshot, next) + for (const [dir, marker] of next.markers) { + if (marker) { + pendingMarkers.delete(dir) + } else if (!pendingMarkers.has(dir)) { + pendingMarkers.set(dir, tickCount) + } + } + for (const [dir, firstSeenTick] of pendingMarkers) { + if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) { + pendingMarkers.delete(dir) + } + } + snapshot = next + gateSignatures = nextSignatures + if (events.length > 0) { + onEvents(events) + } + } + + const checkPendingMarkers = async (): Promise => { + const events: WorktreeBasePollEvent[] = [] + for (const dir of pendingMarkers.keys()) { + if (await hasGitMarker(dir)) { + pendingMarkers.delete(dir) + snapshot.markers.set(dir, true) + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + if (!disposed && events.length > 0) { + onEvents(events) + } + } + + const tick = async (): Promise => { + tickCount++ + if (tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + await fullScan() + return + } + // Idle fast path: when the dirs whose listings define the candidate set + // are untouched, skip the readdir + per-candidate stat fan-out entirely. + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const gateChanged = + signatures.length !== gateSignatures.length || + signatures.some((sig, index) => sig !== gateSignatures[index]) + if (gateChanged) { + await fullScan() + return + } + if (pendingMarkers.size > 0) { + await checkPendingMarkers() + } + } + + const timer = setInterval(() => { + if (disposed || ticking) { + return + } + ticking = true + void tick() + .catch(() => { + // Transient fs error: keep the previous snapshot and retry next tick. + }) + .finally(() => { + ticking = false + }) + }, pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + clearInterval(timer) + } + } +} + +/** Watches the shallow paths a worktree base target cares about and emits + * watcher-shaped events. Resolves once the baseline (snapshot or narrow + * native subscription) is established. */ +export async function startWorktreeBaseDirectoryPoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + options: WorktreeBasePollerOptions = {} +): Promise { + const pollIntervalMs = options.pollIntervalMs ?? WORKTREE_BASE_POLL_INTERVAL_MS + const platform = options.platform ?? process.platform + if (target.kind === 'git-common') { + return startGitCommonWatch(target, onEvents, pollIntervalMs, platform, options.onFullScan) + } + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, options.onFullScan) +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 2694b723c87..d312c8ab431 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join, sep } from 'node:path' -import type { Event as WatcherEvent, SubscribeCallback } from '@parcel/watcher' import type { GlobalSettings, Repo } from '../../shared/types' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' vi.mock('fs/promises', () => ({ readFile: vi.fn(async () => ''), @@ -9,8 +9,8 @@ vi.mock('fs/promises', () => ({ stat: vi.fn(async () => ({ isDirectory: () => true })) })) -vi.mock('@parcel/watcher', () => ({ - subscribe: vi.fn() +vi.mock('./worktree-base-directory-poller', () => ({ + startWorktreeBaseDirectoryPoller: vi.fn() })) vi.mock('./worktree-remote', () => ({ @@ -21,18 +21,18 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn() })) -import { subscribe } from '@parcel/watcher' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { notifyWorktreesChanged } from './worktree-remote' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' import { disposeWorktreeBaseDirectoryWatchers, syncWorktreeBaseDirectoryWatchers } from './worktree-base-directory-watcher' import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter' -type WatcherCallback = SubscribeCallback +type PollerCallback = (events: WorktreeBasePollEvent[]) => void -const watcherCallbacks = new Map() +const watcherCallbacks = new Map() const unsubscribeMocks = new Map>() const absolutePath = (...parts: string[]): string => join(sep, ...parts) const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') @@ -69,12 +69,12 @@ function makeWindow(options: { destroyed?: () => boolean } = {}) { } } -function emit(root: string, events: WatcherEvent[]): void { +function emit(root: string, events: WorktreeBasePollEvent[]): void { const callback = watcherCallbacks.get(root) if (!callback) { - throw new Error(`No watcher callback for ${root}`) + throw new Error(`No poller callback for ${root}`) } - callback(null, events) + callback(events) } describe('worktree base directory watcher', () => { @@ -83,12 +83,14 @@ describe('worktree base directory watcher', () => { watcherCallbacks.clear() unsubscribeMocks.clear() vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) - vi.mocked(subscribe).mockImplementation(async (root, callback) => { - const unsubscribe = vi.fn(async () => {}) - watcherCallbacks.set(root, callback) - unsubscribeMocks.set(root, unsubscribe) - return { unsubscribe } - }) + vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation( + async (target, _getRepos, onEvents) => { + const unsubscribe = vi.fn(async () => {}) + watcherCallbacks.set(target.path, onEvents) + unsubscribeMocks.set(target.path, unsubscribe) + return { unsubscribe } + } + ) }) afterEach(async () => { @@ -103,7 +105,7 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } - ] as WatcherEvent[]) + ]) await vi.advanceTimersByTimeAsync(300) @@ -120,7 +122,7 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } - ] as WatcherEvent[]) + ]) destroyed = true await vi.advanceTimersByTimeAsync(300) @@ -132,7 +134,7 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } - ] as WatcherEvent[]) + ]) await vi.advanceTimersByTimeAsync(300) expect(notifyWorktreesChanged).not.toHaveBeenCalled() @@ -143,7 +145,7 @@ describe('worktree base directory watcher', () => { emit(PROJECT_GIT_COMMON_DIR, [ { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } - ] as WatcherEvent[]) + ]) await vi.advanceTimersByTimeAsync(300) expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') @@ -158,7 +160,7 @@ describe('worktree base directory watcher', () => { makeWindow() as never ) - expect(subscribe).not.toHaveBeenCalled() + expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled() }) it('uses the remote sibling root for default SSH worktree roots', async () => { @@ -180,7 +182,7 @@ describe('worktree base directory watcher', () => { makeWindow() as never ) - expect(subscribe).not.toHaveBeenCalled() + expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled() expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function)) remoteCallbacks.get('/home/alice')?.([ { @@ -209,12 +211,12 @@ describe('worktree base directory watcher', () => { }) it('unsubscribes a watcher that finishes installing after disposal starts', async () => { - let resolveSubscribe: (subscription: { unsubscribe: () => Promise }) => void = () => {} + let resolveInstall: (subscription: { unsubscribe: () => Promise }) => void = () => {} const unsubscribe = vi.fn(async () => {}) - vi.mocked(subscribe).mockImplementationOnce( + vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementationOnce( async () => new Promise((resolve) => { - resolveSubscribe = resolve + resolveInstall = resolve }) ) @@ -222,9 +224,9 @@ describe('worktree base directory watcher', () => { makeStore([makeRepo()]) as never, makeWindow() as never ) - await vi.waitFor(() => expect(subscribe).toHaveBeenCalled()) + await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalled()) const disposePromise = disposeWorktreeBaseDirectoryWatchers() - resolveSubscribe({ unsubscribe }) + resolveInstall({ unsubscribe }) await syncPromise await disposePromise @@ -243,13 +245,13 @@ describe('worktree base directory watcher', () => { matchingWorktreeBaseRepoIds(target, { type: 'create', path: join(WORKTREE_ROOT, 'external-5104', '.git') - } as WatcherEvent) + }) ).toEqual(['repo-1']) expect( matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(WORKTREE_ROOT, 'external-5104', 'src', 'file.ts') - } as WatcherEvent) + }) ).toEqual([]) }) }) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index f7b23103d2b..db71349a7dd 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,5 +1,4 @@ import type { BrowserWindow } from 'electron' -import type { AsyncSubscription } from '@parcel/watcher' import type { FsChangeEvent } from '../../shared/types' import type { Store } from '../persistence' import { notifyWorktreesChanged } from './worktree-remote' @@ -12,10 +11,11 @@ import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow - subscription: Pick + subscription: { unsubscribe: () => Promise } notifyTimer: ReturnType | null pendingRepoIds: Set disposed: boolean @@ -137,19 +137,19 @@ async function subscribeTarget( return activeWatch } - const watcher = await import('@parcel/watcher') - const subscription = await watcher.subscribe( - target.path, - (error, events) => { + // Why: a recursive native watcher here forced fseventsd to deliver every + // event under the whole workspace root (all worktrees) / whole common .git + // (objects included) just to observe a few shallow paths. The poller reads + // exactly those paths and registers zero fseventsd clients. + const subscription = await startWorktreeBaseDirectoryPoller( + target, + () => (activeWatches.get(target.key) ?? activeWatch)?.repos ?? target.repos, + (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (!currentWatch || currentWatch.disposed) { return } - handleLocalWatchEvents(currentWatch, error, events) - }, - { - ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.cache/**'], - ...(process.platform === 'win32' ? { backend: 'windows' as const } : {}) + handleLocalWatchEvents(currentWatch, null, events) } ) activeWatch = { diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts new file mode 100644 index 00000000000..d328220aa00 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -0,0 +1,233 @@ +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import type { + WorktreeBasePollEvent, + WorktreeBaseSubscription +} from './worktree-base-directory-poller' + +// Watches a repo's `/.git/worktrees` metadata — the only subtree the +// git-common event filter consumes. +// macOS: a narrow native stream rooted there — a tiny, rare-churn tree — +// gives instant detection with zero idle cost and zero wide-scope fseventsd +// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and +// on Windows an open directory handle on `worktrees/` could interfere with +// `git worktree prune` removing it). + +async function snapshotGitCommon(commonDirPath: string): Promise> { + const mtimes = new Map() + const worktreesDir = join(commonDirPath, 'worktrees') + let entries + try { + entries = await readdir(worktreesDir, { withFileTypes: true }) + } catch { + // Missing worktrees dir is normal for repos without linked worktrees. + return mtimes + } + for (const entry of entries) { + const entryPath = join(worktreesDir, entry.name) + try { + // Entry-dir mtime covers the metadata writes the old recursive watcher + // reacted to (HEAD/gitdir/locked are written via rename into the entry + // dir, which bumps its mtime). + mtimes.set(entryPath, (await stat(entryPath)).mtimeMs) + } catch { + // Entry removed between readdir and stat. + } + } + return mtimes +} + +function diffGitCommon( + prev: Map, + next: Map +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [entryPath, mtime] of next) { + const prevMtime = prev.get(entryPath) + if (prevMtime === undefined) { + events.push({ type: 'create', path: entryPath }) + } else if (prevMtime !== mtime) { + events.push({ type: 'update', path: entryPath }) + } + } + for (const entryPath of prev.keys()) { + if (!next.has(entryPath)) { + events.push({ type: 'delete', path: entryPath }) + } + } + return events +} + +async function startGitCommonPoller( + target: WorktreeBaseWatchTarget, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + onFullScan?: () => void +): Promise { + let disposed = false + let ticking = false + let snapshot = await snapshotGitCommon(target.path) + + const timer = setInterval(() => { + if (disposed || ticking) { + return + } + ticking = true + onFullScan?.() + void snapshotGitCommon(target.path) + .then((next) => { + if (disposed) { + return + } + const events = diffGitCommon(snapshot, next) + snapshot = next + if (events.length > 0) { + onEvents(events) + } + }) + .catch(() => { + // Transient fs error: keep the previous snapshot and retry next tick. + }) + .finally(() => { + ticking = false + }) + }, pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + clearInterval(timer) + } + } +} + +async function startGitCommonNarrowWatch( + target: WorktreeBaseWatchTarget, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number +): Promise { + const worktreesDir = join(target.path, 'worktrees') + let disposed = false + let subscription: WorktreeBaseSubscription | null = null + let existenceTimer: ReturnType | null = null + let subscribing = false + + const stopExistencePoll = (): void => { + if (existenceTimer) { + clearInterval(existenceTimer) + existenceTimer = null + } + } + + const armExistencePoll = (): void => { + if (disposed || existenceTimer) { + return + } + existenceTimer = setInterval(() => { + if (disposed || subscribing || subscription) { + return + } + subscribing = true + void trySubscribe() + .then((installed) => { + if (installed && !disposed) { + stopExistencePoll() + // The dir appearing means a first linked worktree was just + // registered; surface it so the repo's worktree list refreshes. + onEvents([{ type: 'create', path: worktreesDir }]) + } + }) + .finally(() => { + subscribing = false + }) + }, pollIntervalMs) + existenceTimer.unref?.() + } + + const trySubscribe = async (): Promise => { + try { + const s = await stat(worktreesDir) + if (!s.isDirectory()) { + return false + } + } catch { + return false + } + let errored = false + // Why: parcel tears its native stream down when the watched root is + // deleted (e.g. `git worktree prune` removing an empty worktrees dir) — + // sometimes surfaced as an error, sometimes as a delete event for the + // root. Either way: notify, drop the dead stream, and let the existence + // poll re-arm when a future worktree add recreates the dir. + const teardownAndRearm = (): void => { + errored = true + const current = subscription + subscription = null + if (current) { + void current.unsubscribe().catch(() => {}) + } + armExistencePoll() + } + try { + const watcher = await import('@parcel/watcher') + const sub = await watcher.subscribe(worktreesDir, (error, events) => { + if (disposed) { + return + } + if (error) { + onEvents([{ type: 'update', path: worktreesDir }]) + teardownAndRearm() + return + } + if (events.length > 0) { + const rootGone = events.some( + (event) => event.type === 'delete' && event.path === worktreesDir + ) + onEvents(events.map((event) => ({ type: event.type, path: event.path }))) + if (rootGone) { + teardownAndRearm() + } + } + }) + if (disposed || errored) { + void sub.unsubscribe().catch(() => {}) + return !errored + } + subscription = { unsubscribe: () => sub.unsubscribe() } + return true + } catch { + return false + } + } + + if (!(await trySubscribe())) { + armExistencePoll() + } + + return { + unsubscribe: async () => { + disposed = true + stopExistencePoll() + const current = subscription + subscription = null + if (current) { + await current.unsubscribe().catch(() => {}) + } + } + } +} + +export async function startGitCommonWatch( + target: WorktreeBaseWatchTarget, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + platform: NodeJS.Platform, + onFullScan?: () => void +): Promise { + if (platform === 'darwin') { + return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs) + } + return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan) +} diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 4aa4d7d3ba7..3bbfc0ab1e0 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -8,20 +8,15 @@ import { join } from 'node:path' import { app } from 'electron' import type { FsChangeEvent } from '../../shared/types' import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-watcher-worker' +// Why: shares the explorer watcher's canonical ignore list, pre-shaped so the +// macOS daemon-side exclusion-path cap (8) is respected — over the cap ALL +// exclusions silently fail and fseventsd delivers full node_modules churn. +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOption +} from '../ipc/filesystem-watcher-ignore' -// Mirrors VS Code's predefined recursive-watch excludes: skip churny generated -// trees at crawl time so the watcher never traverses them. -const RUNTIME_FILE_WATCH_IGNORE = [ - '.git', - 'node_modules', - 'dist', - 'build', - '.next', - '.cache', - '__pycache__', - 'target', - '.venv' -] +const RUNTIME_FILE_WATCH_IGNORE = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) // Why: clean teardown is async (the worker awaits subscription.unsubscribe() // before closing its port and exiting). Wait this long for the worker to exit on From 7c66f37632443f9bfd031790a51f6fca6ad94042 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:09:20 -0700 Subject: [PATCH 017/233] Fix AppImage env leakage into Linux terminal shells (#7076) --- src/main/daemon/pty-subprocess.test.ts | 51 ++++++++++- src/main/daemon/pty-subprocess.ts | 2 + src/main/providers/local-pty-provider.test.ts | 45 ++++++++++ src/main/providers/local-pty-provider.ts | 2 + src/main/pty/appimage-terminal-env.test.ts | 84 +++++++++++++++++++ src/main/pty/appimage-terminal-env.ts | 56 +++++++++++++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/main/pty/appimage-terminal-env.test.ts create mode 100644 src/main/pty/appimage-terminal-env.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 3d50ce30ff8..5d34f57c959 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import type * as LocalPtyUtils from '../providers/local-pty-utils' const { @@ -758,6 +758,55 @@ describe('createPtySubprocess', () => { expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined() }) + it('does not inherit AppImage runtime env into daemon PTY shells', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const saved = { + APPIMAGE: process.env.APPIMAGE, + APPDIR: process.env.APPDIR, + ARGV0: process.env.ARGV0, + OWD: process.env.OWD, + APPIMAGE_LIBRARY_PATH: process.env.APPIMAGE_LIBRARY_PATH, + PATH: process.env.PATH, + LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH + } + Object.defineProperty(process, 'platform', { value: 'linux' }) + process.env.APPIMAGE = '/data/apps/orca.appimage' + process.env.APPDIR = '/tmp/.mount_orca123' + process.env.ARGV0 = '/data/apps/orca.appimage' + process.env.OWD = '/home/user/project' + process.env.APPIMAGE_LIBRARY_PATH = '/tmp/.mount_orca123/usr/lib' + process.env.PATH = ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join( + delimiter + ) + process.env.LD_LIBRARY_PATH = ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join(delimiter) + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.APPIMAGE).toBeUndefined() + expect(env.APPDIR).toBeUndefined() + expect(env.ARGV0).toBeUndefined() + expect(env.OWD).toBeUndefined() + expect(env.APPIMAGE_LIBRARY_PATH).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib') + }) + it('does not inherit parent agent hook endpoint for development hook env', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 1b8e72681a5..a4bd1fca7e4 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -29,6 +29,7 @@ import { import { isPwshAvailable } from '../pwsh' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' +import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { parseWslPath } from '../wsl' import { addWslEnvKeys } from '../wsl-env' import { getWslContextFromSessionId } from './wsl-session-context' @@ -559,6 +560,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl removeUnspecifiedPaneIdentityEnv(env, opts.env) removeInheritedDevAgentHookEndpoint(env, opts.env) removeInheritedElectronRunAsNode(env) + removeAppImageRuntimeEnv(env) removeInheritedNoColor(env) env.LANG ??= 'en_US.UTF-8' diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index b4744545467..bc236e9f9bd 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { delimiter } from 'node:path' const { existsSyncMock, @@ -365,6 +366,50 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() }) + it('does not inherit AppImage runtime env into Linux PTY shells', async () => { + const saved = { + APPIMAGE: process.env.APPIMAGE, + APPDIR: process.env.APPDIR, + ARGV0: process.env.ARGV0, + OWD: process.env.OWD, + APPIMAGE_LIBRARY_PATH: process.env.APPIMAGE_LIBRARY_PATH, + PATH: process.env.PATH, + LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH + } + process.env.APPIMAGE = '/data/apps/orca.appimage' + process.env.APPDIR = '/tmp/.mount_orca123' + process.env.ARGV0 = '/data/apps/orca.appimage' + process.env.OWD = '/home/user/project' + process.env.APPIMAGE_LIBRARY_PATH = '/tmp/.mount_orca123/usr/lib' + process.env.PATH = ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join( + delimiter + ) + process.env.LD_LIBRARY_PATH = ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join( + delimiter + ) + + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const env = spawnMock.mock.calls.at(-1)?.[2].env + expect(env.APPIMAGE).toBeUndefined() + expect(env.APPDIR).toBeUndefined() + expect(env.ARGV0).toBeUndefined() + expect(env.OWD).toBeUndefined() + expect(env.APPIMAGE_LIBRARY_PATH).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib') + }) + it('uses shell wrapper when MiMo home must survive shell startup', async () => { provider.configure({ buildSpawnEnv: (_id, env) => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 96670ca38f5..01ea3aeea57 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -38,6 +38,7 @@ import { } from './local-pty-shell-ready' import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' +import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' import { @@ -484,6 +485,7 @@ export class LocalPtyProvider implements IPtyProvider { // Why: Orca can be launched from an Orca terminal while developing. Pane // identity belongs to the child PTY, not the parent shell that spawned app. removeUnspecifiedPaneIdentityEnv(spawnEnv, args.env) + removeAppImageRuntimeEnv(spawnEnv) removeInheritedNoColor(spawnEnv) for (const key of args.envToDelete ?? []) { delete spawnEnv[key] diff --git a/src/main/pty/appimage-terminal-env.test.ts b/src/main/pty/appimage-terminal-env.test.ts new file mode 100644 index 00000000000..3b690f99872 --- /dev/null +++ b/src/main/pty/appimage-terminal-env.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { delimiter } from 'node:path' +import { removeAppImageRuntimeEnv } from './appimage-terminal-env' + +describe('removeAppImageRuntimeEnv', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('removes AppImage runtime identity and mount paths from Linux terminal env', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + const env = { + APPIMAGE: '/data/apps/orca.appimage', + APPDIR: '/tmp/.mount_orca123', + ARGV0: '/data/apps/orca.appimage', + OWD: '/home/user', + APPIMAGE_LIBRARY_PATH: '/tmp/.mount_orca123/usr/lib', + PATH: ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join(delimiter), + LD_LIBRARY_PATH: ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join(delimiter), + HOME: '/home/user' + } + + removeAppImageRuntimeEnv(env) + + expect(env).toEqual({ + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/opt/audio/lib', + HOME: '/home/user' + }) + }) + + it('leaves non-AppImage environments untouched', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const env = { + PATH: ['/opt/tools', '/usr/bin'].join(delimiter), + LD_LIBRARY_PATH: '/opt/audio/lib' + } + + removeAppImageRuntimeEnv(env) + + expect(env).toEqual({ + PATH: ['/opt/tools', '/usr/bin'].join(delimiter), + LD_LIBRARY_PATH: '/opt/audio/lib' + }) + }) + + it('removes AppImage ARGV0 even without a mounted APPDIR', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const env = { + ARGV0: '/data/apps/orca.appimage', + PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) + } + + removeAppImageRuntimeEnv(env) + + expect(env).toEqual({ + PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) + }) + }) + + it('leaves AppImage-looking env untouched outside Linux', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const env = { + APPIMAGE: '/data/apps/orca.appimage', + APPDIR: '/tmp/.mount_orca123', + ARGV0: '/data/apps/orca.appimage', + PATH: ['/tmp/.mount_orca123/usr/bin', '/usr/bin'].join(delimiter) + } + + removeAppImageRuntimeEnv(env) + + expect(env).toEqual({ + APPIMAGE: '/data/apps/orca.appimage', + APPDIR: '/tmp/.mount_orca123', + ARGV0: '/data/apps/orca.appimage', + PATH: ['/tmp/.mount_orca123/usr/bin', '/usr/bin'].join(delimiter) + }) + }) +}) diff --git a/src/main/pty/appimage-terminal-env.ts b/src/main/pty/appimage-terminal-env.ts new file mode 100644 index 00000000000..ae96e4b4d01 --- /dev/null +++ b/src/main/pty/appimage-terminal-env.ts @@ -0,0 +1,56 @@ +import { delimiter } from 'node:path' + +const APPIMAGE_RUNTIME_ENV_KEYS = [ + 'APPIMAGE', + 'APPDIR', + 'ARGV0', + 'OWD', + 'APPIMAGE_LIBRARY_PATH' +] as const + +function normalizeAppDir(value: string | undefined): string | null { + const trimmed = value?.trim().replace(/\/+$/, '') ?? '' + return trimmed.startsWith('/') ? trimmed : null +} + +function isInsideAppDir(entry: string, appDir: string): boolean { + const normalized = entry.replace(/\/+$/, '') + return normalized === appDir || normalized.startsWith(`${appDir}/`) +} + +function removeAppDirPathEntries(value: string | undefined, appDir: string): string | undefined { + if (value === undefined) { + return undefined + } + const cleaned = value + .split(delimiter) + .filter((entry) => !isInsideAppDir(entry, appDir)) + .join(delimiter) + return cleaned.length > 0 ? cleaned : undefined +} + +export function removeAppImageRuntimeEnv(env: Record): void { + if (process.platform !== 'linux' || (!env.APPIMAGE && !env.APPDIR && !env.ARGV0)) { + return + } + + // Why: zsh treats exported ARGV0 as argv[0] for external commands, so an + // AppImage launch can make host commands think they were invoked as Orca. + const appDir = normalizeAppDir(env.APPDIR) + if (appDir) { + // Why: AppImage mount paths are for Orca's own loader; user shells should + // resolve commands and shared libraries from the host environment. + for (const key of ['PATH', 'LD_LIBRARY_PATH'] as const) { + const cleaned = removeAppDirPathEntries(env[key], appDir) + if (cleaned === undefined) { + delete env[key] + } else { + env[key] = cleaned + } + } + } + + for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { + delete env[key] + } +} From 171d32d8f7639acf104e322aa25ca156b423d265 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:09:23 -0700 Subject: [PATCH 018/233] Reduce idle git status polling (#7069) --- .../right-sidebar/useGitStatusPolling.test.ts | 23 +++++ .../right-sidebar/useGitStatusPolling.ts | 88 +++++++++---------- .../src/lib/passive-macos-app-data-access.ts | 16 +++- 3 files changed, 80 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index cfd3aa05aea..83ad40e3aef 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -46,6 +46,7 @@ async function usePollingOnce( sshStatus?: string enabled?: boolean expectStatusCall?: boolean + stateOverrides?: Partial } = {} ): Promise<{ state: PollState; gitStatus: ReturnType }> { vi.resetModules() @@ -67,6 +68,7 @@ async function usePollingOnce( rightSidebarTab: 'source-control', openFiles: [] } + Object.assign(state, options.stateOverrides) const mockedRepo = { ...repo, connectionId: options.connectionId ?? null } const gitStatus = vi.fn().mockResolvedValue(status) @@ -226,6 +228,27 @@ describe('useGitStatusPolling', () => { expect(gitStatus).not.toHaveBeenCalled() expect(state.setGitStatus).not.toHaveBeenCalled() + expect(globalThis.setInterval).not.toHaveBeenCalled() + }) + + it('uses a slower git status cadence when only terminal branch detection needs polling', async () => { + const { gitStatus } = await usePollingOnce( + { + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'refs/heads/main' + }, + { + stateOverrides: { + rightSidebarOpen: false, + openFiles: [] + } + } + ) + + expect(gitStatus).toHaveBeenCalledTimes(1) + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 30_000) }) it('does not install the visible git status poll while disabled', async () => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 98e74d9f45c..0fc1bc8b4a1 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -8,11 +8,18 @@ import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' +import { + hasInteractiveActiveGitStatusConsumer, + shouldPollActiveGitStatus +} from '@/lib/passive-macos-app-data-access' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' -const POLL_INTERVAL_MS = 3000 +const MIN_STATUS_REFRESH_INTERVAL_MS = 3000 +const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS +// Why: file-watch refreshes cover content changes; terminal-only polling is +// just a fallback for branch switches made inside shells. +const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true @@ -44,6 +51,29 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { !connectionId || sshConnectionStates.get(connectionId)?.status === 'connected', [sshConnectionStates] ) + const activeGitStatusPollingArgs = { + activeWorktreeId, + worktreePath, + rightSidebarOpen, + rightSidebarTab, + rightSidebarExplorerView, + openFiles + } + const isActiveConnectionReady = isConnectionReady(activeConnectionId) + const shouldPollActiveWorktreeGitStatus = + enabled && + !!activeWorktreeId && + !!worktreePath && + activeRepoSupportsGit && + shouldPollActiveGitStatus(activeGitStatusPollingArgs) && + isActiveConnectionReady && + !gitStatusHugeByWorktree?.[activeWorktreeId] + const activeStatusPollIntervalMs = hasInteractiveActiveGitStatusConsumer( + activeGitStatusPollingArgs + ) + ? INTERACTIVE_STATUS_POLL_INTERVAL_MS + : TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS + const activeStatusPollScope = shouldPollActiveWorktreeGitStatus ? activeWorktreeId : null // Why: build a list of non-active worktrees that still have a known conflict // operation (merge/rebase/cherry-pick). These need lightweight polling so @@ -68,34 +98,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) const runFetchStatus = useCallback(async () => { - if (!enabled) { - return - } - if (!activeWorktreeId || !worktreePath) { - return - } - if ( - !shouldPollActiveGitStatus({ - activeWorktreeId, - worktreePath, - rightSidebarOpen, - rightSidebarTab, - rightSidebarExplorerView, - openFiles - }) || - !activeRepoSupportsGit - ) { - return - } - if (!isConnectionReady(activeConnectionId)) { - return - } - // Why: once a repo's status was truncated at the entry limit, re-running git - // status every 3s just re-does expensive work and re-truncates. Pause the - // automatic poll while huge (a manual refresh still goes through its own - // path); resolving the changes (e.g. .gitignoring the huge folder) clears - // the flag and polling resumes. Mirrors a "huge repo" disabling auto status. - if (gitStatusHugeByWorktree?.[activeWorktreeId]) { + if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { return } try { @@ -117,18 +120,10 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { // ignore } }, [ - activeRepoSupportsGit, - activeConnectionId, activePushTarget, activeWorktreeId, - enabled, fetchUpstreamStatus, - gitStatusHugeByWorktree, - isConnectionReady, - openFiles, - rightSidebarExplorerView, - rightSidebarOpen, - rightSidebarTab, + shouldPollActiveWorktreeGitStatus, worktreePath, setGitStatus, setUpstreamStatus, @@ -147,7 +142,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const statusPollRunnerRef = useRef(null) useEffect(() => { const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: POLL_INTERVAL_MS + minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) statusPollRunnerRef.current = runner return () => { @@ -158,16 +153,19 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const fetchStatus = useCallback(() => { statusPollRunnerRef.current?.run() - }, [activeWorktreeId]) + }, []) useEffect(() => { - if (!enabled) { + if (!activeStatusPollScope) { return } // Why: this root-level poll should pause while hidden, but visible // unfocused windows still need fresh status for second-display workflows. - return installWindowVisibilityInterval({ run: fetchStatus, intervalMs: POLL_INTERVAL_MS }) - }, [enabled, fetchStatus]) + return installWindowVisibilityInterval({ + run: fetchStatus, + intervalMs: activeStatusPollIntervalMs + }) + }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus]) useGitStatusFileWatchRefresh({ activeConnectionId, @@ -225,7 +223,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { // visible unfocused windows, but do not poll disconnected hidden windows. const stopVisiblePoll = installWindowVisibilityInterval({ run: () => pollRunner.run(), - intervalMs: POLL_INTERVAL_MS + intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) return () => { pollRunner.dispose() diff --git a/src/renderer/src/lib/passive-macos-app-data-access.ts b/src/renderer/src/lib/passive-macos-app-data-access.ts index 5475adbc1c9..5be004d612e 100644 --- a/src/renderer/src/lib/passive-macos-app-data-access.ts +++ b/src/renderer/src/lib/passive-macos-app-data-access.ts @@ -20,7 +20,7 @@ export function isMacAppDataPath(path: string | null | undefined, userAgent?: st return MAC_APP_DATA_SEGMENT_RE.test(path.replace(/\\/g, '/')) } -export function shouldPollActiveGitStatus(args: { +export type ActiveGitStatusPollingArgs = { activeWorktreeId: string | null worktreePath: string | null rightSidebarOpen: boolean @@ -28,7 +28,9 @@ export function shouldPollActiveGitStatus(args: { rightSidebarExplorerView?: RightSidebarExplorerView openFiles?: OpenFile[] userAgent?: string -}): boolean { +} + +export function hasInteractiveActiveGitStatusConsumer(args: ActiveGitStatusPollingArgs): boolean { if (!args.activeWorktreeId || !args.worktreePath) { return false } @@ -43,6 +45,16 @@ export function shouldPollActiveGitStatus(args: { if ((args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId)) { return true } + return false +} + +export function shouldPollActiveGitStatus(args: ActiveGitStatusPollingArgs): boolean { + if (!args.activeWorktreeId || !args.worktreePath) { + return false + } + if (hasInteractiveActiveGitStatusConsumer(args)) { + return true + } // Why: macOS app-container paths can trigger the "data from other apps" // prompt. Keep terminal-only workspace switching from passively probing them. return !isMacAppDataPath(args.worktreePath, args.userAgent) From b157c0d5b3ecd423afa5232cd3b54e10fa2b7a9b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:36:13 +0000 Subject: [PATCH 019/233] release: v1.4.117-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef098f102a5..d3f62f5fe9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.117-rc.1", + "version": "1.4.117-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From c29bf6f3dae4562c34868f8871857da1303f76c6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:43:32 -0700 Subject: [PATCH 020/233] Raise WebGL terminal context budget and recover from context loss (#7064) Co-authored-by: Orca --- src/main/startup/configure-process.test.ts | 15 +++ src/main/startup/configure-process.ts | 6 + .../pane-manager/pane-rendering-control.ts | 6 + .../pane-terminal-gpu-acceleration.test.ts | 24 ++++ .../pane-terminal-gpu-acceleration.ts | 8 +- .../pane-webgl-context-recovery.test.ts | 126 ++++++++++++++++++ .../lib/pane-manager/pane-webgl-reattach.ts | 5 +- .../lib/pane-manager/pane-webgl-renderer.ts | 18 ++- 8 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7f0e5f49528..72a545a3988 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -297,6 +297,17 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) + it('raises the WebGL context budget above the 16-context Blink default', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.commandLine.appendSwitch).mockClear() + enableMainProcessGpuFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('max-active-webgl-contexts', '128') + }) + it('disables the GPU sandbox on Linux Wayland without disabling acceleration', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') @@ -447,6 +458,10 @@ describe('enableMainProcessGpuFeatures', () => { 'enable-features', expect.any(String) ) + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith( + 'max-active-webgl-contexts', + expect.any(String) + ) }) it('preserves existing enable-features switches', async () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index f4fa359b8c1..e92c53e4b0d 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -307,6 +307,12 @@ export function enableMainProcessGpuFeatures(): void { return } + // Why: Blink force-loses the oldest WebGL context past 16 per renderer, and + // each attached terminal pane holds one — a busy worktree (tabs × splits) + // can exceed that, silently downgrading evicted panes to the DOM renderer. + // 128 covers real layouts while keeping a bound so context leaks surface. + app.commandLine.appendSwitch('max-active-webgl-contexts', '128') + const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase() const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase() const isLinuxX11Override = diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 8f0da555597..8ad22f07eca 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -2,6 +2,7 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { safeFit } from './pane-tree-ops' import { attachWebgl, + clearTerminalWebglAttachBackoff, disposeWebgl, markComplexScriptOutput, resetWebglTextureAtlas @@ -49,8 +50,13 @@ export function suspendPaneRendering(panes: Iterable): void } export function resumePaneRendering(panes: Iterable): void { + // Why: resume (worktree foreground, window wake) is the WebGL retry + // boundary — Chromium may have restored the GPU process since a context + // loss, and bounding retries to resume events cannot loop on live loss. + clearTerminalWebglAttachBackoff() for (const pane of panes) { pane.webglAttachmentDeferred = false + pane.webglDisabledAfterContextLoss = false reattachWebglIfNeeded(pane) } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts index 7f2c4145efe..1ac49093354 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts @@ -77,6 +77,30 @@ describe('applyTerminalGpuAcceleration', () => { expect(pane.fitAddon.fit).not.toHaveBeenCalled() }) + it('clears context-loss latches when the acceleration mode changes', () => { + vi.stubGlobal('navigator', { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' + }) + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + const options: PaneManagerOptions = { terminalGpuAcceleration: 'auto' } + + applyTerminalGpuAcceleration([pane], options, 'on') + + expect(pane.webglDisabledAfterContextLoss).toBe(false) + }) + + it('keeps context-loss latches when the acceleration mode is unchanged', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + const options: PaneManagerOptions = { terminalGpuAcceleration: 'on' } + + applyTerminalGpuAcceleration([pane], options, 'on') + + expect(pane.webglDisabledAfterContextLoss).toBe(true) + }) + it('returns Linux panes to DOM when switching from forced WebGL back to auto without a safe renderer', () => { vi.stubGlobal('navigator', { platform: 'Linux x86_64', diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index be53b77e1f6..655f9447ebc 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -14,12 +14,18 @@ export function applyTerminalGpuAcceleration( ): void { const nextMode = mode ?? 'auto' const previousMode = options.terminalGpuAcceleration ?? 'auto' + const modeChanged = previousMode !== nextMode options.terminalGpuAcceleration = nextMode - if (previousMode !== nextMode) { + if (modeChanged) { resetTerminalWebglSuggestion() } for (const pane of panes) { pane.terminalGpuAcceleration = nextMode + if (modeChanged) { + // Why: an explicit setting change is user intent to re-evaluate the + // renderer; context-loss latches from the old mode should not pin DOM. + pane.webglDisabledAfterContextLoss = false + } if (!shouldUseTerminalWebgl(pane)) { disposeWebgl(pane, { refreshDimensions: true }) continue diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts new file mode 100644 index 00000000000..3f16c655f1a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { resumePaneRendering } from './pane-rendering-control' +import { attachWebgl, resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function throwWebglUnavailable(): never { + // Mirrors the addon's activate() throw when getContext('webgl2') returns null. + throw new Error('WebGL2 not supported null') +} + +function fireContextLoss(pane: ManagedPaneInternal): void { + // Fire the addon's real context-loss emitter so the loss -> latch -> resume + // cycle runs through the production onContextLoss handler. + const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } } + addon._onContextLoss.fire() +} + +describe('terminal WebGL context recovery', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('backs off after a failed attach instead of retrying on every call', () => { + const pane = createPane({ loadAddon: throwWebglUnavailable }) + + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + + // Title changes re-enter attach via setPaneGpuRendering; while WebGL is + // blocked these must not construct new addons or log again. + attachWebgl(pane) + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(console.warn).toHaveBeenCalledTimes(1) + }) + + it('retries a backed-off attach on the next rendering resume', () => { + const pane = createPane({ loadAddon: throwWebglUnavailable }) + + attachWebgl(pane) + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + + resumePaneRendering([pane]) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2) + }) + + it('recovers a context-lost pane on the next rendering resume', () => { + const pane = createPane() + + attachWebgl(pane) + expect(pane.webglAddon).not.toBeNull() + + fireContextLoss(pane) + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + + resumePaneRendering([pane]) + expect(pane.webglDisabledAfterContextLoss).toBe(false) + expect(pane.webglAddon).not.toBeNull() + }) + + it('re-latches when the retried context is lost again', () => { + const pane = createPane() + + attachWebgl(pane) + fireContextLoss(pane) + resumePaneRendering([pane]) + expect(pane.webglAddon).not.toBeNull() + + fireContextLoss(pane) + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index a975a66f588..82f8c5d2224 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -1,5 +1,5 @@ import type { ManagedPaneInternal } from './pane-manager-types' -import { attachWebgl, disposeWebgl } from './pane-webgl-renderer' +import { attachWebgl, clearTerminalWebglAttachBackoff, disposeWebgl } from './pane-webgl-renderer' export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void { if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) { @@ -12,5 +12,8 @@ export function rebuildAttachedWebgl(pane: ManagedPaneInternal): void { return } disposeWebgl(pane) + // Why: the live addon just proved context creation works, so a stale attach + // backoff from an earlier failure must not downgrade this pane to DOM. + clearTerminalWebglAttachBackoff() attachWebgl(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index e68cdafcffa..b84ba23590b 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -7,14 +7,25 @@ import { export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined +// Why: while Chromium refuses WebGL context creation (GPU process crashed or +// WebGL blocked after repeated resets), every attach attempt burns a canvas + +// failed getContext and logs a full-stack warning — and title changes retrigger +// attach constantly in "on" mode. Latch the first failure and skip attempts +// until the next recovery boundary (rendering resume or GPU-setting change). +let webglAttachFailedSinceRecovery = false export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. suggestedRendererType = undefined + webglAttachFailedSinceRecovery = false resetTerminalWebglAutoDecision() } +export function clearTerminalWebglAttachBackoff(): void { + webglAttachFailedSinceRecovery = false +} + export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean { if (pane.terminalGpuAcceleration === 'on') { return true @@ -96,7 +107,8 @@ export function attachWebgl(pane: ManagedPaneInternal): void { !pane.gpuRenderingEnabled || !shouldUseTerminalWebgl(pane) || pane.webglAttachmentDeferred || - pane.webglDisabledAfterContextLoss + pane.webglDisabledAfterContextLoss || + webglAttachFailedSinceRecovery ) { pane.webglAddon = null return @@ -111,7 +123,8 @@ export function attachWebgl(pane: ManagedPaneInternal): void { ) // Why: Chromium starts reclaiming terminal contexts under pressure. // Recreating WebGL for this pane can loop context loss and leave xterm - // visually blank, so keep the pane on the DOM renderer until remount. + // visually blank, so keep the pane on the DOM renderer until the next + // rendering resume (worktree foreground / window wake) retries it. pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) @@ -124,6 +137,7 @@ export function attachWebgl(pane: ManagedPaneInternal): void { // enough signal to keep new auto panes on DOM until the setting changes. suggestedRendererType = 'dom' } + webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) pane.webglAddon = null From ab3878ffa08671fca1ea324b29b13f603fc2aa56 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:17:22 +0000 Subject: [PATCH 021/233] release: v1.4.117-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d3f62f5fe9a..88f598a7d08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.117-rc.2", + "version": "1.4.117-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 279135ed070af773b453022723aa0f91affb536a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:27:29 +0000 Subject: [PATCH 022/233] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 8287429c5dc..79f778f0a38 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.1m + + downloads: 3.2m @@ -15,7 +15,7 @@ downloads downloads - 3.1m - 3.1m + 3.2m + 3.2m From 151c567074cb29dfc9cb4b6f49fc2d8e3cce3bd3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:52:01 -0700 Subject: [PATCH 023/233] Promote stale agent targets when live titles prove they are sendable (#7043) * Promote stale agent targets when live titles prove they are sendable - Extract centralized `detectAgentSendTitleStatus` helper to determine if an agent is ready or needs permission based on pane and tab titles. - Prevent stale hook-backed status rows from disabling active targets when fresh live titles and PTYs prove they are sendable. - Fallback to the tab's launch agent type when status-backed targets have unknown agent types. - Add comprehensive test coverage for promoting stale pane targets and preserving permission blocks. * Prevent split panes from borrowing stale tab titles Introduce `resolveRuntimePaneTitleLeafResolution` to track whether a tab has any reported runtime pane titles. Use this to ensure that a split pane does not fall back to the overall tab title when another pane in the same tab already has active title evidence, avoiding stale target status. * Resolve single pane title if tab layout lacks root When a tab layout does not have a root, we can only attribute a single reported pane title. This ensures that any pane title still suppresses the stale tab-title fallback. --- .../src/lib/agent-send-title-status.ts | 31 +++ .../src/lib/notes-send-agent-targets.test.ts | 192 +++++++++++++++++- .../src/lib/notes-send-agent-targets.ts | 191 +++++++++-------- .../src/lib/running-agent-targets.test.ts | 124 ++++++++++- src/renderer/src/lib/running-agent-targets.ts | 40 ++-- .../lib/runtime-pane-title-leaf-id.test.ts | 30 ++- .../src/lib/runtime-pane-title-leaf-id.ts | 55 +++-- 7 files changed, 540 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/lib/agent-send-title-status.ts diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts new file mode 100644 index 00000000000..11f949fde88 --- /dev/null +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -0,0 +1,31 @@ +import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status' + +const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i +const CLAUDE_IDLE_PREFIX = '\u2733' +const GEMINI_IDLE_PREFIX = '\u25c7' +const PI_IDLE_PREFIX = '\u03c0 - ' + +export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null { + if (!title || getAgentLabel(title) === null) { + return null + } + + const status = detectAgentStatusFromTitle(title) + if (status !== 'idle') { + return status + } + + // Why: selected-target sends are immediate. A bare agent name proves identity, + // but not that the CLI is ready for submitted input yet. + return isExplicitIdleSendTitle(title) ? status : null +} + +function isExplicitIdleSendTitle(title: string): boolean { + return ( + EXPLICIT_IDLE_SEND_TITLE_RE.test(title) || + title.startsWith(CLAUDE_IDLE_PREFIX) || + title.startsWith('* ') || + title.includes(GEMINI_IDLE_PREFIX) || + title.startsWith(PI_IDLE_PREFIX) + ) +} diff --git a/src/renderer/src/lib/notes-send-agent-targets.test.ts b/src/renderer/src/lib/notes-send-agent-targets.test.ts index 018e63b6aff..a87ba7392c9 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.test.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../shared/agent-status-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' import { makePaneKey } from '../../../shared/stable-pane-id' import { @@ -13,6 +17,7 @@ const LAUNCH_TAB_ID = 'tab-launch' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' const NOW = 10_000 +const OLD_STATUS_UPDATED_AT = NOW - AGENT_STATUS_STALE_AFTER_MS - 1 function tab(id: string, overrides: Partial = {}): TerminalTab { return { @@ -28,15 +33,21 @@ function tab(id: string, overrides: Partial = {}): TerminalTab { } } -function entry(paneKey: string, state: AgentStatusState = 'done'): AgentStatusEntry { +function entry( + paneKey: string, + state: AgentStatusState = 'done', + updatedAt = NOW, + overrides: Partial = {} +): AgentStatusEntry { return { paneKey, state, prompt: '', - updatedAt: NOW, - stateStartedAt: NOW, + updatedAt, + stateStartedAt: updatedAt, agentType: 'codex', - stateHistory: [] + stateHistory: [], + ...overrides } } @@ -340,6 +351,177 @@ describe('notes send agent targets', () => { }) }) + it('promotes a stale status-backed launch-agent pane when live title and PTY prove it is sendable', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [ + tab(LAUNCH_TAB_ID, { title: 'Previous Codex session', launchAgent: 'codex' }) + ] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + { + paneKey, + tabId: LAUNCH_TAB_ID, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Previous Codex session', + status: 'eligible' + } + ]) + }) + + it('uses launch ownership when promoting a stale unknown status-backed pane', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT, { + agentType: 'unknown' + }) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + agentType: 'codex', + status: 'eligible' + }) + ]) + }) + + it('keeps a stale status-backed launch-agent pane disabled with only a bare agent title', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + ]) + }) + + it('keeps a stale status-backed launch-agent pane disabled when the live title needs permission', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex - action required' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + status: 'disabled', + disabledReason: 'Agent needs permission' + }) + ]) + }) + + it('does not let a stale status-backed split pane hide a different live active launch-agent pane', () => { + const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A) + const livePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: stalePaneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }), + expect.objectContaining({ + paneKey: livePaneKey, + status: 'eligible' + }) + ]) + }) + + it('does not borrow a stale tab title for an active split pane after another pane has title evidence', () => { + const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: stalePaneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + ]) + }) + it('skips a launch-agent tab whose active leaf is not a terminal leaf', () => { const targets = deriveNotesSendAgentTargets( state({ diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts index e2909a22b0f..3ce0e61f7fa 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -1,18 +1,17 @@ import type { AgentType } from '../../../shared/agent-status-types' import type { AppState } from '@/store/types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { detectAgentStatusFromTitle, getAgentLabel } from './agent-status' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import type { TerminalTab } from '../../../shared/types' +import { detectAgentSendTitleStatus } from './agent-send-title-status' +import { + resolveRuntimePaneTitleLeafResolution, + type RuntimePaneTitleLeafResolution +} from './runtime-pane-title-leaf-id' import { deriveRunningAgentSendTargets, type RunningAgentTargetState } from './running-agent-targets' -const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i -const CLAUDE_IDLE_PREFIX = '\u2733' -const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' - export type NotesSendAgentTargetState = RunningAgentTargetState & Pick @@ -26,41 +25,19 @@ export type NotesSendAgentTarget = { disabledReason?: string } -function isRecognizedAgentTitle(title: string | null): boolean { - return ( - title !== null && detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null - ) -} - -function detectLaunchAgentPaneStatus(paneTitle: string | null, tabTitle: string) { - if (paneTitle !== null && isRecognizedAgentTitle(paneTitle)) { - return detectLaunchAgentStatusFromTitle(paneTitle) +function detectLaunchAgentPaneStatus( + paneTitleResolution: RuntimePaneTitleLeafResolution, + tabTitle: string +) { + if (paneTitleResolution.title !== null) { + return detectAgentSendTitleStatus(paneTitleResolution.title) } // Why: mirror isTerminalRunningAgent — the OSC-enriched tab title only counts // when the leaf has no runtime pane title of its own yet. - return paneTitle === null && isRecognizedAgentTitle(tabTitle) - ? detectLaunchAgentStatusFromTitle(tabTitle) - : null -} - -function detectLaunchAgentStatusFromTitle(title: string) { - const status = detectAgentStatusFromTitle(title) - if (status !== 'idle') { - return status + if (paneTitleResolution.hasAnyPaneTitle) { + return null } - // Why: selected-target sends are immediate. A bare launch title like - // "Codex" proves agent identity, but not that the CLI is ready for input. - return isExplicitIdleLaunchTitle(title) ? status : null -} - -function isExplicitIdleLaunchTitle(title: string): boolean { - return ( - EXPLICIT_IDLE_TITLE_RE.test(title) || - title.startsWith(CLAUDE_IDLE_PREFIX) || - title.startsWith('* ') || - title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) - ) + return detectAgentSendTitleStatus(tabTitle) } /** @@ -88,59 +65,113 @@ export function deriveNotesSendAgentTargets( paneKey: target.paneKey, tabId: target.tabId, leafId: target.leafId, - agentType: target.entry.agentType, + agentType: resolveNotesTargetAgentType(target.entry.agentType, target.tab.launchAgent), tabTitle: target.tab.title, status: target.status, ...(target.disabledReason ? { disabledReason: target.disabledReason } : {}) }) ) - // Why: dedupe by tab, not pane. A launch-agent tab already surfaced through a - // live status entry must not also emit a hint row — its active leaf may be a - // split shell pane, which would list a second bogus row for the same tab. - const statusBackedTabIds = new Set(targets.map((target) => target.tabId)) - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - if (!tab.launchAgent || statusBackedTabIds.has(tab.id)) { + const launchTarget = deriveLaunchAgentTarget(state, tab) + if (!launchTarget) { continue } - const layout = state.terminalLayoutsByTabId[tab.id] - const leafId = layout?.activeLeafId - if (!leafId || !isTerminalLeafId(leafId)) { - continue - } - - const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null - if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) { - continue - } - - const paneTitle = resolveRuntimePaneTitleForLeaf( - layout, - state.runtimePaneTitlesByTabId[tab.id], - leafId - ) - const launchStatus = detectLaunchAgentPaneStatus(paneTitle, tab.title) - if (!launchStatus) { - // Why: launchAgent is set the instant Orca spawns the tab, but the runtime - // only accepts a send once the pane reads as an agent. Skipping until the - // title is recognized keeps "listed ⇒ sendable" and avoids the boot-window - // "not a recognized agent session" error. - continue - } - const disabledReason = launchStatus === 'permission' ? 'Agent needs permission' : undefined - - targets.push({ - paneKey: makePaneKey(tab.id, leafId), - tabId: tab.id, - leafId, - agentType: tab.launchAgent, - tabTitle: tab.title, - status: disabledReason ? 'disabled' : 'eligible', - ...(disabledReason ? { disabledReason } : {}) - }) + mergeLaunchAgentTarget(targets, launchTarget) } return targets } + +function resolveNotesTargetAgentType( + entryAgentType: AgentType | null | undefined, + launchAgent: AgentType | null | undefined +): AgentType | null | undefined { + if (entryAgentType && entryAgentType !== 'unknown') { + return entryAgentType + } + return launchAgent ?? entryAgentType +} + +function deriveLaunchAgentTarget( + state: NotesSendAgentTargetState, + tab: TerminalTab +): NotesSendAgentTarget | null { + if (!tab.launchAgent) { + return null + } + + const layout = state.terminalLayoutsByTabId[tab.id] + const leafId = layout?.activeLeafId + if (!leafId || !isTerminalLeafId(leafId)) { + return null + } + + const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null + if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) { + return null + } + + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId) + const launchStatus = detectLaunchAgentPaneStatus(paneTitleResolution, tab.title) + if (!launchStatus) { + // Why: launchAgent is set the instant Orca spawns the tab, but the runtime + // only accepts a send once the pane reads as an agent. Skipping until the + // title is recognized keeps "listed ⇒ sendable" and avoids the boot-window + // "not a recognized agent session" error. + return null + } + const disabledReason = launchStatus === 'permission' ? 'Agent needs permission' : undefined + + return { + paneKey: makePaneKey(tab.id, leafId), + tabId: tab.id, + leafId, + agentType: tab.launchAgent, + tabTitle: tab.title, + status: disabledReason ? 'disabled' : 'eligible', + ...(disabledReason ? { disabledReason } : {}) + } +} + +function mergeLaunchAgentTarget( + targets: NotesSendAgentTarget[], + launchTarget: NotesSendAgentTarget +): void { + const samePaneIndex = targets.findIndex((target) => target.paneKey === launchTarget.paneKey) + if (samePaneIndex !== -1) { + const existing = targets[samePaneIndex] + if (existing.status === 'eligible' || existing.disabledReason === 'Agent needs permission') { + return + } + + // Why: hook-backed status can outlive the CLI after sleep/resume. When the + // same live launch-agent pane has a fresh title proof, prefer the sendable + // runtime evidence over the stale retained status row. + targets[samePaneIndex] = { + ...launchTarget, + agentType: + existing.agentType && existing.agentType !== 'unknown' + ? existing.agentType + : launchTarget.agentType, + tabTitle: existing.tabTitle || launchTarget.tabTitle + } + return + } + + // Why: dedupe by tab for fresh/permission status rows. Their active leaf may + // be a split shell pane, which would list a second bogus row for the same tab. + if ( + targets.some( + (target) => + target.tabId === launchTarget.tabId && + (target.status === 'eligible' || target.disabledReason === 'Agent needs permission') + ) + ) { + return + } + + targets.push(launchTarget) +} diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 47e24fbf061..847052d2e79 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -205,7 +205,127 @@ describe('running agent send targets', () => { ]) }) - it('disables stale agent status rows even when the pane still has a leaf PTY', () => { + it('keeps stale agent status rows disabled when no live title proves the agent is sendable', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'waiting', NOW - 31 * 60 * 1000) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + + it('keeps stale agent status rows disabled when only a bare agent title remains', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000) + }, + tabsByWorktree: { [WORKTREE_ID]: [{ ...tab(TAB_ID), title: 'Codex' }] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + + it('promotes stale agent status rows when a live pane title proves the agent is sendable', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'eligible' + }) + expect(target).not.toHaveProperty('disabledReason') + }) + + it('treats a missing tab title as absent live title evidence', () => { + const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done') + }, + tabsByWorktree: { + [WORKTREE_ID]: [{ ...tab(TAB_ID), title: undefined } as unknown as TerminalTab] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + paneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey, + ptyId: 'pty-right', + status: 'eligible' + }) + }) + + it('keeps stale agent status rows disabled when the live pane title needs permission', () => { const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) const target = resolveRunningAgentSendTarget( state({ @@ -231,7 +351,7 @@ describe('running agent send targets', () => { paneKey: stalePaneKey, ptyId: 'pty-right', status: 'disabled', - disabledReason: 'Agent status is stale' + disabledReason: 'Agent needs permission' }) }) diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 8e310c86d0b..332f419dbd2 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -5,12 +5,9 @@ import { } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { - detectAgentStatusFromTitle, - getAgentLabel, - isExplicitAgentStatusFresh -} from './agent-status' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { isExplicitAgentStatusFresh } from './agent-status' +import { detectAgentSendTitleStatus } from './agent-send-title-status' +import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' export type RunningAgentTargetState = Pick< AppState, @@ -61,13 +58,22 @@ export function deriveRunningAgentSendTargets( : null let disabledReason: string | undefined + // Why: hook-backed rows can go stale while the same PTY is still a live + // agent; live titles are the runtime proof that the row remains targetable. + const liveTitleStatus = ptyId + ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) + : null if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - disabledReason = 'Agent status is stale' + if (liveTitleStatus === 'permission') { + disabledReason = 'Agent needs permission' + } else if (liveTitleStatus === null) { + disabledReason = 'Agent status is stale' + } } else if (!ptyId) { disabledReason = 'Terminal is no longer available' } else if (entry.state === 'blocked' || entry.state === 'waiting') { disabledReason = 'Agent needs permission' - } else if (hasPermissionPaneTitle(state, parsed.tabId, parsed.leafId, tab.title)) { + } else if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } @@ -86,22 +92,22 @@ export function deriveRunningAgentSendTargets( return targets } -function hasPermissionPaneTitle( +function detectLiveAgentPaneStatus( state: RunningAgentTargetState, tabId: string, leafId: string, tabTitle: string -): boolean { +): ReturnType { const layout = state.terminalLayoutsByTabId[tabId] - const paneTitle = resolveRuntimePaneTitleForLeaf( - layout, - state.runtimePaneTitlesByTabId?.[tabId], - leafId - ) + const paneTitles = state.runtimePaneTitlesByTabId?.[tabId] + const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId) // Why: runtime pane titles are the freshest title signal for split panes; use // the tab title only before the runtime has reported a pane title for the leaf. - const title = paneTitle ?? tabTitle - return detectAgentStatusFromTitle(title) === 'permission' && getAgentLabel(title) !== null + const title = paneTitleResolution.title ?? (paneTitleResolution.hasAnyPaneTitle ? null : tabTitle) + if (title === null) { + return null + } + return detectAgentSendTitleStatus(title) } export function resolveRunningAgentSendTarget( diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts index 3ae0b9a49bd..be00d6b7098 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../shared/types' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { + resolveRuntimePaneTitleForLeaf, + resolveRuntimePaneTitleLeafResolution +} from './runtime-pane-title-leaf-id' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' @@ -45,3 +48,28 @@ describe('resolveRuntimePaneTitleForLeaf', () => { expect(resolveRuntimePaneTitleForLeaf(undefined, { 7: 'Codex' }, LEAF_A)).toBe('Codex') }) }) + +describe('resolveRuntimePaneTitleLeafResolution', () => { + it('reports when no pane titles are present', () => { + expect(resolveRuntimePaneTitleLeafResolution(leafLayout, {}, LEAF_A)).toEqual({ + title: null, + hasAnyPaneTitle: false + }) + }) + + it('reports an unrelated sparse split title without attributing it to the leaf', () => { + expect(resolveRuntimePaneTitleLeafResolution(splitLayout, { 2: 'Codex' }, LEAF_A)).toEqual({ + title: null, + hasAnyPaneTitle: true + }) + }) + + it('reports the matching leaf title when one resolves', () => { + expect( + resolveRuntimePaneTitleLeafResolution(splitLayout, { 1: 'zsh', 2: 'Codex' }, LEAF_B) + ).toEqual({ + title: 'Codex', + hasAnyPaneTitle: true + }) + }) +}) diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts index dd7eb849d03..0847f1e3de2 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -2,6 +2,11 @@ import { FIRST_PANE_ID } from '../../../shared/pane-key' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types' +export type RuntimePaneTitleLeafResolution = { + title: string | null + hasAnyPaneTitle: boolean +} + function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) } @@ -55,34 +60,48 @@ export function resolveRuntimePaneTitleForLeaf( paneTitles: Record | undefined, leafId: string ): string | null { + return resolveRuntimePaneTitleLeafResolution(tabLayout, paneTitles, leafId).title +} + +export function resolveRuntimePaneTitleLeafResolution( + tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined, + paneTitles: Record | undefined, + leafId: string +): RuntimePaneTitleLeafResolution { if (!paneTitles) { - return null + return { title: null, hasAnyPaneTitle: false } } - const titleEntries = Object.entries(paneTitles) - if (titleEntries.length === 0) { - return null - } + const titlesByPaneId = paneTitles as Record + let firstTitle: string | null = null + let hasOnePaneTitle = false + let hasMultiplePaneTitles = false - if (tabLayout?.root) { - for (const [runtimePaneId, title] of titleEntries) { - if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { - return title - } + for (const runtimePaneId in titlesByPaneId) { + if (!Object.prototype.hasOwnProperty.call(titlesByPaneId, runtimePaneId)) { + continue } - return null - } - if (titleEntries.length === 1) { - return titleEntries[0][1] - } + const title = titlesByPaneId[runtimePaneId] + if (hasOnePaneTitle) { + hasMultiplePaneTitles = true + } else { + firstTitle = title + hasOnePaneTitle = true + } - for (const [runtimePaneId, title] of titleEntries) { if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { - return title + return { title, hasAnyPaneTitle: true } } } - return null + + // Why: without a layout root, only a single reported pane title can be + // attributed; any pane title still suppresses stale tab-title fallback. + if (!tabLayout?.root && hasOnePaneTitle && !hasMultiplePaneTitles) { + return { title: firstTitle, hasAnyPaneTitle: true } + } + + return { title: null, hasAnyPaneTitle: hasOnePaneTitle } } export function resolveRuntimePaneTitleLeafIdFromRoot( From 6dafe8c8705fdd62d79b638ea2a1c6a5470925dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:06:10 -0700 Subject: [PATCH 024/233] Fix worktree sort crash when display name is missing (#6993) --- .../src/components/sidebar/WorktreeList.tsx | 4 +-- .../src/components/sidebar/smart-sort.test.ts | 16 +++++++++ .../src/components/sidebar/smart-sort.ts | 34 +++++++++++++++---- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index bb16fbde91b..75cab5bcc2c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -63,7 +63,7 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator } from './smart-sort' +import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' import { buildAttentionByWorktree, type SmartClass, @@ -5193,7 +5193,7 @@ const WorktreeList = React.memo(function WorktreeList({ sessionHasHadPty.current = true } else { nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) lastAttentionByWorktreeRef.current = null return nonArchivedWorktrees.map((w) => w.id) diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 34092befe9a..cb61490c90a 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -458,6 +458,22 @@ describe('sortWorktreesSmart — cold start fallback', () => { expect(sorted.map((w) => w.id)).toEqual(['b', 'a']) }) + it('falls back to the path label when a persisted worktree has no displayName', () => { + const missingDisplayName = { + ...makeWorktree({ + id: 'missing-display-name', + path: '/tmp/alpha-path', + sortOrder: 1 + }), + displayName: undefined + } as unknown as Worktree + const named = makeWorktree({ id: 'named', displayName: 'Zulu', sortOrder: 1 }) + + const sorted = sortWorktreesSmart([named, missingDisplayName], {}, repoMap, {}, {}, {}) + + expect(sorted.map((w) => w.id)).toEqual(['missing-display-name', 'named']) + }) + it('treats slept tabs (tab.ptyId without live entry) as cold start', () => { // Why: tab.ptyId is the wake-hint sessionId preserved under sleep — not a // liveness signal. With slept tabs but no live PTYs, sortWorktreesSmart diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 4c87740a944..616e23bc039 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -4,6 +4,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { basename } from '@/lib/path' import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention' export type SortBy = 'name' | 'smart' | 'recent' | 'repo' | 'manual' @@ -40,6 +41,27 @@ export function effectiveRecentActivity(worktree: Worktree, now: number): number return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) } +type WorktreeSortLabelInput = Pick + +export function getWorktreeSortLabel(worktree: WorktreeSortLabelInput): string { + const displayName = typeof worktree.displayName === 'string' ? worktree.displayName.trim() : '' + if (displayName) { + return displayName + } + + // Why: persisted or remote worktree state can briefly omit displayName after + // a custom workspace name is removed; sorting must stay render-safe. + const pathLabel = typeof worktree.path === 'string' ? basename(worktree.path).trim() : '' + return pathLabel || worktree.id +} + +export function compareWorktreeSortLabel( + a: WorktreeSortLabelInput, + b: WorktreeSortLabelInput +): number { + return getWorktreeSortLabel(a).localeCompare(getWorktreeSortLabel(b)) +} + /** * Build a comparator for sorting worktrees based on the current sort mode. * @@ -58,7 +80,7 @@ export function buildWorktreeComparator( return (a, b) => { switch (sortBy) { case 'name': - return a.displayName.localeCompare(b.displayName) + return compareWorktreeSortLabel(a, b) case 'smart': { const aw = attentionByWorktree.get(a.id) ?? IDLE const bw = attentionByWorktree.get(b.id) ?? IDLE @@ -70,7 +92,7 @@ export function buildWorktreeComparator( // Why: idle worktrees fall through to recency (and the create-grace // floor for brand-new worktrees) before alphabetical. effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } case 'recent': @@ -87,13 +109,13 @@ export function buildWorktreeComparator( // events) and by meaningful meta edits (comment, isUnread). return ( effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) case 'repo': { const ra = repoMap.get(a.repoId)?.displayName ?? '' const rb = repoMap.get(b.repoId)?.displayName ?? '' const cmp = ra.localeCompare(rb) - return cmp !== 0 ? cmp : a.displayName.localeCompare(b.displayName) + return cmp !== 0 ? cmp : compareWorktreeSortLabel(a, b) } case 'manual': // Why fallback to sortOrder: existing users have a persisted smart-sort @@ -101,7 +123,7 @@ export function buildWorktreeComparator( // restored order instead of alphabetizing every legacy workspace. return ( (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } } @@ -143,7 +165,7 @@ export function sortWorktreesSmart( // Cold start: use persisted sortOrder snapshot until the agent-status // snapshot lands and a warm sort runs. return [...worktrees].sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) } From b07ea4a1a5cf94bc8de18df1d21c95352cc7dd5c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:32:05 -0700 Subject: [PATCH 025/233] Push-refresh git status on repo metadata changes and shell command completion (#7086) Branch switches and commits made inside terminals now surface through push signals instead of waiting for the 30s terminal-only fallback poll: - The git-common watch now also covers the primary checkout's HEAD, packed-refs, and index (a few stat calls per tick; no new native watchers). - worktrees:changed events nudge the active worktree's coalesced git status runner (visibility-gated, 3s floor). - OSC 133;D command completion dispatches a window event that nudges the same runner for the active worktree. Co-authored-by: Orca --- ...rktree-base-directory-event-filter.test.ts | 70 ++++++++ .../worktree-base-directory-event-filter.ts | 13 +- .../worktree-base-directory-poller.test.ts | 55 ++++++ src/main/ipc/worktree-git-common-watch.ts | 76 ++++++-- .../git-status-push-signal-refresh.test.ts | 164 ++++++++++++++++++ .../git-status-push-signal-refresh.ts | 65 +++++++ .../right-sidebar/useGitStatusPolling.test.ts | 15 ++ .../right-sidebar/useGitStatusPolling.ts | 13 +- .../terminal-pane/pty-connection.ts | 4 + .../hooks/terminal-command-finished-event.ts | 16 ++ 10 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 src/main/ipc/worktree-base-directory-event-filter.test.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts create mode 100644 src/renderer/src/hooks/terminal-command-finished-event.ts diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts new file mode 100644 index 00000000000..ad66359d965 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { + matchingWorktreeBaseRepoIds, + type WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +const COMMON_DIR = join('/repos', 'project', '.git') + +function makeGitCommonTarget(): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${COMMON_DIR}`, + kind: 'git-common', + path: COMMON_DIR, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } +} + +describe('matchingWorktreeBaseRepoIds (git-common)', () => { + it('matches linked-worktree metadata under worktrees/', () => { + const target = makeGitCommonTarget() + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD') + }) + ).toEqual(['repo-1']) + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'create', + path: join(COMMON_DIR, 'worktrees', 'wt-b') + }) + ).toEqual(['repo-1']) + }) + + it('matches primary-checkout branch/index metadata at the common-dir top level', () => { + const target = makeGitCommonTarget() + for (const file of ['HEAD', 'packed-refs', 'index']) { + expect( + matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) }) + ).toEqual(['repo-1']) + } + }) + + it('ignores non-status common-dir churn', () => { + const target = makeGitCommonTarget() + for (const path of [ + join(COMMON_DIR, 'config'), + join(COMMON_DIR, 'FETCH_HEAD'), + join(COMMON_DIR, 'COMMIT_EDITMSG'), + join(COMMON_DIR, 'objects', 'ab', 'cdef'), + join(COMMON_DIR, 'refs', 'heads', 'main'), + join(COMMON_DIR, 'logs', 'HEAD'), + // Nested HEAD outside worktrees/ must not be mistaken for the primary's. + join(COMMON_DIR, 'modules', 'sub', 'HEAD') + ]) { + expect(matchingWorktreeBaseRepoIds(target, { type: 'update', path })).toEqual([]) + } + }) + + it('ignores paths outside the watch root', () => { + const target = makeGitCommonTarget() + expect( + matchingWorktreeBaseRepoIds(target, { + type: 'update', + path: join('/repos', 'project', 'HEAD') + }) + ).toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 4c9d294cf21..c12da5d216b 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -82,11 +82,22 @@ function matchingBaseRepoIds( return repoIds } +// Why: branch switches and commits in the primary checkout rewrite these +// top-level common-dir files; matching them keeps root-checkout branch/status +// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored. +const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index']) + // Why: Git records linked worktrees under the common dir's `worktrees` // metadata, which is lower churn than watching checkout contents. function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] { const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath) - if (!parts || parts[0] !== 'worktrees') { + if (!parts) { + return [] + } + if ( + parts[0] !== 'worktrees' && + !(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0])) + ) { return [] } return [...target.repos.keys()] diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index cf8ce22a620..292530f0814 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -204,6 +204,33 @@ describe('worktree base directory poller', () => { ) }) + it('reports primary-checkout HEAD changes via polling', async () => { + const commonDir = await makeRoot() + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + // Force the non-darwin poll path so this test is deterministic on all CI. + { pollIntervalMs: POLL_MS, platform: 'linux' } + ) + cleanups.push(() => poller.unsubscribe()) + + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === headFile) + ) + + // A branch switch rewrites HEAD in place; the mtime diff must surface it. + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(headFile, 'ref: refs/heads/feature') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + }) + it('emits deletes for all known worktrees when the root vanishes', async () => { const root = await makeRoot() const worktree = join(root, 'external-5') @@ -255,6 +282,34 @@ describe('worktree base directory poller', () => { ) }) + it('covers primary-checkout metadata alongside the narrow stream', async () => { + const commonDir = await makeRoot() + await mkdir(join(commonDir, 'worktrees')) + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('git-common', commonDir) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { pollIntervalMs: POLL_MS, platform: 'darwin' } + ) + cleanups.push(() => poller.unsubscribe()) + + // The narrow stream is rooted at worktrees/, so top-level HEAD writes + // must arrive through the companion metadata poll. + const headFile = join(commonDir, 'HEAD') + await writeFile(headFile, 'ref: refs/heads/main') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === headFile) + ) + + await new Promise((resolve) => setTimeout(resolve, 10)) + await writeFile(headFile, 'ref: refs/heads/feature') + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'update' && event.path === headFile) + ) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { const commonDir = await makeRoot() const received: WorktreeBasePollEvent[][] = [] diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index d328220aa00..1529f9de1a4 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -6,14 +6,37 @@ import type { WorktreeBaseSubscription } from './worktree-base-directory-poller' -// Watches a repo's `/.git/worktrees` metadata — the only subtree the -// git-common event filter consumes. -// macOS: a narrow native stream rooted there — a tiny, rare-churn tree — -// gives instant detection with zero idle cost and zero wide-scope fseventsd -// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and +// Watches a repo's `/.git/worktrees` metadata plus the primary +// checkout's shallow branch/index files — the only paths the git-common event +// filter consumes. +// macOS: a narrow native stream rooted at `worktrees/` — a tiny, rare-churn +// tree — gives instant detection with zero idle cost and zero wide-scope +// fseventsd delivery; the primary files are covered by a few stat calls per +// tick (a native stream would have to span the whole common dir, objects +// included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// Why: branch switches and commits made in the primary checkout rewrite these +// top-level files (linked-worktree equivalents live under `worktrees/`). +// Deliberately excludes FETCH_HEAD-style churn that carries no status change. +const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index'] + +async function snapshotPrimaryCheckoutMetadata( + commonDirPath: string +): Promise> { + const mtimes = new Map() + for (const name of PRIMARY_CHECKOUT_METADATA_FILES) { + const filePath = join(commonDirPath, name) + try { + mtimes.set(filePath, (await stat(filePath)).mtimeMs) + } catch { + // Missing file (e.g. no packed-refs yet) diffs into a create later. + } + } + return mtimes +} + async function snapshotGitCommon(commonDirPath: string): Promise> { const mtimes = new Map() const worktreesDir = join(commonDirPath, 'worktrees') @@ -59,15 +82,15 @@ function diffGitCommon( return events } -async function startGitCommonPoller( - target: WorktreeBaseWatchTarget, +async function startSnapshotDiffPoller( + takeSnapshot: () => Promise>, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, onFullScan?: () => void ): Promise { let disposed = false let ticking = false - let snapshot = await snapshotGitCommon(target.path) + let snapshot = await takeSnapshot() const timer = setInterval(() => { if (disposed || ticking) { @@ -75,7 +98,7 @@ async function startGitCommonPoller( } ticking = true onFullScan?.() - void snapshotGitCommon(target.path) + void takeSnapshot() .then((next) => { if (disposed) { return @@ -103,6 +126,19 @@ async function startGitCommonPoller( } } +async function snapshotGitCommonAndPrimaryMetadata( + commonDirPath: string +): Promise> { + const [worktrees, primary] = await Promise.all([ + snapshotGitCommon(commonDirPath), + snapshotPrimaryCheckoutMetadata(commonDirPath) + ]) + for (const [path, mtime] of primary) { + worktrees.set(path, mtime) + } + return worktrees +} + async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, @@ -227,7 +263,25 @@ export async function startGitCommonWatch( onFullScan?: () => void ): Promise { if (platform === 'darwin') { - return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs) + const [narrowWatch, primaryMetadataPoll] = await Promise.all([ + startGitCommonNarrowWatch(target, onEvents, pollIntervalMs), + startSnapshotDiffPoller( + () => snapshotPrimaryCheckoutMetadata(target.path), + onEvents, + pollIntervalMs, + onFullScan + ) + ]) + return { + unsubscribe: async () => { + await Promise.all([narrowWatch.unsubscribe(), primaryMetadataPoll.unsubscribe()]) + } + } } - return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan) + return startSnapshotDiffPoller( + () => snapshotGitCommonAndPrimaryMetadata(target.path), + onEvents, + pollIntervalMs, + onFullScan + ) } diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts new file mode 100644 index 00000000000..e5bbde86077 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as React from 'react' +import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT } from '@/hooks/terminal-command-finished-event' + +type WorktreesChangedCallback = (data: { repoId: string }) => void +type HookParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +async function renderHookOnce(params: HookParams): Promise<{ + emitWorktreesChanged: (repoId: string) => void + emitCommandFinished: (worktreeId: string) => void + onChangedSubscribe: ReturnType + windowListeners: Map + cleanups: (() => void)[] +}> { + vi.resetModules() + + const cleanups: (() => void)[] = [] + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect() + if (typeof cleanup === 'function') { + cleanups.push(cleanup) + } + }, + useRef: (initial: T) => ({ current: initial }) + } + }) + + let worktreesChangedCallback: WorktreesChangedCallback | null = null + const onChangedUnsubscribe = vi.fn() + const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => { + worktreesChangedCallback = callback + return onChangedUnsubscribe + }) + const windowListeners = new Map() + + vi.stubGlobal('window', { + api: { + worktrees: { + onChanged: onChangedSubscribe + } + }, + addEventListener: vi.fn((type: string, listener: EventListener) => { + windowListeners.set(type, listener) + }), + removeEventListener: vi.fn((type: string) => { + windowListeners.delete(type) + }) + }) + vi.stubGlobal('document', { + visibilityState: 'visible', + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh') + function PushSignalRefreshHarness(props: HookParams): null { + useGitStatusPushSignalRefresh(props) + return null + } + PushSignalRefreshHarness(params) + + return { + emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }), + emitCommandFinished: (worktreeId: string) => { + const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT) + listener?.({ detail: { worktreeId } } as unknown as Event) + }, + onChangedSubscribe, + windowListeners, + cleanups + } +} + +describe('useGitStatusPushSignalRefresh', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('nudges status when the active repo reports a worktrees change', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitWorktreesChanged('repo-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitWorktreesChanged('repo-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('nudges status when a terminal command finishes in the active worktree', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitCommandFinished('wt-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitCommandFinished('wt-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('drops nudges while the window is hidden', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + ;(document as unknown as { visibilityState: string }).visibilityState = 'hidden' + harness.emitWorktreesChanged('repo-1') + harness.emitCommandFinished('wt-1') + expect(fetchStatus).not.toHaveBeenCalled() + }) + + it('subscribes to nothing while disabled', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: false, + fetchStatus + }) + + expect(harness.onChangedSubscribe).not.toHaveBeenCalled() + expect(harness.windowListeners.size).toBe(0) + }) + + it('unsubscribes both signals on cleanup', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + expect(harness.cleanups.length).toBe(2) + for (const cleanup of harness.cleanups) { + cleanup() + } + expect(harness.windowListeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts new file mode 100644 index 00000000000..e6e0ea6d87b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +type UseGitStatusPushSignalRefreshParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +// Why: these push signals close the latency gap left by the slow terminal-only +// fallback poll — branch switches and commits made inside shells surface at +// the coalescer's floor instead of waiting out the fallback cadence. Bursts +// are safe: the main-process watcher debounces and fetchStatus feeds a +// coalesced runner with a minimum interval. +export function useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled, + fetchStatus +}: UseGitStatusPushSignalRefreshParams): void { + const fetchStatusRef = useRef(fetchStatus) + fetchStatusRef.current = fetchStatus + + useEffect(() => { + if (!enabled || !activeRepoId) { + return + } + // Why: remote web surfaces have no preload bridge; the fallback poll + // still covers them. + const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged + if (!subscribeToWorktreesChanged) { + return + } + // Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows + // skip the nudge; the visibility interval refreshes immediately on reveal. + return subscribeToWorktreesChanged(({ repoId }) => { + if (repoId !== activeRepoId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + }) + }, [enabled, activeRepoId]) + + useEffect(() => { + if (!enabled || !activeWorktreeId) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [enabled, activeWorktreeId]) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 83ad40e3aef..5c4d57338a3 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -114,6 +114,9 @@ async function usePollingOnce( watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), @@ -362,6 +365,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -471,6 +477,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -592,6 +601,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -707,6 +719,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 0fc1bc8b4a1..895b69012a1 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -14,11 +14,13 @@ import { } from '@/lib/passive-macos-app-data-access' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' +import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' const MIN_STATUS_REFRESH_INTERVAL_MS = 3000 const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS -// Why: file-watch refreshes cover content changes; terminal-only polling is -// just a fallback for branch switches made inside shells. +// Why: file-watch refreshes cover content changes and push signals (repo +// metadata watch, shell command completion) cover branch switches; the +// terminal-only poll is a last-resort backstop for shells without either. const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { @@ -182,6 +184,13 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { worktreePath }) + useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled: shouldPollActiveWorktreeGitStatus, + fetchStatus + }) + // Why: poll conflict operation for non-active worktrees that have a stale // non-unknown operation. This is a lightweight fs-only check (no git status) // so it won't cause performance issues even with many worktrees. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 3147e291644..f9b025da2c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -85,6 +85,7 @@ import { import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' +import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' @@ -1373,6 +1374,9 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(deps.worktreeId) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts new file mode 100644 index 00000000000..79bf8a978a8 --- /dev/null +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -0,0 +1,16 @@ +export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finished' + +export type TerminalCommandFinishedEventDetail = { + worktreeId: string +} + +// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets +// decoupled consumers (e.g. git status refresh) react to shell commands +// finishing without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId } + }) + ) +} From 9fc61aa7cac430ae6e05d986ce07215d420b7b25 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:33:51 -0700 Subject: [PATCH 026/233] Bound hot-terminal full-snapshot checkpoints with a per-session cooldown (#7088) * Bound hot-terminal full-snapshot checkpoints with a per-session cooldown A streaming session (build logs, yes) re-triggered a full multi-MB checkpoint.json tmp+rename on every 5s tick via pending-buffer overflow or the 5MB log cap - hundreds of MB/min of disk writes from one busy terminal, and the dominant Orca-generated filesystem event volume. Cap/overflow-triggered full snapshots now honor a 45s per-session cooldown: while cooling down the session's checkpoint work is deferred entirely (no takePendingOutput RPC, no increment appends - appending past a dropped range would leave a hole in the log, so the on-disk state stays a consistent, merely stale, prefix) and the session stays dirty so the timer retries until the cooldown expires. Final/teardown checkpoints (quit, sleep, clean disconnect) bypass the cooldown, and a session's first-ever snapshot is never delayed, so cold-restore fidelity changes only for hot sessions mid-stream: at most 45s of tail scrollback staleness after a hard crash. Co-authored-by: Orca * Harden checkpoint cooldown against crash-boundary and reuse edges From adversarial review of the cooldown commit: - Warm reattach by an adapter that was not already managing the session now forces a full-snapshot re-anchor: the previous adapter may have drained daemon records it never persisted (deferred hot-session tick), and appending past that unknown drain point would put a seq gap in the log, which the restore reader rejects wholesale. This also closes the pre-existing (previously ~ms-wide) drain-vs-persist crash window. - Cold-restore re-anchor clears any cooldown inherited from the pre-crash generation (daemon respawn within one adapter), so the revived session's first snapshot is never deferred. - Exit-event routing clears lastFullCheckpointAt (reused ptyIds must not inherit a dead session's cooldown); fanoutSyntheticExits now clears sessionsNeedingFullCheckpoint for consistency. - Backward wall-clock jumps count as expired instead of extending the deferral. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 158 +++++++++++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 86 +++++++++-- src/main/daemon/history-reader.ts | 5 +- 3 files changed, 238 insertions(+), 11 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 539f66b5582..e32415aa9a3 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -719,6 +719,108 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(checkpoint).not.toHaveBeenCalled() }) + describe('full-snapshot cooldown', () => { + type CooldownInternals = { + client: { request: ReturnType; disconnect: ReturnType } + historyManager: { + checkpoint: ReturnType + appendIncrements: ReturnType + dispose: ReturnType + } + checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + + function makeCooldownHarness(takeResult: { + overflowed: boolean + appendResult?: 'ok' | 'needs-checkpoint' + }): CooldownInternals { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const request = vi.fn(async (_type: string, payload: Record) => { + if (payload.includeSnapshot === true) { + return { records: [], seq: 2, overflowed: false, snapshot: { cols: 80, rows: 24 } } + } + return { + records: [{ kind: 'output', data: 'x' }], + seq: 1, + overflowed: takeResult.overflowed, + snapshot: null + } + }) + const internals = historyAdapter as unknown as CooldownInternals + internals.client = { request, disconnect: vi.fn() } + internals.historyManager = { + checkpoint: vi.fn(async () => {}), + appendIncrements: vi.fn(async () => takeResult.appendResult ?? 'ok'), + dispose: vi.fn(async () => {}) + } + return internals + } + + it('bounds overflow-triggered full snapshots to one per cooldown window', async () => { + const internals = makeCooldownHarness({ overflowed: true }) + + // First overflow: full snapshot allowed immediately. + await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set(['hot'])) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + + // Second tick inside the cooldown: the overflow defers and flags the + // session; no snapshot write. + await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set()) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + expect(internals.sessionsNeedingFullCheckpoint.has('hot')).toBe(true) + const requestsAfterSecondTick = internals.client.request.mock.calls.length + + // Ticks 3..24 (a hot session over ~2 minutes): flagged + cooling down + // short-circuits with ZERO daemon RPCs and zero disk writes. + for (let i = 0; i < 22; i++) { + await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set()) + } + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + expect(internals.client.request.mock.calls.length).toBe(requestsAfterSecondTick) + + // Cooldown expiry: the deferred full snapshot lands and clears the flag. + internals.lastFullCheckpointAt.set('hot', Date.now() - 46_000) + await expect(internals.checkpointSessions(['hot'])).resolves.toEqual(new Set(['hot'])) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(2) + expect(internals.sessionsNeedingFullCheckpoint.has('hot')).toBe(false) + }) + + it('lets final checkpoints bypass the cooldown', async () => { + const internals = makeCooldownHarness({ overflowed: true }) + await internals.checkpointSessions(['hot']) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + + // Cooldown is active, but quit/sleep-time persistence must not be + // deferred — stale-on-crash is acceptable, stale-on-clean-exit is not. + await expect(internals.checkpointSessions(['hot'], { final: true })).resolves.toEqual( + new Set(['hot']) + ) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(2) + }) + + it('defers log-cap (needs-checkpoint) snapshots inside the cooldown', async () => { + const internals = makeCooldownHarness({ + overflowed: false, + appendResult: 'needs-checkpoint' + }) + internals.lastFullCheckpointAt.set('capped', Date.now()) + + await expect(internals.checkpointSessions(['capped'])).resolves.toEqual(new Set()) + expect(internals.historyManager.checkpoint).not.toHaveBeenCalled() + expect(internals.sessionsNeedingFullCheckpoint.has('capped')).toBe(true) + + internals.lastFullCheckpointAt.set('capped', Date.now() - 46_000) + await expect(internals.checkpointSessions(['capped'])).resolves.toEqual(new Set(['capped'])) + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + expect(internals.sessionsNeedingFullCheckpoint.has('capped')).toBe(false) + }) + }) + it('does not schedule a checkpoint timer until a session is dirty', async () => { const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS @@ -1081,6 +1183,62 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('clears a stale snapshot cooldown when the cold-restore re-anchor is flagged', async () => { + // Simulate a previous daemon crash with recoverable history on disk. + const sessionId = 'cold-restore-stale-cooldown' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'pre-crash output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const internals = historyAdapter as unknown as { + lastFullCheckpointAt: Map + sessionsNeedingFullCheckpoint: Set + } + // A daemon respawn inside one adapter keeps this map: seed a fresh + // cooldown as if the pre-crash generation just snapshotted. + internals.lastFullCheckpointAt.set(sessionId, Date.now()) + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + // The revived generation has no checkpoint of its own — the re-anchor + // must not inherit the previous generation's cooldown. + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('re-anchors a warm reattach the adapter was not already managing', async () => { + const sessionId = 'warm-reattach-reanchor' + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await first.spawn({ cols: 80, rows: 24, sessionId }) + first.dispose() + + // A fresh adapter (app relaunch) attaches to the still-live daemon + // session. The old adapter may have drained records it never persisted + // (deferred hot-session tick), so appends must not resume until a full + // snapshot re-anchors the log. + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.isReattach).toBe(true) + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => { const sessionId = 'sticky-cache-test' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index b3252aae307..f4d717f1999 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -97,6 +97,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // Against older daemons the tick falls back to full-snapshot checkpoints. private supportsIncrementalCheckpoints: boolean private static CHECKPOINT_INTERVAL_MS = 5_000 + // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB + // snapshot checkpoint on every 5s tick via pending-buffer overflow or the + // log-size cap — hundreds of MB/min of disk writes from one busy terminal. + // Bounding cap/overflow-triggered snapshots per session trades bounded + // cold-crash scrollback staleness (warm reattach and final checkpoints are + // unaffected and bypass this) for a ~9x cut in worst-case write volume. + private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 + private lastFullCheckpointAt = new Map() constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION @@ -200,6 +208,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) // Cold restore: daemon created a new session but disk history shows @@ -223,6 +232,10 @@ export class DaemonPtyAdapter implements IPtyProvider { if (this.historyManager) { this.historyManager.registerWriter(sessionId) this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint of its own; a + // cooldown inherited from the pre-crash generation (daemon respawn + // within one adapter) must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) } if (scrollback) { const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } @@ -246,6 +259,15 @@ export class DaemonPtyAdapter implements IPtyProvider { // without overwriting meta.json or deleting the existing checkpoint // (which is the only valid recovery data until the next tick). this.historyManager.registerWriter(sessionId) + if (!wasAlreadyManaged) { + // Why: a previous adapter may have drained daemon records it never + // persisted (a deferred hot-session tick) before the app died. + // Appending increments past that unknown drain point would put a seq + // gap in the log, which the restore reader rejects wholesale. Force a + // full snapshot to re-anchor before any further appends. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } } const isReattach = !result.isNew @@ -305,6 +327,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // (Under keepHistory the final checkpoint above already cleared the flag, so // this is a harmless no-op there — kept unconditional to cover both paths.) this.sessionsNeedingFullCheckpoint.delete(id) + this.lastFullCheckpointAt.delete(id) this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) // Why: history removal is for the "user explicitly closed this terminal" @@ -504,6 +527,8 @@ export class DaemonPtyAdapter implements IPtyProvider { const ids = [...this.activeSessionIds] this.activeSessionIds.clear() this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() this.stopCheckpointTimer() for (const id of ids) { this.coldRestoreCache.delete(id) @@ -563,6 +588,7 @@ export class DaemonPtyAdapter implements IPtyProvider { dispose(): void { this.stopCheckpointTimer() this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() this.removeEventListener?.() this.removeEventListener = null @@ -598,6 +624,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // and the pending getSnapshot RPCs would be rejected. await this.checkpointAllSessions() this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() this.removeEventListener?.() this.removeEventListener = null @@ -723,8 +750,12 @@ export class DaemonPtyAdapter implements IPtyProvider { final: opts?.final === true, teardown: opts?.teardown === true }) - .then(() => { - completed.add(sessionId) + .then((result) => { + // Why: deferred sessions stay dirty so the checkpoint timer keeps + // retrying until their full-snapshot cooldown expires. + if (result === 'done') { + completed.add(sessionId) + } }) .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) } @@ -739,18 +770,42 @@ export class DaemonPtyAdapter implements IPtyProvider { return completed } + // Why cooldown starts only after a session's FIRST full snapshot: a session + // with no checkpoint on disk yet must be able to write one immediately or a + // cold restore would find nothing. + private isFullCheckpointCoolingDown(sessionId: string): boolean { + const last = this.lastFullCheckpointAt.get(sessionId) + if (last === undefined) { + return false + } + const elapsed = Date.now() - last + // Why elapsed < 0 counts as expired: a backward wall-clock jump must not + // extend the deferral window. + return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS + } + + // Why 'deferred' exists: a cap/overflow-triggered full snapshot inside the + // cooldown window is postponed, and the session must STAY dirty so the 5s + // timer keeps retrying until the cooldown expires. While deferred, no + // takePendingOutput/append runs for the session — appending past a dropped + // range would leave a hole in the log, whereas skipping keeps the on-disk + // state a consistent (merely stale) prefix that the eventual full snapshot + // re-anchors. private async checkpointSession( sessionId: string, opts: { final: boolean; teardown: boolean } - ): Promise { + ): Promise<'done' | 'deferred'> { if (!this.supportsIncrementalCheckpoints) { const result = await this.client.request('getSnapshot', { sessionId }) if (result.snapshot && this.historyManager) { await this.historyManager.checkpoint(sessionId, result.snapshot) } - return + return 'done' } if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { + if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { + return 'deferred' + } // Why take-with-snapshot instead of plain getSnapshot: the take clears // the daemon's pending records in the same synchronous turn as the // serialize. A plain snapshot would leave pre-snapshot records pending; @@ -759,25 +814,29 @@ export class DaemonPtyAdapter implements IPtyProvider { // contains them. await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown }) this.sessionsNeedingFullCheckpoint.delete(sessionId) - return + return 'done' } const take = await this.client.request('takePendingOutput', { sessionId }) if (!take) { - return + return 'done' } if (take.overflowed) { // Why: overflow dropped records, so the log has a hole — only a full // snapshot (which reflects everything ever written) can re-anchor it. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) - return + return 'done' } if (take.records.length === 0) { - return + return 'done' } if (!this.historyManager) { - return + return 'done' } const appendResult = await this.historyManager.appendIncrements( sessionId, @@ -787,8 +846,13 @@ export class DaemonPtyAdapter implements IPtyProvider { if (appendResult === 'needs-checkpoint') { // Why dropping take.records is lossless: they were applied to the live // emulator before the take, so the snapshot below contains them. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) } + return 'done' } private async takeSnapshotAndCheckpoint( @@ -802,6 +866,7 @@ export class DaemonPtyAdapter implements IPtyProvider { }) if (take?.snapshot && this.historyManager) { await this.historyManager.checkpoint(sessionId, take.snapshot) + this.lastFullCheckpointAt.set(sessionId, Date.now()) if (take.records.length > 0) { // Why: take-with-snapshot usually returns no records because the // snapshot subsumes them. Held parser-state bytes, such as an @@ -916,6 +981,9 @@ export class DaemonPtyAdapter implements IPtyProvider { // full-checkpoint flag is dead state. Without this, a cold-restored // session that exits before its first checkpoint leaks a permanent entry. this.sessionsNeedingFullCheckpoint.delete(event.sessionId) + // Why: a reused sessionId (renderer respawns a persisted ptyId) must + // not inherit the dead session's snapshot cooldown. + this.lastFullCheckpointAt.delete(event.sessionId) this.stopCheckpointTimerIfIdle() if (this.historyManager) { void this.historyManager diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 65e1afd8abe..2914ed21aa7 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -50,8 +50,9 @@ export class HistoryReader { } // Why log replay is preferred over the checkpoint alone: the log carries - // byte-exact output up to ~5s before the crash, while the checkpoint can - // be a full log-cap (~5MB of output) stale. + // byte-exact output up to ~5s before the crash (up to the full-snapshot + // cooldown, ~45s, for a streaming session mid-deferral), while the + // checkpoint can be a full log-cap (~5MB of output) stale. const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint) if (logRestore) { return logRestore From 366746ad2bac185ea99dceff880f81ab8bf4c0fb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:34:44 -0700 Subject: [PATCH 027/233] Support Claude weekly Fable usage meter (#7079) * Support Claude weekly Fable usage meter * Reference Claude weekly usage research * Add distinct Claude Fable weekly meter * Tighten Claude Fable usage parsing --- docs/claude-fable-weekly-usage-meter.md | 75 ++++++ src/main/rate-limits/claude-fetcher.test.ts | 72 ++++++ src/main/rate-limits/claude-fetcher.ts | 63 ++++- src/main/rate-limits/claude-pty.test.ts | 219 ++++++++++++++++++ src/main/rate-limits/claude-pty.ts | 85 +++++-- src/main/rate-limits/service.ts | 1 + .../src/components/status-bar/StatusBar.tsx | 62 ++++- .../status-bar-provider-visibility.ts | 1 + .../src/components/status-bar/tooltip.test.ts | 23 ++ .../src/components/status-bar/tooltip.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + src/shared/rate-limit-types.ts | 2 + 16 files changed, 586 insertions(+), 42 deletions(-) create mode 100644 docs/claude-fable-weekly-usage-meter.md diff --git a/docs/claude-fable-weekly-usage-meter.md b/docs/claude-fable-weekly-usage-meter.md new file mode 100644 index 00000000000..ae7bcc0f909 --- /dev/null +++ b/docs/claude-fable-weekly-usage-meter.md @@ -0,0 +1,75 @@ +# Claude Fable Weekly Usage Meter + +## Problem + +Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can show an explicit Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them. + +Relevant code: + +- `src/shared/rate-limit-types.ts:46` models provider usage with `session` and `weekly` windows. +- `src/main/rate-limits/claude-fetcher.ts:373` maps OAuth `five_hour` and `seven_day` into Orca's Claude provider state. +- `src/main/rate-limits/claude-pty.ts:18` parses hidden `claude` `/usage` output, but `WEEKLY_RE` only accepts `Current week`. +- `src/renderer/src/components/status-bar/StatusBar.tsx:1112` renders both session and weekly windows when both are present. +- `src/renderer/src/components/status-bar/tooltip.tsx:138` includes weekly usage in the details popover. + +Research: + +- Official Claude Code statusline docs: [`rate_limits.five_hour.used_percentage` and `rate_limits.seven_day.used_percentage`](https://code.claude.com/docs/en/statusline#available-data), plus matching `resets_at`, are the 5-hour and 7-day rate-limit fields. +- `harveyxiacn/cc-usage-monitor` uses Claude Code's statusline `rate_limits` data and shows both [`5h` and `7d` windows](https://github.com/harveyxiacn/cc-usage-monitor), matching Orca's existing `session` and `weekly` model. +- `leeguooooo/claude-code-usage-bar` independently exposes the same [`5h` and `7d` rate-limit usage](https://github.com/leeguooooo/claude-code-usage-bar) in a Claude Code statusLine integration. +- Fable is not part of the documented statusline schema above. Orca only treats it as Fable weekly usage when the live `/usage` panel renders a standalone `Fable` label or an OAuth response uses an explicit weekly/seven-day Fable field name. + +Redacted live `/usage` shape this parser targets: + +```text +Plan usage limits + +Current session +18% remaining +Resets in 2h 10m + +Current week (all models) +84% left +Resets in 5d 4h + +Fable +42% consumed +Resets in 3d 2h +``` + +## Goal + +Make Orca's existing Claude status-bar meter show the weekly Claude and Fable usage windows whenever Claude Code reports them, including newer `/usage` panel wording such as `Weekly limits`, `Fable`, or `7-day`. + +## Non-goals + +- Do not infer subscription quota from token logs. +- Do not spend user Claude quota during automated verification. +- Do not change provider account switching, polling cadence, or OAuth credential handling. + +## Design + +1. Keep `ProviderRateLimits.weekly` as the canonical generic 7-day UI field. OAuth already maps `seven_day` to `weekly`, and the status bar already renders it next to the 5-hour window. +2. Add `ProviderRateLimits.fableWeekly` as a distinct optional Claude window so the chip and popover can render Session, Weekly, and Fable simultaneously. +3. Accept both OAuth `utilization` windows and Claude Code-style `used_percentage` windows with epoch-second `resets_at` values. +4. Broaden the hidden Claude CLI parser so the generic weekly label accepts both old `Current week` wording and newer usage/statusline wording: `Weekly limits`, `Weekly usage`, `weekly rate limit`, and `7-day`. +5. Parse only a standalone `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`; ambiguous Fable copy is a section boundary, not a meter. +6. Broaden percent parsing to treat `consumed` like `used`, because Anthropic describes rate-limit percentages as consumed. +7. Add focused tests for the new weekly wording and retain existing old-copy coverage. + +## Edge Cases + +- Weekly data may be absent for API-key users or before the first Claude API response; keep `weekly: null`. +- The hidden PTY fallback may still only return session data; the status bar should continue showing the 5-hour meter without error. +- Reset timestamps/descriptions may be absent from CLI output; keep `resetsAt: null` and parse only visible reset text. +- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless an explicit weekly/seven-day field or standalone `Fable` label is present. +- A bare OAuth `fable` field is ambiguous because it does not encode the window length; ignore it until the upstream contract is clearer. + +## Rollout + +1. Update OAuth window mapping for statusline-style percentages, reset timestamps, and distinct Fable weekly fields when present. +2. Update `claude-pty` weekly label, Fable label, and percent parsing. +3. Add focused tests for statusline-style OAuth data, `Weekly limits`, `Fable`, and `7-day` wording. +4. Run focused tests, then typecheck/lint. +5. Validate in Electron by injecting a Claude provider state with 5-hour, generic weekly, and Fable weekly data and capturing status-bar screenshots. +6. Commit, push, open a PR, and attach screenshots in a PR comment. diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 5c984c607e8..0790374ce0b 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,6 +169,78 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('accepts Claude Code statusline-style rate limit window fields', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, + seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, + fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 23.5, resetsAt: 1770000000000 }, + weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, + fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } + }) + }) + + it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable: { utilization: 33 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 11 }, + weekly: { usedPercent: 22 }, + fableWeekly: null + }) + }) + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 94f725c2366..c89aef0473c 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -293,27 +293,50 @@ function warnClaudeUsageFetchFailure( type OAuthUsageWindow = { utilization?: number - resets_at?: string + used_percentage?: number + resets_at?: string | number } type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow + fable_weekly?: OAuthUsageWindow + fable_seven_day?: OAuthUsageWindow + seven_day_fable?: OAuthUsageWindow } type ClaudeUsageAttemptState = { attemptedSources: UsageRateLimitSource[] } -function parseResetDescription(isoString: string | undefined): string | null { - if (!isoString) { +function parseResetTimestamp(value: string | number | undefined): number | null { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return null + } + return value > 10_000_000_000 ? value : value * 1000 + } + + if (!value) { + return null + } + + const numericValue = Number(value) + if (Number.isFinite(numericValue) && value.trim() !== '') { + return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 + } + + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? null : parsed +} + +function parseResetDescription(resetValue: string | number | undefined): string | null { + const resetTimestamp = parseResetTimestamp(resetValue) + if (resetTimestamp === null) { return null } try { - const date = new Date(isoString) - if (isNaN(date.getTime())) { - return null - } + const date = new Date(resetTimestamp) const now = new Date() const isToday = date.toDateString() === now.toDateString() if (isToday) { @@ -333,17 +356,36 @@ function mapWindow( raw: OAuthUsageWindow | undefined, windowMinutes: number ): RateLimitWindow | null { - if (!raw || typeof raw.utilization !== 'number') { + if (!raw) { + return null + } + const usedPercent = + typeof raw.utilization === 'number' + ? raw.utilization + : typeof raw.used_percentage === 'number' + ? raw.used_percentage + : null + if (usedPercent === null) { return null } return { - usedPercent: Math.min(100, Math.max(0, raw.utilization)), + usedPercent: Math.min(100, Math.max(0, usedPercent)), windowMinutes, - resetsAt: raw.resets_at ? new Date(raw.resets_at).getTime() || null : null, + resetsAt: parseResetTimestamp(raw.resets_at), resetDescription: parseResetDescription(raw.resets_at) } } +function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { + // Why: a bare "fable" field does not prove the window length. Only accept + // explicit weekly/seven-day names for the distinct Fable meter. + return ( + mapWindow(data.fable_weekly, 10080) ?? + mapWindow(data.fable_seven_day, 10080) ?? + mapWindow(data.seven_day_fable, 10080) + ) +} + async function fetchViaOAuth(token: string): Promise { await ensureProxyFromEnv() @@ -374,6 +416,7 @@ async function fetchViaOAuth(token: string): Promise { provider: 'claude', session: mapWindow(data.five_hour, 300), weekly: mapWindow(data.seven_day, 10080), + fableWeekly: mapFableWeeklyWindow(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index 4fbf801975f..a613194868e 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -177,4 +177,223 @@ describe('fetchViaPty', () => { error: null }) }) + + it('parses the newer Claude weekly limits wording for Fable usage', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 18% remaining + Resets in 2h 10m + + Weekly limits + Fable + 42% consumed + Resets in 3d 2h + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetDescription: '2h 10m' + }, + weekly: null, + fableWeekly: { + usedPercent: 42, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + it('parses generic weekly and Fable weekly limits as separate windows', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 18% remaining + Resets in 2h 10m + + Current week (all models) + 84% left + Resets in 5d 4h + + Fable + 42% consumed + Resets in 3d 2h + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetDescription: '2h 10m' + }, + weekly: { + usedPercent: 16, + resetDescription: '5d 4h' + }, + fableWeekly: { + usedPercent: 42, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + it('does not let an incomplete Fable section consume later usage sections', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + Usage unavailable + + Current session + 12% used + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('does not treat inline Fable weekly text as a parsed usage label', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + Fable weekly usage + 42% consumed + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: null, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('keeps waiting after a bare Fable heading until another usage section renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + let settled = false + + const resultPromise = fetchViaPty().finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + `) + await vi.advanceTimersByTimeAsync(2_000) + + expect(settled).toBe(false) + + term.emitData(` + 42% consumed + + Current session + 12% used + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + fableWeekly: { + usedPercent: 42 + }, + error: null + }) + }) + + it('parses 7-day weekly labels without the old Current week heading', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Usage + + Current session + 12% used + + 7-day + 84% left + Resets Wed at 9:05 PM + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16, + resetDescription: 'Wed at 9:05 PM' + }, + error: null + }) + }) }) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index a010f145734..f0363eba3f2 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -17,10 +17,12 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // Why: these patterns match the Claude CLI's /usage TUI panel output. // "Current session" shows a percent like "62% used" or "62% left". -// "Current week" section is similar. +// Weekly labels have varied between "Current week" and "Weekly limits". const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /current\s*week/i -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|left|remaining|available)/i +const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i +const FABLE_WORD_RE = /\bfable\b/i +const FABLE_LABEL_RE = /^\s*fable\s*$/i +const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) const BEL = String.fromCharCode(7) @@ -35,18 +37,36 @@ function stripTerminalControlSequences(output: string): string { * Extract percent-left from lines following a label match. * Scans up to 12 lines after the label to find the associated percent. */ -function extractPercentAfterLabel(lines: string[], labelRe: RegExp): number | null { +function matchesWeeklyLabel(line: string): boolean { + return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) +} + +function matchesFableBoundary(line: string): boolean { + return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) +} + +function isSectionLabel(line: string): boolean { + return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) +} + +function extractPercentAfterLabel( + lines: string[], + matchesLabel: (line: string) => boolean +): number | null { for (let i = 0; i < lines.length; i++) { - if (!labelRe.test(lines[i])) { + if (!matchesLabel(lines[i])) { continue } // Scan next 12 lines for a percent for (let j = i; j < Math.min(i + 12, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } const m = PERCENT_RE.exec(lines[j]) if (m) { const pct = parseFloat(m[1]) const word = m[2].toLowerCase() - const isUsed = word === 'used' + const isUsed = word === 'used' || word === 'consumed' return isUsed ? pct : 100 - pct } } @@ -54,12 +74,18 @@ function extractPercentAfterLabel(lines: string[], labelRe: RegExp): number | nu return null } -function extractResetAfterLabel(lines: string[], labelRe: RegExp): string | null { +function extractResetAfterLabel( + lines: string[], + matchesLabel: (line: string) => boolean +): string | null { for (let i = 0; i < lines.length; i++) { - if (!labelRe.test(lines[i])) { + if (!matchesLabel(lines[i])) { continue } for (let j = i; j < Math.min(i + 14, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } const m = RESET_LINE_RE.exec(lines[j]) if (m) { return m[1].trim().replace(/[)]+$/, '') @@ -72,11 +98,13 @@ function extractResetAfterLabel(lines: string[], labelRe: RegExp): string | null function parsePtyUsage(output: string): { session: RateLimitWindow | null weekly: RateLimitWindow | null + fableWeekly: RateLimitWindow | null } { const lines = output.split(/\r\n|\n|\r/) - const sessionPct = extractPercentAfterLabel(lines, SESSION_RE) - const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE) + const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) + const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) + const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) const session: RateLimitWindow | null = sessionPct !== null @@ -84,7 +112,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, sessionPct)), windowMinutes: 300, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, SESSION_RE) + resetDescription: extractResetAfterLabel(lines, (line) => SESSION_RE.test(line)) } : null @@ -94,11 +122,21 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, weeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, WEEKLY_RE) + resetDescription: extractResetAfterLabel(lines, matchesWeeklyLabel) } : null - return { session, weekly } + const fableWeekly: RateLimitWindow | null = + fableWeeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), + windowMinutes: 10080, + resetsAt: null, + resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + } + : null + + return { session, weekly, fableWeekly } } // Why: these substrings indicate the /usage TUI panel has finished @@ -109,6 +147,10 @@ const STOP_SUBSTRINGS = [ 'Current week (Opus)', 'Current week (Sonnet only)', 'Current week (Sonnet)', + 'Weekly limits', + 'Weekly limit', + 'Weekly usage', + '7-day', 'Current session', 'Failed to load usage data', 'failed to load usage data' @@ -237,12 +279,13 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) - if (session || weekly) { + const { session, weekly, fableWeekly } = parsePtyUsage(clean) + if (session || weekly || fableWeekly) { resolve({ provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: null, status: 'ok' @@ -288,9 +331,9 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parsePtyUsage(clean) - if (!session && !weekly) { + if (!session && !weekly && !fableWeekly) { resolve({ provider: 'claude', session: null, @@ -304,6 +347,7 @@ export async function fetchViaPty(options?: { provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: null, status: 'ok' @@ -385,17 +429,18 @@ export async function fetchViaPty(options?: { resolved = true clearTimeout(timeout) const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parsePtyUsage(clean) resolve({ provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: - session || weekly + session || weekly || fableWeekly ? null : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly ? 'ok' : 'error' + status: session || weekly || fableWeekly ? 'ok' : 'error' }) } }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 822dfa37101..42d8f8c3580 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1110,6 +1110,7 @@ export class RateLimitService { const previousHasData = Boolean( previous?.session || previous?.weekly || + previous?.fableWeekly || previous?.monthly || (previous?.buckets && previous.buckets.length > 0) ) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 4b15511ec7a..eb43c2c5625 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -912,6 +912,9 @@ function InlineUsageBars({ ? Math.max(0, Math.round(100 - limits.session.usedPercent)) : null const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null + const fableLeft = limits.fableWeekly + ? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)) + : null return (
@@ -943,7 +946,21 @@ function InlineUsageBars({
)} - {limits.status === 'error' && !limits.session && !limits.weekly && ( + {fableLeft !== null && ( +
+
+
+
+ + {fableLeft} + {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} + +
+ )} + {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} @@ -953,7 +970,7 @@ function InlineUsageBars({ } function isUnavailableInactiveUsage(limits: ProviderRateLimits | null | undefined): boolean { - return limits?.status === 'error' && !limits.session && !limits.weekly + return limits?.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly } function InlineUsageSignInAction({ @@ -1053,7 +1070,7 @@ function ProviderSegment({ } // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly) { + if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1072,7 +1089,7 @@ function ProviderSegment({ } // Error with no data - if (p.status === 'error' && !p.session && !p.weekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1109,15 +1126,40 @@ function ProviderSegment({ ) } + const visibleWindows = [ + p.session + ? { + key: 'session', + window: p.session, + label: formatWindowLabel(p.session.windowMinutes) + } + : null, + p.weekly + ? { + key: 'weekly', + window: p.weekly, + label: formatWindowLabel(p.weekly.windowMinutes) + } + : null, + p.fableWeekly + ? { + key: 'fableWeekly', + window: p.fableWeekly, + label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable') + } + : null + ].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null) + return ( {p.session && !compact && } - {p.session && ( - - )} - {p.session && p.weekly && ·} - {p.weekly && } + {visibleWindows.map((window, index) => ( + + {index > 0 && ·} + + + ))} {isStale && } ) @@ -1643,7 +1685,7 @@ export function ProviderDetailsMenu({ {iconOnly ? ( {provider.provider === 'claude' diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index bd2c5c1dcaa..b6e35c292fb 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -23,6 +23,7 @@ function hasUsageData(provider: ProviderRateLimits): boolean { return Boolean( provider.session || provider.weekly || + provider.fableWeekly || provider.monthly || (provider.buckets && provider.buckets.length > 0) ) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 356ecd7bd49..85d09ebc71b 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -274,6 +274,29 @@ describe('getWindowSections', () => { ]) }) + it('returns a separate Fable section when Claude reports Fable weekly usage', () => { + const p: ProviderRateLimits = { + provider: 'claude', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 20, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: p.weekly }, + { label: 'Fable', window: p.fableWeekly } + ]) + }) + it('returns session and weekly for empty buckets array', () => { const p: ProviderRateLimits = { provider: 'gemini', diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 44b3e0d432a..61c56d6e5a7 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -158,6 +158,12 @@ export function getWindowSections( window: p.weekly } ] + if (p.fableWeekly !== undefined && p.fableWeekly !== null) { + sections.push({ + label: translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable'), + window: p.fableWeekly + }) + } if (p.monthly !== undefined && p.monthly !== null) { sections.push({ label: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), @@ -229,7 +235,7 @@ export function ProviderPanel({ ) } - if (p.status === 'error' && !p.session && !p.weekly && !p.monthly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return (
@@ -324,7 +330,7 @@ export function ProviderPanel({ {p.error ? ( ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0695879260a..66b74f5c0b8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2903,7 +2903,9 @@ "c35af53b73": "Sign in", "f19a63e7cd": "Sign in to see usage", "5c938d39ac": "% wk", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "Restart live Claude terminals before continuing old conversations after switching.", "c98ea88392": "No other accounts", "9332ba8684": "Switch to", @@ -3067,6 +3069,7 @@ "6d6df77f41": "No data available", "7f7f208060": "Monthly", "252c096536": "Weekly", + "a79c64f87e": "Fable", "94038ad2fa": "Session", "2c35eca8d4": "Unable to fetch usage", "1292d4f2ee": "Unavailable", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 282c041e488..0981dc1e963 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2903,7 +2903,9 @@ "c35af53b73": "Iniciar sesión", "f19a63e7cd": "Inicia sesión para ver el uso", "5c938d39ac": "% semana", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "Reinicie los terminales Claude en vivo antes de continuar conversaciones antiguas después de cambiar.", "c98ea88392": "Ninguna otra cuenta", "9332ba8684": "Cambiar a", @@ -3067,6 +3069,7 @@ "6d6df77f41": "No hay datos disponibles", "7f7f208060": "Mensual", "252c096536": "Semanalmente", + "a79c64f87e": "Fable", "94038ad2fa": "Sesión", "2c35eca8d4": "No se puede recuperar el uso", "1292d4f2ee": "Indisponible", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index db058eddd76..b2e321fc659 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2903,7 +2903,9 @@ "c35af53b73": "サインイン", "f19a63e7cd": "サインインして使用状況を確認する", "5c938d39ac": "%週", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5時間", + "a79c64f87e": "Fable", "8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude terminals を再起動します。", "c98ea88392": "他のアカウントはありません", "9332ba8684": "に切り替えます", @@ -3067,6 +3069,7 @@ "6d6df77f41": "利用可能なデータがありません", "7f7f208060": "毎月", "252c096536": "毎週", + "a79c64f87e": "Fable", "94038ad2fa": "セッション", "2c35eca8d4": "使用状況を取得できません", "1292d4f2ee": "利用不可", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c120d72f13..eb7378ea9fa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2903,7 +2903,9 @@ "c35af53b73": "로그인", "f19a63e7cd": "사용량을 보려면 로그인하세요.", "5c938d39ac": "%주간", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.", "c98ea88392": "다른 계정 없음", "9332ba8684": "다음으로 전환", @@ -3067,6 +3069,7 @@ "6d6df77f41": "데이터가 없습니다", "7f7f208060": "월간", "252c096536": "주간", + "a79c64f87e": "Fable", "94038ad2fa": "세션", "2c35eca8d4": "사용량을 가져올 수 없습니다.", "1292d4f2ee": "사용할 수 없음", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7510e6718e5..595230d7805 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2903,7 +2903,9 @@ "c35af53b73": "登录", "f19a63e7cd": "登录查看使用情况", "5c938d39ac": "% 周", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5小时", + "a79c64f87e": "Fable", "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", "9332ba8684": "切换到", @@ -3067,6 +3069,7 @@ "6d6df77f41": "无可用数据", "7f7f208060": "每月", "252c096536": "每周", + "a79c64f87e": "Fable", "94038ad2fa": "会话", "2c35eca8d4": "无法获取使用情况", "1292d4f2ee": "不可用", diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 1f22a545302..59a897f51e1 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -49,6 +49,8 @@ export type ProviderRateLimits = { session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ weekly: RateLimitWindow | null + /** Claude Fable 7-day weekly window, null if not available. */ + fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go only), null if not available. */ monthly?: RateLimitWindow | null /** Named per-model buckets (Gemini only). */ From 6142ec1a06deaec7593a16a20def9d8d6889bd2d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:45:03 -0700 Subject: [PATCH 028/233] Refresh AI Vault session list on window refocus (#7075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refresh AI Vault session list when the window regains focus Sessions started after the panel mounted never appeared until a manual refresh, since the hook only scanned on mount and scope changes. Listen for window focus and visibilitychange (to visible) while the panel is mounted and trigger a non-force re-scan, letting the main process's 15s scan cache rate-limit rapid focus flips. The manual refresh button keeps its force (cache-bypassing) behavior. Co-authored-by: Orca * Keep AI Vault refocus refresh render-free on cache hits Refocus refreshes now run as background: the loading flag stays down (no spinner flash on every alt-tab back), and when the main process replays the cached snapshot (same scope key + scannedAt) the state updates are skipped entirely so nothing re-renders. Fresh scans and the manual force refresh apply results exactly as before. Co-authored-by: Orca * Bypass AI Vault scan cache on panel entry and refocus, throttled Non-force refreshes were served the 15s-old cached snapshot, so a session started right before re-entering the panel or refocusing the window still didn't appear — only the manual force refresh showed it. Panel entry and refocus now request a force scan, throttled in module scope to one forced scan per 5s (surviving panel remounts), so rapid tab/focus flips still resolve from the main-process cache. Manual force refreshes count against the throttle to avoid back-to-back full scans. Co-authored-by: Orca * Deliver AI Vault refocus via main-process signal; calm in-app triggers Renderer DOM focus/visibility events never fire on macOS app activation (verified live: document.hasFocus() stays true and no focus/blur event lands when the window loses/gains OS focus), so the refocus refresh was inert. Main now broadcasts browser-window-focus to the renderer as aiVault:windowFocused and the hook subscribes to that instead. Sessions started inside Orca (window never blurs) get their own trigger from agent hooks: re-scan only when an unseen provider session id appears in agent status. State transitions and message pings on known sessions are deliberately ignored — keying on them made the panel churn on every AI message. Event-driven rescans that land inside the 5s throttle window defer to one trailing scan instead of being dropped. Verified end-to-end against a dev instance: a headless claude session appeared in the panel on OS-level refocus and another on panel re-entry, without the manual refresh button. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/ai-vault.ts | 9 +- src/preload/api-types.ts | 2 + src/preload/index.ts | 7 +- .../ai-vault-session-refresh.test.ts | 304 ++++++++++++++++++ .../right-sidebar/ai-vault-session-refresh.ts | 146 ++++++++- src/renderer/src/web/web-preload-api.ts | 3 +- 6 files changed, 461 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 2a7188bf175..f96aa0b6d61 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { join } from 'node:path' import { scanAiVaultSessions } from '../ai-vault/session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' @@ -68,6 +68,13 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) + // DOM focus/visibility events don't fire in the renderer on macOS app + // activation, so refresh-on-refocus needs this main-process signal. + app.on('browser-window-focus', (_event, window) => { + if (!window.isDestroyed()) { + window.webContents.send('aiVault:windowFocused') + } + }) } async function getAiVaultWslHomeDirs(): Promise { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 139474b25d6..3f31cc560e2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -751,6 +751,8 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + /** Fires when any app window regains OS focus; returns an unsubscribe. */ + onWindowFocused: (callback: () => void) => () => void } export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } diff --git a/src/preload/index.ts b/src/preload/index.ts index e705586d4f6..9a162aa5473 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3612,7 +3612,12 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args) + ipcRenderer.invoke('aiVault:listSessions', args), + onWindowFocused: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('aiVault:windowFocused', listener) + return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) + } }, nativeChat: { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts new file mode 100644 index 00000000000..cdff27604d9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { useAppStore } from '@/store' +import { + resetAiVaultForcedRescanThrottleForTest, + useAiVaultSessionRefresh +} from './ai-vault-session-refresh' + +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' +} + +const THROTTLE_MS = 5_000 + +const listSessionsMock = vi.fn<(args: unknown) => Promise>() + +// Captures the hook's subscription to the main-process window-focus push. +let windowFocusCallback: (() => void) | null = null +const onWindowFocusedMock = vi.fn((callback: () => void) => { + windowFocusCallback = callback + return () => { + windowFocusCallback = null + } +}) + +async function fireWindowFocused(): Promise { + await act(async () => { + windowFocusCallback?.() + }) + await flushMicrotasks() +} + +const initialAppState = useAppStore.getInitialState() + +const roots: Root[] = [] +let latest: ReturnType | null = null + +function HookProbe(props: { scopePaths: readonly string[] }): null { + latest = useAiVaultSessionRefresh(props.scopePaths) + return null +} + +async function renderHook(scopePaths: readonly string[] = []): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { scopePaths })) + }) +} + +async function flushMicrotasks(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function dispatch(target: EventTarget, type: string): Promise { + await act(async () => { + target.dispatchEvent(new Event(type)) + }) + await flushMicrotasks() +} + +async function advance(ms: number): Promise { + await act(async () => { + vi.advanceTimersByTime(ms) + }) + await flushMicrotasks() +} + +function makeAgentEntry(sessionId: string, state = 'working'): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: `tab-${sessionId}:leaf-${sessionId}`, + stateHistory: [], + providerSession: { key: 'session_id', id: sessionId } + } as AgentStatusEntry +} + +async function setAgentStatuses(entries: Record): Promise { + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: entries }) + }) + await flushMicrotasks() +} + +function lastCallArgs(): unknown { + return listSessionsMock.mock.calls.at(-1)?.[0] +} + +beforeEach(() => { + vi.useFakeTimers() + listSessionsMock.mockReset().mockResolvedValue(EMPTY_RESULT) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim + ;(window as any).api = { + aiVault: { listSessions: listSessionsMock, onWindowFocused: onWindowFocusedMock } + } + resetAiVaultForcedRescanThrottleForTest() + useAppStore.setState(initialAppState, true) +}) + +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('useAiVaultSessionRefresh refocus behavior', () => { + it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + await renderHook() + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true }) + }) + + it('force re-scans on refocus once the throttle allows it', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Within the throttle window nothing runs yet — the event must not be + // dropped, so it lands as one trailing scan when the throttle frees up. + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores focus/visibility events while the document is hidden', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + await advance(THROTTLE_MS + 1) + await dispatch(document, 'visibilitychange') + await fireWindowFocused() + await advance(THROTTLE_MS + 1) + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('stops listening and cancels trailing scans after unmount', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Queue a trailing forced scan, then unmount before it fires. + await fireWindowFocused() + roots.splice(0).forEach((root) => act(() => root.unmount())) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('does not raise the loading flag for refocus refreshes', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + + let resolveScan: ((result: AiVaultListResult) => void) | null = null + listSessionsMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveScan = resolve)) + ) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(latest?.loading).toBe(false) + + await act(async () => { + resolveScan?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + expect(latest?.loading).toBe(false) + }) + + it('skips state updates when a refresh returns the applied snapshot', async () => { + await renderHook() + await flushMicrotasks() + const firstResult = latest?.scanResult + + // Same scannedAt = the snapshot already on screen was replayed. + listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).toBe(firstResult) + + listSessionsMock.mockResolvedValueOnce({ + ...EMPTY_RESULT, + scannedAt: '2026-07-01T00:00:02.000Z' + }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).not.toBe(firstResult) + }) + + it('keeps the manual refresh button forcing a cache bypass', async () => { + await renderHook() + await flushMicrotasks() + + await act(async () => { + await latest?.refresh({ force: true }) + }) + + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('counts a manual force refresh against the rescan throttle', async () => { + await renderHook() + await flushMicrotasks() + + await advance(THROTTLE_MS + 1) + await act(async () => { + await latest?.refresh({ force: true }) + }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // The button just scanned; an immediate refocus defers to trailing. + await fireWindowFocused() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('useAiVaultSessionRefresh in-app agent session behavior', () => { + it('force re-scans when an agent session starts inside Orca', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers an in-throttle session start to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores agent activity on already-known sessions', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // Message/tool pings and state transitions on a known session must not + // re-trigger — only a session id we haven't seen does. + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'done') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // A closed pane re-opening the same session is not a new session either. + await setAgentStatuses({}) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 8d9a7fdfd9d..98539f858ce 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,12 +1,35 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import { useAppStore } from '@/store' const SESSION_LIMIT = 500 +// Panel entry and window refocus must show sessions started since the last +// scan, so they bypass the main process's 15s cache — but a full scan parses +// up to ~1000 transcripts, so bound forced scans to one per interval. Module +// scope so the throttle survives panel remounts (the panel unmounts per tab). +const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +let lastForcedRescanAt = 0 + +function consumeForcedRescanBudget(): boolean { + const now = Date.now() + if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { + return false + } + lastForcedRescanAt = now + return true +} + +export function resetAiVaultForcedRescanThrottleForTest(): void { + lastForcedRescanAt = 0 +} + +type AiVaultRefreshArgs = { force?: boolean; background?: boolean } + export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { error: string | null loading: boolean - refresh: (args?: { force?: boolean }) => Promise + refresh: (args?: AiVaultRefreshArgs) => Promise scanResult: AiVaultListResult | null sessions: AiVaultSession[] } { @@ -18,25 +41,39 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) + const pendingBackgroundRef = useRef(true) + const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths - const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { + const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => { // A scope change during an in-flight scan must not be dropped; queue one more // scan so the current scoped view is refreshed after the older scan settles. if (refreshInFlightRef.current) { pendingRefreshRef.current = true pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } refreshInFlightRef.current = true const refreshId = refreshIdRef.current + 1 refreshIdRef.current = refreshId - setLoading(true) + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() + } + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) + } setError(null) + const scopeKey = scopePathsRef.current.join('\n') try { const result = await window.api.aiVault.listSessions({ limit: SESSION_LIMIT, @@ -46,6 +83,15 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (!mountedRef.current || refreshIdRef.current !== refreshId) { return } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scopeKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt } setScanResult(result) setSessions(result.sessions) } catch (err) { @@ -60,28 +106,114 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (pendingRefreshRef.current && mountedRef.current) { pendingRefreshRef.current = false const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current pendingForceRef.current = false - void refresh({ force }) + pendingBackgroundRef.current = true + void refresh({ force, background }) } } // Deps are intentionally empty: refresh reads changing values through refs // and recurses on itself, so its identity must stay stable. }, []) + // Forced rescans triggered by events (refocus, agent-session starts) run + // immediately when the throttle allows, otherwise once as soon as it frees + // up — dropping the event would leave a just-started session invisible + // until some unrelated later trigger. + const forcedRescanTimerRef = useRef | null>(null) + const requestForcedRescan = useCallback(() => { + const waitMs = lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() + if (waitMs <= 0) { + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + return + } + if (forcedRescanTimerRef.current !== null) { + return + } + forcedRescanTimerRef.current = setTimeout(() => { + forcedRescanTimerRef.current = null + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + }, waitMs) + }, [refresh]) + useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false refreshIdRef.current += 1 refreshInFlightRef.current = false + if (forcedRescanTimerRef.current !== null) { + clearTimeout(forcedRescanTimerRef.current) + forcedRescanTimerRef.current = null + } } }, []) // Re-scan on mount and whenever the active scope changes, since the scanner - // tailors its in-scope results to scopePaths. + // tailors its in-scope results to scopePaths. Force (throttled) so + // re-entering the panel shows sessions newer than the 15s cache; when the + // throttle denies it, paint from cache now and catch up once it frees. useEffect(() => { - void refresh() - }, [refresh, scopePathsKey]) + const force = consumeForcedRescanBudget() + void refresh({ force }) + if (!force) { + requestForcedRescan() + } + }, [refresh, requestForcedRescan, scopePathsKey]) + + // Sessions started while the app was backgrounded should appear when the + // user returns, so refocus also bypasses the scan cache (throttled). OS + // refocus arrives via the main process — renderer DOM focus events don't + // fire on macOS app activation; visibilitychange covers minimize-restore. + useEffect(() => { + const onRefocus = (): void => { + if (document.visibilityState !== 'visible') { + return + } + requestForcedRescan() + } + const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) + document.addEventListener('visibilitychange', onRefocus) + return () => { + unsubscribeWindowFocus?.() + document.removeEventListener('visibilitychange', onRefocus) + } + }, [requestForcedRescan]) + + // Sessions started inside Orca never blur the window, so refocus alone + // can't surface them. Agent hooks already report provider sessions; re-scan + // only when a session id we haven't seen appears — state transitions are + // deliberately ignored, they fire constantly while agents work. + const agentSessionIdsKey = useAppStore((s) => { + const ids: string[] = [] + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.providerSession?.id) { + ids.push(entry.providerSession.id) + } + } + return ids.sort().join('\n') + }) + const seenAgentSessionIdsRef = useRef | null>(null) + useEffect(() => { + const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + // The mount refresh already covers sessions live at mount time. + if (seenAgentSessionIdsRef.current === null) { + seenAgentSessionIdsRef.current = new Set(ids) + return + } + const seen = seenAgentSessionIdsRef.current + const freshIds = ids.filter((id) => !seen.has(id)) + if (freshIds.length === 0) { + return + } + for (const id of freshIds) { + seen.add(id) + } + requestForcedRescan() + }, [agentSessionIdsKey, requestForcedRescan]) return { error, loading, refresh, scanResult, sessions } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c7c472123a3..1da70b789e5 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -632,7 +632,8 @@ function createWebPreloadApi(): Partial { sessions: [], issues: [], scannedAt: new Date().toISOString() - }) + }), + onWindowFocused: () => () => {} }, preflight: createPreflightApi(), notifications: createNotificationsApi(), From b51dd802524bbeebbe60f66c3d560f7846497563 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:56:25 -0700 Subject: [PATCH 029/233] Skip no-op state writes and bound save postponement in persistence (#7092) * Skip no-op state writes and bound save postponement in persistence orca-data.json (1.6MB live) was fully rewritten (pretty-print + tmp + rename) on every debounced save even when the state content had not changed - measured 3 rewrites/min (~5MB/min) on an idle production instance, and a sync-flush storm of identical multi-MB writes at every warm start via persistPtyBinding re-binds. - Content-hash guard in both writers: a save whose plaintext state hash matches the last successful write skips serialize+write+rename entirely. Hashing plaintext (not the payload) because encrypt() uses a random IV per call. Safe under flushOrThrow's durability contract: a matching hash means the file already holds exactly this state. - Debounce 300ms -> 1s trailing with a 5s max-wait. The old timer reset on every mutation with no bound, so sustained sub-interval mutation bursts could postpone the write indefinitely; now staleness is capped at 5s while bursts coalesce. Co-authored-by: Orca * Guard the state-hash against sync-flush interleaving mid-rename From adversarial review: an async writer that had already passed its generation check and dispatched the rename could have a sync flush interleave during the await, write fresher state, and record its hash - the async continuation then clobbered lastWrittenStateHash with a value describing content NOT on disk, making later saves (including the quit flush) silently skip. Re-check writeGeneration before recording. Twin edge: a hash-matching sync flush could skip its write while a stale dispatched rename lands afterwards, leaving stale content unchallenged. flushOrThrow now forces the sync write whenever an async write chain was in flight at entry, restoring the sync-write-last disk ordering. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 130 ++++++++++++++++++++++++++++++++--- src/main/persistence.ts | 99 ++++++++++++++++++-------- 2 files changed, 190 insertions(+), 39 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d60033eac5e..b1856b1a8c4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -10,6 +10,7 @@ import { mkdirSync, existsSync, realpathSync, + statSync, symlinkSync } from 'node:fs' import { join } from 'node:path' @@ -4442,7 +4443,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) store.flush() - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) const persisted = readDataFile() as { repos: Repo[] } expect(persisted.repos).toHaveLength(1) @@ -4462,9 +4463,9 @@ describe('Store', () => { // Before the debounce fires, file should not exist yet (or be stale) vi.advanceTimersByTime(100) - // The 300ms debounce hasn't elapsed yet + // The 1s debounce hasn't elapsed yet - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) // The timer fired; wait for the async disk write to complete await store.waitForPendingWrite() @@ -4476,6 +4477,113 @@ describe('Store', () => { } }) + // ── Content-hash write skipping ──────────────────────────────────── + // Why inode comparison: every real write is a tmp+rename, which allocates a + // new inode. An unchanged inode proves no write happened. + + it('skips the disk write when a mutation burst nets out to already-persisted state', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + store.updateUI({ sidebarWidth: 400 }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const inoBefore = statSync(dataFile()).ino + + store.updateUI({ sidebarWidth: 500 }) + store.updateUI({ sidebarWidth: 400 }) + vi.advanceTimersByTime(2000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + } finally { + vi.useRealTimers() + } + }) + + it('skips the sync flush when state already matches the last write', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + store.updateUI({ sidebarWidth: 420 }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const inoBefore = statSync(dataFile()).ino + + store.flush() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + } finally { + vi.useRealTimers() + } + }) + + it('bounds save postponement under sustained mutation bursts (max-wait)', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + // Mutations every 500ms keep resetting the 1s trailing debounce; the + // 5s max-wait must force a write anyway. + let width = 400 + for (let i = 0; i < 11; i++) { + store.updateUI({ sidebarWidth: width++ }) + vi.advanceTimersByTime(500) + } + await store.waitForPendingWrite() + + expect(existsSync(dataFile())).toBe(true) + const persisted = readDataFile() as { ui: { sidebarWidth: number } } + expect(persisted.ui.sidebarWidth).toBeGreaterThanOrEqual(400) + } finally { + vi.useRealTimers() + } + }) + + it('re-binding an already-persisted pty does not rewrite the state file', async () => { + const store = await createStore() + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + }) + + const binding = { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'daemon-pty' + } + store.persistPtyBinding(binding) + const inoBefore = statSync(dataFile()).ino + + // The warm-restart re-bind storm: every restored terminal re-asserts an + // identical binding with a sync flush. Identical state must not rewrite. + store.persistPtyBinding(binding) + + expect(statSync(dataFile()).ino).toBe(inoBefore) + }) + // ── UI state ─────────────────────────────────────────────────────── it('updateUI merges partial updates', async () => { @@ -4518,7 +4626,7 @@ describe('Store', () => { tasks: { firstInteractedAt: 100, interactionCount: 1 } } }) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const persistedBefore = readFileSync(dataFile(), 'utf-8') store.onUIChanged((ui) => notifications.push(ui)) @@ -4532,7 +4640,7 @@ describe('Store', () => { tasks: { firstInteractedAt: 100, interactionCount: 1 } } }) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect(notifications).toEqual([]) @@ -8307,7 +8415,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ id: 'first-async' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const bak0AfterFirst = readBackup(0) @@ -8315,7 +8423,7 @@ describe('Store', () => { vi.setSystemTime(new Date(Date.now() + 5 * 60 * 1000)) store.addRepo(makeRepo({ id: 'within-hour-async', path: '/within-async' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const bak0AfterSecond = readBackup(0) @@ -8340,7 +8448,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ id: 'first-async' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect( @@ -8351,7 +8459,7 @@ describe('Store', () => { vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) store.addRepo(makeRepo({ id: 'after-hour-async', path: '/after-async' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() expect( @@ -8464,9 +8572,9 @@ describe('Store', () => { try { const store = await createStore() store.addRepo(makeRepo({ id: 'first' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) store.addRepo(makeRepo({ id: 'second', path: '/second' })) - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1000) await store.waitForPendingWrite() const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { repos: Repo[] } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 21b1ff28dc6..5d9ef610f48 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -16,7 +16,7 @@ import { import { writeFile, rename, mkdir, rm, copyFile } from 'node:fs/promises' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import type { Automation, AutomationCreateInput, @@ -2444,6 +2444,13 @@ export class Store { private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Why: hash of the plaintext state as of the last successful write. Saves + // triggered by mutations that net out to identical state skip the full + // 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the + // written payload) because encrypt() uses a random IV per call, so the + // on-disk bytes differ even for identical state. + private lastWrittenStateHash: string | null = null + private firstPendingSaveAt: number | null = null private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -3353,12 +3360,25 @@ export class Store { } } + // Why 1s trailing + 5s max-wait (previously 300ms trailing, unbounded): + // sustained sub-interval mutation bursts used to either rewrite the full + // multi-MB state ~3x/sec or postpone the write indefinitely by resetting + // the timer. The max-wait bounds crash staleness at 5s while bursts + // coalesce; the content-hash guard in the writers skips no-op payloads. + private static SAVE_DEBOUNCE_MS = 1_000 + private static SAVE_MAX_WAIT_MS = 5_000 + private scheduleSave(): void { + const now = Date.now() + this.firstPendingSaveAt ??= now if (this.writeTimer) { clearTimeout(this.writeTimer) } + const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) + const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) this.writeTimer = setTimeout(() => { this.writeTimer = null + this.firstPendingSaveAt = null // Why (issue #1158): serialize async writes so backup rotation never has // two callers racing over the same dataFile/tmp/.bak paths. const prev = this.pendingWrite ?? Promise.resolve() @@ -3373,7 +3393,7 @@ export class Store { } }) this.pendingWrite = next - }, 300) + }, delay) } /** Wait for any in-flight async disk write to complete. Used in tests. */ @@ -3383,15 +3403,14 @@ export class Store { } } - // Why: async writes avoid blocking the main Electron thread on every - // debounced save (every 300ms during active use). - private async writeToDiskAsync(): Promise { - const gen = this.writeGeneration - const dataFile = getDataFile() - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + private computeStateHash(): string { + return createHash('sha1').update(JSON.stringify(this.state)).digest('hex') + } + // Why: builds the on-disk payload synchronously so the hash and the + // serialized bytes reflect the same state tick (no mutation can interleave + // before an await). + private buildStateToSave(): string { // Why: secrets must be encrypted on disk. Clone state so the in-memory // this.state stays plaintext for the rest of the app. const stateToSave = { @@ -3406,13 +3425,31 @@ export class Store { browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) } } + return JSON.stringify(stateToSave, null, 2) + } + + // Why: async writes avoid blocking the main Electron thread on every + // debounced save during active use. + private async writeToDiskAsync(): Promise { + const gen = this.writeGeneration + const stateHash = this.computeStateHash() + // Why: a mutation burst that nets out to already-persisted state (or a + // flush that raced ahead) must not rewrite a byte-identical multi-MB file. + if (stateHash === this.lastWrittenStateHash) { + return + } + const payload = this.buildStateToSave() + const dataFile = getDataFile() + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` // Why: wrap write+rename in try/finally-on-error so any failure (ENOSPC, // ENFILE, EIO, permission) removes the tmp file rather than leaving a // multi-megabyte orphan behind. Successful rename consumes the tmp file. let renamed = false try { - await writeFile(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8') + await writeFile(tmpFile, payload, 'utf-8') // Why: if flush() ran while this async write was in-flight, it bumped // writeGeneration and already wrote the latest state synchronously. // Renaming this stale tmp file would overwrite the fresh data. @@ -3421,6 +3458,13 @@ export class Store { } await rename(tmpFile, dataFile) renamed = true + // Why the gen re-check: a sync flush can interleave during the rename + // await, write fresher state, and record its own hash. Recording this + // stale hash over it would make later saves skip against content that + // is not what the file holds. + if (this.writeGeneration === gen) { + this.lastWrittenStateHash = stateHash + } } finally { if (!renamed) { await rm(tmpFile).catch(() => {}) @@ -3439,7 +3483,16 @@ export class Store { // Why: synchronous variant kept only for flush() at shutdown, where the // process may exit before an async write completes. - private writeToDiskSync(): void { + private writeToDiskSync(opts: { force?: boolean } = {}): void { + const stateHash = this.computeStateHash() + // Why: skipping is safe under flushOrThrow's durability contract — a + // matching hash means this exact state is already the file's content. + // Except when an async write was in flight at flush entry (force): its + // rename may already be dispatched past the generation check, and only + // an unconditional sync write afterwards reliably out-orders it. + if (!opts.force && stateHash === this.lastWrittenStateHash) { + return + } const dataFile = getDataFile() const dir = dirname(dataFile) if (!existsSync(dir)) { @@ -3447,29 +3500,17 @@ export class Store { } const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - // Why: secrets must be encrypted on disk. Clone state so the in-memory - // this.state stays plaintext for the rest of the app. - const stateToSave = { - ...this.state, - settings: { - ...this.state.settings, - opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encrypt(this.state.settings.httpProxyUrl ?? '') - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalSecret(this.state.ui.browserKagiSessionLink) - } - } + const payload = this.buildStateToSave() // Why: mirror the async path — on any failure between writeFileSync and // renameSync, remove the tmp file so crashes during shutdown don't leak // orphans into userData. let renamed = false try { - writeFileSync(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8') + writeFileSync(tmpFile, payload, 'utf-8') renameSync(tmpFile, dataFile) renamed = true + this.lastWrittenStateHash = stateHash } finally { if (!renamed) { try { @@ -3490,11 +3531,13 @@ export class Store { clearTimeout(this.writeTimer) this.writeTimer = null } + this.firstPendingSaveAt = null + const asyncWriteWasInFlight = this.pendingWrite !== null // Why: bump writeGeneration so any in-flight async writeToDiskAsync skips // its rename, preventing a stale snapshot from overwriting this sync write. this.writeGeneration++ this.pendingWrite = null - this.writeToDiskSync() + this.writeToDiskSync({ force: asyncWriteWasInFlight }) } // ── Repos ────────────────────────────────────────────────────────── From e59c04122d848904d9d02b246e0cc24b1393fd31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:17:38 -0700 Subject: [PATCH 030/233] Fix Activity thread options trigger ref loop (#7096) --- .../activity/ActivityPrototypePage.tsx | 109 ++++++++++-------- .../ActivityThreadOptionsMenu.test.tsx | 65 +++++++++++ 2 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 38cd942c765..d296f5da471 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -873,6 +873,61 @@ function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element { ) } +export function ActivityThreadOptionsMenu({ + compactMode, + hasUnreadThreads, + onCompactModeChange, + onMarkAllThreadsRead +}: { + compactMode: boolean + hasUnreadThreads: boolean + onCompactModeChange: (compactMode: boolean) => void + onMarkAllThreadsRead: () => void +}): React.JSX.Element { + return ( + + + + {/* Why: keep Tooltip and Dropdown from composing refs onto the same + button; the crash report's stack loops through Radix setRef. */} + + + + + + + + {translate('auto.components.activity.ActivityPrototypePage.a472a14700', 'More options')} + + + + onCompactModeChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')} + + + + {translate('auto.components.activity.ActivityPrototypePage.023ff75afe', 'Mark all read')} + + + + ) +} + function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { if (!repo) { return null @@ -1823,54 +1878,12 @@ export default function ActivityPrototypePage(): React.JSX.Element { the toolbar focused on the high-frequency Filter + unread toggle while still giving the action a stable home next to the list it acts on (rather than the titlebar). */} - - - - - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.a472a14700', - 'More options' - )} - - - - setCompactMode(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.f70e4bec47', - 'Compact mode' - )} - - - markAllThreadsRead()} - disabled={!hasUnreadThreads} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.023ff75afe', - 'Mark all read' - )} - - - +
diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx new file mode 100644 index 00000000000..b5d786f3568 --- /dev/null +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { act, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { TooltipProvider } from '@/components/ui/tooltip' +import { ActivityThreadOptionsMenu } from './ActivityPrototypePage' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function Harness({ + compactMode = false, + hasUnreadThreads = true +}: { + compactMode?: boolean + hasUnreadThreads?: boolean +}): ReactElement { + return ( + + + + ) +} + +describe('ActivityThreadOptionsMenu', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('opens without recursively updating composed Radix trigger refs', async () => { + await act(async () => { + root.render() + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + + expect(trigger).not.toBeNull() + expect(trigger?.parentElement?.tagName).toBe('SPAN') + + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(document.body.textContent).toContain('Compact mode') + }) +}) From 4f0a141951fd91db413d90701ee3371667b3961e Mon Sep 17 00:00:00 2001 From: WONGIL Date: Thu, 2 Jul 2026 18:31:19 +0900 Subject: [PATCH 031/233] Fix mobile terminal Korean IME composition on Android (#7011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Korean IME composition by deferring live terminal preedit The mobile terminal capture field previously sent and cleared every TextInput change, which can break Hangul composition on Android keyboards. Introduce a small commit model and extracted live-input hook so composed text is flushed deliberately while ASCII remains immediate. Constraint: React Native TextInput has no portable composition event for this path; the fix uses a bounded commit delay for likely IME text. Rejected: Native-module IME integration | unnecessary for the confirmed JS dispatch/clear failure and higher maintenance risk. Confidence: high Scope-risk: moderate Directive: Keep terminal.send payload shape and buffered command input unchanged; do not claim physical Samsung Keyboard QA without device evidence. Tested: cd mobile && pnpm exec vitest run src/terminal/terminal-live-text-commit.test.ts src/terminal/terminal-live-input.test.ts src/terminal/terminal-text-input-normalization.test.ts src/terminal/terminal-keyboard-type.test.ts --reporter=verbose Tested: cd mobile && pnpm exec tsc --noEmit Tested: cd mobile && pnpm exec oxlint src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx Not-tested: Physical Galaxy Fold7/Samsung Keyboard and Android emulator/Gboard QA were unavailable; device probes recorded no attached Android device. * Preserve pending Korean IME text before mobile accessory controls Accessory keys share the same pending live-input commit gate as TextInput keypress and submit paths, so control bytes cannot race ahead of composed Hangul. Constraint: React Native mobile input does not expose portable composition events for Samsung/Gboard IME paths. Rejected: Let accessory buttons keep sending directly | Direct sends can drop pending Hangul before Tab/Esc/Enter/Backspace reaches the PTY. Confidence: high Scope-risk: narrow Directive: Keep all terminal control-byte paths behind the pending live-input flush/local-edit decision before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --cached --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA is still external-device only. * Prevent stale IME timer flushes after mobile terminal teardown Pending live-input timers now clear on hook unmount, and accessory Delete documents why it stays local without trimming pending IME text. Constraint: React Native TextInput lacks a portable composition lifecycle, so pending IME text is guarded by a bounded timer that must not survive screen teardown. Rejected: Use clearPendingLiveInputCommit during unmount | it would also touch React state/native props during teardown when only timer/ref cleanup is required. Confidence: high Scope-risk: narrow Directive: Any delayed terminal input commit must have an owner-lifecycle cleanup path before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec vitest run src/terminal/terminal-live-text-commit.test.ts --reporter=verbose; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/use-terminal-live-input-commit.ts; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Use semantic accessory edits for mobile IME commits Accessory Backspace/Delete now carry semantic local-edit intent from built-in keys instead of inferring intent from raw bytes, and submit handling is reconnected to the pure submit-sequence model. Constraint: Custom terminal accessory keys may produce the same bytes as built-ins but should still flush pending IME text before sending rather than being silently treated as hidden-input edits. Rejected: Classify local accessory edits by raw bytes | That couples future custom controls to current built-in byte encodings. Confidence: high Scope-risk: narrow Directive: Keep semantic input intent separate from terminal byte payloads when pending IME text is present. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Respect IME flush failures before control input Propagate terminal.send success from pending Korean IME text before sending Enter, Tab, or accessory bytes, while keeping custom no-pending accessory bytes on the original direct path. Constraint: PR #7011 review required follow-up control bytes only after the pending composed text send actually succeeds. Rejected: Treating send invocation as success | It can still reject or no-op when RPC state changed. Confidence: high Scope-risk: narrow Directive: Keep pending IME flush paths async-success-aware before adding new terminal control inputs. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; targeted no-excuse clean for mobile/src/terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Serialize mobile IME flushes before live controls Treat terminal.send as successful only when the RPC response is ok and the runtime send result is accepted, then route all live-input control sends through a shared in-flight pending-flush barrier. Constraint: PR #7011 review found that resolved RPC promises and per-call sequencing were not enough to prove pending Hangul text reached the PTY before follow-up controls. Rejected: Only awaiting each flush-then-send call | Repeatable accessory keys and no-pending sends can arrive while the first flush is still in flight. Confidence: high Scope-risk: moderate Directive: Keep future mobile terminal control paths behind the pending-flush barrier whenever IME text may be in flight. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Queue current IME snapshots behind active flushes Drain the pending snapshot captured by a control action after any already-active terminal send, and make accessory commit handling explicit so raw fallback is not encoded as an inverted boolean. Constraint: Architecture review found the previous single-slot barrier could wait for an older flush while skipping newly pending Hangul text. Rejected: Reusing the prior in-flight promise as the current flush result | It proves only an older snapshot, not the current pending buffer. Confidence: high Scope-risk: narrow Directive: New mobile terminal control paths must distinguish allow-raw, handled, and suppress-raw outcomes explicitly. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Preserve accessory raw-send terminal targets Capture the terminal handle at accessory keypress time and suppress raw fallback if the active live terminal changes while waiting for pending IME flushes. Constraint: Independent review found raw accessory bytes could retarget to a different terminal after an async IME flush barrier. Rejected: Re-reading activeHandleRef as the send target after await | It can point at a different terminal than the keypress belonged to. Confidence: high Scope-risk: narrow Directive: Raw accessory fallback must use the keypress-time target and revalidate it after any await. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Document accessory flush barrier intent Make the non-obvious raw accessory wait/suppress behavior explicit so future changes preserve IME-before-control ordering. Constraint: CodeRabbit requested a why-comment for the send-now accessory branch. Rejected: Leaving the barrier semantics implicit | The branch can otherwise look like unnecessary async defensive code. Confidence: high Scope-risk: narrow Directive: Keep comments focused on why raw accessory bytes wait behind IME flushes. Tested: targeted terminal vitest suite; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt check for changed file. Not-tested: Physical Galaxy Fold7 Samsung keyboard. * Preserve buffered accessory raw sends Keep the stale-handle guard focused on the captured active terminal instead of live-input opt-in state, so buffered mode keeps existing accessory key behavior while async live-input waits still cannot retarget to another terminal. Constraint: Buffered command input behavior must remain unchanged while fixing mobile Korean IME live input ordering. Rejected: Requiring live-input enabled handles for raw accessory fallback | suppresses valid buffered-mode accessory sends. Confidence: high Scope-risk: narrow Directive: Do not use live-input opt-in state as terminal liveness for raw accessory sends; validate captured target, active terminal tab, connection, and client instead. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep Hangul IME text pending until explicit flush Avoid timer-driven PTY writes for Hangul candidates so paused Korean composition cannot leak intermediate jamo, while preserving the bounded settle timer for non-Hangul IME text. Also keep disabled live-input accessory fallback behind any existing pending flush barrier. Constraint: React Native TextInput does not expose a portable composition lifecycle on this mobile surface. Rejected: Fixed 150ms auto-flush for Hangul | can emit ㅎ or 하 if the user pauses mid-composition. Confidence: high Scope-risk: narrow Directive: Treat Hangul candidates as pending until submit/control/accessory flush; do not reintroduce idle timer commits for Hangul without device-level composition evidence. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Gate dictation toast on accepted live send Honor the async live-input sender contract so the mobile UI reports dictation insertion only after terminal.send is accepted. Constraint: sendLiveTerminalInput now returns false for stale, disconnected, oversized, or rejected terminal sends. Rejected: Toasting immediately after dispatch | reports success for sends that never reached the PTY. Confidence: high Scope-risk: narrow Directive: Treat live-input UI success as terminal.send acceptance, not request dispatch. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check app/h/[hostId]/session/[worktreeId].tsx. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep accessory edits on Hangul pending path Make accessory local edits reuse the Hangul-aware defer policy so built-in Backspace/Delete cannot reintroduce timer-driven Hangul PTY writes. Constraint: Hangul IME candidates must remain pending until explicit submit/control/accessory flush. Rejected: Reusing the non-Hangul 150ms settle timer for accessory local edits | can leak pending Hangul after Backspace/Delete. Confidence: high Scope-risk: narrow Directive: Any future pending-text reschedule must use getTerminalLiveDeferredTextDelayMs instead of a hardcoded timer. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Prove Hangul live-input hook ordering Add a direct hook-level regression so Android Korean IME fixes are covered at the orchestration boundary, not only by lower-level helpers. Constraint: React Native mobile TextInput lacks portable composition lifecycle events in this path. Rejected: Relying only on helper tests | misses hook-level pending flush and submit ordering. Confidence: high Scope-risk: narrow Directive: Keep Hangul candidates pending until an explicit terminal action flushes them. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; no-excuse on terminal modules Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep accessory raw-send tests precise Remove a duplicate raw-target assertion whose title implied disabled live-input behavior that is covered at the accessory commit boundary instead. Constraint: Anti-slop cleanup must preserve existing Hangul/accessory behavior and stay within changed terminal tests. Rejected: Keeping the duplicate disabled-input wording | it tests the same active-terminal predicate as the preceding case. Confidence: high Scope-risk: narrow Directive: Test disabled live-input buffering in the accessory commit layer, not in the raw-target predicate helper. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Explain stale mobile terminal send gates Document why async IME flush paths re-check terminal/client refs before sending raw bytes or reporting live-send success. Constraint: CodeRabbit review requested short why comments for non-obvious stale-send safety gates. Rejected: Leaving the gates undocumented | future edits could remove the stale-target suppression contract. Confidence: high Scope-risk: narrow Directive: Keep async terminal sends guarded by current client, active handle, tab type, and connection state. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Run mobile IME hook tests through effects Move the Hangul live-input hook regression from server rendering to react-test-renderer so effect cleanup and unmount timer cancellation are exercised. Constraint: @testing-library/react-native imports React Native's Flow entry under this Vitest setup, so the narrow effect-running renderer is the compatible test surface. Rejected: Keeping renderToString | it never runs useEffect cleanup and missed the pending timer cleanup path. Rejected: Adding @testing-library/react-native directly | it failed before tests with React Native Flow syntax under the current Vitest transform. Confidence: high Scope-risk: narrow Directive: Hook-level IME tests must use a renderer that runs effects when asserting pending flush cleanup. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep hook lifecycle tests quiet Suppress only the react-test-renderer deprecation warning around the effect-running hook harness so real console errors still surface. Constraint: CodeRabbit flagged React 19 renderer warning noise; @testing-library/react-native remains incompatible with the current Vitest/RN Flow transform path. Rejected: Global console silencing | it would hide unrelated test failures. Confidence: high Scope-risk: narrow Directive: Keep the renderer warning suppression scoped to this hook harness and pass all other console errors through. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * fix: flush pending mobile IME input before external sends * fix: guard terminal command finished event dispatch --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../app/h/[hostId]/session/[worktreeId].tsx | 326 +++++++----------- mobile/package.json | 2 + mobile/pnpm-lock.yaml | 63 +++- .../session/mobile-image-attachment.test.ts | 26 ++ mobile/src/session/mobile-image-attachment.ts | 7 +- .../session/use-mobile-image-attachment.ts | 8 +- .../src/session/use-mobile-terminal-paste.ts | 200 +++++++++++ .../terminal/terminal-live-accessory-input.ts | 21 ++ ...nal-live-accessory-raw-send-target.test.ts | 49 +++ ...terminal-live-accessory-raw-send-target.ts | 17 + .../terminal-live-control-send-order.test.ts | 74 ++++ .../terminal-live-control-send-order.ts | 12 + .../terminal/terminal-live-input-sender.ts | 1 + .../terminal-live-pending-flush-state.test.ts | 146 ++++++++ .../terminal-live-pending-flush-state.ts | 29 ++ .../terminal-live-text-commit.test.ts | 234 +++++++++++++ .../src/terminal/terminal-live-text-commit.ts | 145 ++++++++ .../terminal-send-rpc-response.test.ts | 53 +++ .../terminal/terminal-send-rpc-response.ts | 15 + ...rminal-live-accessory-input-commit.test.ts | 52 +++ ...se-terminal-live-accessory-input-commit.ts | 124 +++++++ .../use-terminal-live-input-commit.test.ts | 184 ++++++++++ .../use-terminal-live-input-commit.ts | 236 +++++++++++++ .../use-terminal-live-pending-input-flush.ts | 139 ++++++++ .../hooks/terminal-command-finished-event.ts | 5 + 25 files changed, 1945 insertions(+), 223 deletions(-) create mode 100644 mobile/src/session/use-mobile-terminal-paste.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-input.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.test.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.ts create mode 100644 mobile/src/terminal/terminal-live-input-sender.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.test.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.test.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.test.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-pending-input-flush.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1912f7a04a4..3166645727e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,8 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' -import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' -import { File as FsFile, Paths } from 'expo-file-system' import { BackHandler, FlatList, @@ -95,14 +93,18 @@ import { getVisibleTerminalAccessoryKeys, loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' +import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' +import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, - getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' +import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -162,13 +164,8 @@ import { type MobileNewTabAgentOption, type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' -import { - buildMobileImagePastePayload, - prepareMobileClipboardImageBase64, - saveMobileClipboardImageAsTempFile, - type MobileClipboardImageResizer -} from '../../../../src/session/mobile-clipboard-image' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -233,52 +230,9 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i -const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 +type TerminalLiveAccessoryInput = ReturnType -// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and -// photos can exceed the upload byte budget; resize the raster down to fit before upload. -// The image is staged to a temp file first because the iOS ImageManipulator loader -// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI. -const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { - const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') - const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) - let context: ReturnType | null = null - let rendered: Awaited< - ReturnType['renderAsync']> - > | null = null - let resultUri: string | null = null - try { - file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) - context = ImageManipulator.manipulate(file.uri) - context.resize({ width: target.width, height: target.height }) - rendered = await context.renderAsync() - const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) - resultUri = result.uri - // Why: empty base64 would pass the downstream base64 check and upload a corrupt - // image, so fail loudly here instead of silently sending an invalid payload. - if (!result.base64) { - throw new Error('Failed to encode resized clipboard image') - } - return { data: result.base64, width: result.width, height: result.height } - } finally { - rendered?.release() - context?.release() - if (resultUri) { - try { - new FsFile(resultUri).delete() - } catch { - // Best-effort cleanup; ImageManipulator saves into cache for every retry. - } - } - try { - file.delete() - } catch { - // Best-effort cleanup; the OS reclaims the cache directory regardless. - } - } -} +const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function getActiveTabIdForHandle( tabs: MobileSessionTab[], @@ -1034,6 +988,7 @@ export default function SessionScreen() { const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) + const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null) @@ -1068,6 +1023,7 @@ export default function SessionScreen() { // render where client was still null/connecting, silently no-opping the // in-app-browser open). Route through a ref kept current every render. const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) @@ -1094,6 +1050,24 @@ export default function SessionScreen() { const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null + const { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: activeSessionTab?.type, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) const canSend = connState === 'connected' && activeHandle != null && @@ -1244,8 +1218,16 @@ export default function SessionScreen() { if (!insertHandle) { return } - sendLiveTerminalInput(insertHandle, route.text) - showToast('Dictation inserted') + void (async () => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle) + if (!flushedPendingInput) { + return + } + const sent = await sendLiveTerminalInput(insertHandle, route.text) + if (sent) { + showToast('Dictation inserted') + } + })() return } setInput((current) => appendBufferedDictation(current, route.text)) @@ -2680,7 +2662,7 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) - setLiveInputCapture('') + clearPendingLiveInputCommit() liveInputTerminalHandlesRef.current = new Set() defaultedLiveInputTerminalHandlesRef.current = new Set() setLiveInputTerminalHandles(new Set()) @@ -2690,9 +2672,16 @@ export default function SessionScreen() { return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + clearPendingLiveInputCommit() clearDelayedActionTimers() } - }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId]) + }, [ + clearDelayedActionTimers, + clearPendingLiveInputCommit, + clearTerminalCache, + hostId, + worktreeId + ]) useEffect(() => { if (connState !== 'connected') { @@ -3097,46 +3086,62 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(bytes: string) { + async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { if (!client || !activeHandle || !canSend) { return } - - try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: bytes, + const targetHandle = activeHandle + const accessoryCommit = await handleLiveInputAccessoryBytes(input) + if (accessoryCommit.kind !== 'allow-raw') { + return + } + const currentClient = clientRef.current + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: activeHandleRef.current, + activeSessionTabType: activeSessionTabTypeRef.current + }) + if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { + return + } + await currentClient + .sendRequest('terminal.send', { + terminal: rawSendTarget, + text: input.bytes, enter: false, ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - } catch { - // Transient failure - } + .then( + () => undefined, + () => undefined + ) } const sendLiveTerminalInput = useCallback( - (handle: string, bytes: string) => { + async (handle: string, bytes: string): Promise => { const text = normalizeTerminalTextInput(bytes) if (text.length === 0) { - return + return false } if (!isTerminalLiveInputWithinByteLimit(text)) { triggerError() showToast('Input too large (max 256 KiB)', 1500) - return + return false } const rpc = clientRef.current + // Why: callers suppress follow-up controls/toasts when this live send is stale. if ( !rpc || connStateRef.current !== 'connected' || handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal' ) { - return + return false } - void rpc + return rpc .sendRequest('terminal.send', { terminal: handle, text, @@ -3145,12 +3150,11 @@ export default function SessionScreen() { ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - .catch(() => { - // Transient failure - }) + .then(isTerminalSendRpcAccepted, () => false) }, [showToast] ) + sendLiveTerminalInputRef.current = sendLiveTerminalInput const focusLiveInput = useCallback(() => { if (!canSend || !liveInputEnabled) { @@ -3387,70 +3391,14 @@ export default function SessionScreen() { liveInputTerminalHandlesRef.current = next return next }) - setLiveInputCapture('') + clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) } else { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, liveInputTerminalHandles]) - - const handleLiveInputChange = useCallback( - (text: string) => { - if (!activeHandle) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - const normalizedText = normalizeTerminalTextInput(text) - if (normalizedText.length > 0) { - sendLiveTerminalInput(activeHandle, normalizedText) - } - setLiveInputCapture('') - // Why: the field is only a keyboard capture surface. Clearing the - // native value prevents subsequent phone-keyboard events from replaying - // already-sent characters when React state remains the empty string. - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputKeyPress = useCallback( - (event: { nativeEvent: { key: string } }) => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) - if (!bytes) { - return - } - sendLiveTerminalInput(activeHandle, bytes) - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputSubmit = useCallback(() => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - sendLiveTerminalInput(activeHandle, '\r') - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { @@ -3650,11 +3598,11 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (bytes: string) => { + (input: TerminalLiveAccessoryInput) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { - void handleAccessoryKeyRef.current(bytes) + void handleAccessoryKeyRef.current(input) }, 45) }, 400) }, @@ -3673,11 +3621,13 @@ export default function SessionScreen() { clearToastHideTimer() clearDelayedActionTimers() clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) + clearPendingLiveInputCommit() sessionTabActionSheetRequestSeqRef.current += 1 clearSessionTabActionSheetKeyboardListener() stopAccessoryRepeat() }, [ + clearPendingLiveInputCommit, clearDelayedActionTimers, clearSessionTabActionSheetKeyboardListener, clearTerminalCache, @@ -3801,84 +3751,38 @@ export default function SessionScreen() { }) }, []) - const handlePaste = useCallback(async () => { - if (!client || !activeHandle || !canSend) { - return - } - try { - const text = await Clipboard.getStringAsync() - let payload: string | null = null - if (text.length > 0) { - const modes = ptyModesRef.current.get(activeHandle) || { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - } - const wrap = modes.bracketedPasteMode && !modes.altScreen - // Why: strip embedded bracketed-paste markers from clipboard text so a - // malicious copy containing `\x1b[201~` can't terminate paste mode early - // and have the trailing bytes interpreted as shell commands. Matches - // xterm.js / iTerm2 behavior. - // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping - const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text - payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized - } else { - const image = await Clipboard.getImageAsync({ format: 'png' }) - if (!image) { - refreshCanPaste() - return - } - const connectionId = await getActiveWorktreeConnectionId() - const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) - const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { - connectionId - }) - payload = buildMobileImagePastePayload(imagePath) - } - - const wrappedBytes = new TextEncoder().encode(payload).byteLength - if (wrappedBytes > 256 * 1024) { - triggerError() - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste oversized', { wrappedBytes }) - showToast('Paste too large (max 256 KiB)', 1500) - return - } - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: payload, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - triggerSelection() - refreshCanPaste() - } catch (e) { - triggerError() - const err = e as { name?: string; message?: string } - const isDisconnected = connState !== 'connected' - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) - if (isDisconnected) { - showToast('Paste failed (disconnected)', 1500) - } else if (err.message === 'Clipboard image is too large') { - showToast('Image too large to paste', 1500) - } else { - showToast('Paste failed', 1500) - } - } - }, [ + const handlePaste = useMobileTerminalPaste({ client, activeHandle, + activeHandleRef, + activeSessionTabTypeRef, canSend, connState, + connStateRef, + clientRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, getActiveWorktreeConnectionId, + onError: triggerError, + onSuccess: triggerSelection, + ptyModesRef, refreshCanPaste, showToast - ]) + }) + + const flushPendingLiveInputBeforeAttachmentSend = useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + return ( + flushedPendingInput && + connStateRef.current === 'connected' && + targetHandle === activeHandleRef.current && + activeSessionTabTypeRef.current === 'terminal' + ) + }, + [flushPendingLiveInputBeforeExternalSend] + ) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -3886,6 +3790,7 @@ export default function SessionScreen() { canSend, connState, deviceTokenRef, + beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, getActiveWorktreeConnectionId, showToast, onSuccess: triggerSelection, @@ -5046,8 +4951,9 @@ export default function SessionScreen() { if (!key.repeatable) { return } - void handleAccessoryKey(key.bytes) - startAccessoryRepeat(key.bytes) + const input = createTerminalLiveAccessoryInput(key) + void handleAccessoryKey(input) + startAccessoryRepeat(input) }} onPressOut={() => { if (key.repeatable) { @@ -5058,7 +4964,7 @@ export default function SessionScreen() { if (key.repeatable) { return } - void handleAccessoryKey(key.bytes) + void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) }} accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} > @@ -5082,7 +4988,7 @@ export default function SessionScreen() { !canSend && styles.accessoryKeyDisabled ]} disabled={!canSend} - onPress={() => void handleAccessoryKey(key.bytes)} + onPress={() => void handleAccessoryKey({ bytes: key.bytes })} onLongPress={() => { triggerMediumImpact() setDeleteKeyTarget(key) diff --git a/mobile/package.json b/mobile/package.json index 734519e9747..9d75502765f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -60,10 +60,12 @@ "devDependencies": { "@types/react": "^19.2.14", "@types/react-native": "^0.73.0", + "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", "expo-module-scripts": "^55.0.2", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", + "react-test-renderer": "19.2.6", "tsx": "^4.22.4", "typescript": "^5.9.3", "vite": "^8.0.16", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7785a4d05c6..aecf79fe0ee 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-router: specifier: ^55.0.14 - version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) expo-secure-store: specifier: ^55.0.13 version: 55.0.13(expo@55.0.23) @@ -135,18 +135,24 @@ importers: '@types/react-native': specifier: ^0.73.0 version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) oxfmt: specifier: ^0.52.0 version: 0.52.0 oxlint: specifier: ^1.71.0 version: 1.71.0 + react-test-renderer: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -1724,48 +1730,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -1838,48 +1852,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2341,36 +2363,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4632,24 +4660,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5424,6 +5456,11 @@ packages: peerDependencies: react: ^19.2.0 + react-test-renderer@19.2.6: + resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} + peerDependencies: + react: ^19.2.6 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7825,7 +7862,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -8093,7 +8130,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -9055,14 +9092,14 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - react-test-renderer: 19.2.0(react@19.2.6) + react-test-renderer: 19.2.6(react@19.2.6) redent: 3.0.0 optionalDependencies: jest: 29.7.0(@types/node@25.9.3) @@ -10651,7 +10688,7 @@ snapshots: expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10659,7 +10696,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -10734,7 +10771,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e): + expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -10771,7 +10808,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -12948,6 +12985,12 @@ snapshots: react-is: 19.2.6 scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.6 + scheduler: 0.27.0 + react@19.2.6: {} readdirp@3.6.0: diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 3a94348aab4..1bf3df59e74 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => { const sendCall = client.calls.find((c) => c.method === 'terminal.send') expect(sendCall?.params).not.toHaveProperty('client') }) + + it('waits for pending live input before sending the image payload', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/pending.png') + ]) + const beforeTerminalSend = vi.fn(async () => false) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-pending', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }), + beforeTerminalSend + }) + + expect(sent).toBe(false) + expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') + expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 507b58eb671..b7d14d9a04d 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -16,6 +16,7 @@ export type AttachMobileImageDeps = { // start — lets the UI show a sending spinner only for the transfer, not the // (potentially long) time the picker is open. readonly onUploadStart?: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } // Uploads a picked image to the host and pastes the resulting file path into the @@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal( deviceToken, getConnectionId, pickImage, - onUploadStart + onUploadStart, + beforeTerminalSend }: AttachMobileImageDeps ): Promise { const picked = await pickImage(source) @@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal( // Why: a generated image path is terminal image injection, so it's always // bracketed (matching desktop paste) regardless of terminal mode. const payload = buildMobileImagePastePayload(imagePath) + if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { + return false + } await client.sendRequest('terminal.send', { terminal, text: payload, diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 3894928f4f5..417dd8d699b 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = { readonly showToast: ShowToast readonly onSuccess: () => void readonly onError: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } type MobileImageAttachment = { @@ -46,7 +47,8 @@ export function useMobileImageAttachment({ getActiveWorktreeConnectionId, showToast, onSuccess, - onError + onError, + beforeTerminalSend }: UseMobileImageAttachmentArgs): MobileImageAttachment { const [isAttaching, setIsAttaching] = useState(false) const attachImage = useCallback( @@ -61,7 +63,8 @@ export function useMobileImageAttachment({ deviceToken: deviceTokenRef.current, getConnectionId: getActiveWorktreeConnectionId, pickImage: pickMobileImage, - onUploadStart: () => setIsAttaching(true) + onUploadStart: () => setIsAttaching(true), + beforeTerminalSend }) // Cancelled picker: no error, no toast. if (sent) { @@ -88,6 +91,7 @@ export function useMobileImageAttachment({ }, [ activeHandle, + beforeTerminalSend, canSend, client, connState, diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts new file mode 100644 index 00000000000..e3148be67f0 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -0,0 +1,200 @@ +import { useCallback, type RefObject } from 'react' +import * as Clipboard from 'expo-clipboard' +import { File as FsFile, Paths } from 'expo-file-system' +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' +import type { TerminalModes } from '../terminal/TerminalWebView' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + buildMobileImagePastePayload, + prepareMobileClipboardImageBase64, + saveMobileClipboardImageAsTempFile, + type MobileClipboardImageResizer +} from './mobile-clipboard-image' + +const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i + +// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and +// photos can exceed the upload byte budget; resize the raster down to fit before upload. +// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file. +const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { + const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') + const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) + let context: ReturnType | null = null + let rendered: Awaited< + ReturnType['renderAsync']> + > | null = null + let resultUri: string | null = null + try { + file.create({ overwrite: true }) + file.write(base64, { encoding: 'base64' }) + context = ImageManipulator.manipulate(file.uri) + context.resize({ width: target.width, height: target.height }) + rendered = await context.renderAsync() + const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) + resultUri = result.uri + // Why: empty base64 would pass the downstream base64 check and upload a corrupt + // image, so fail loudly here instead of silently sending an invalid payload. + if (!result.base64) { + throw new Error('Failed to encode resized clipboard image') + } + return { data: result.base64, width: result.width, height: result.height } + } finally { + rendered?.release() + context?.release() + if (resultUri) { + try { + new FsFile(resultUri).delete() + } catch { + // Best-effort cleanup; ImageManipulator saves into cache for every retry. + } + } + try { + file.delete() + } catch { + // Best-effort cleanup; the OS reclaims the cache directory regardless. + } + } +} + +function buildMobileTerminalClipboardTextPayload( + text: string, + modes: TerminalModes | undefined +): string { + const wrap = modes?.bracketedPasteMode === true && !modes.altScreen + // Why: strip embedded bracketed-paste markers so copied text cannot terminate + // paste mode early and turn trailing bytes into shell commands. + // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping + const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text + return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized +} + +type UseMobileTerminalPasteOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly canSend: boolean + readonly client: RpcClient | null + readonly clientRef: RefObject + readonly connState: ConnectionState + readonly connStateRef: RefObject + readonly deviceTokenRef: RefObject + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getActiveWorktreeConnectionId: () => Promise + readonly onError: () => void + readonly onSuccess: () => void + readonly ptyModesRef: RefObject> + readonly refreshCanPaste: () => void + readonly showToast: (message: string, durationMs?: number) => void +} + +export function useMobileTerminalPaste({ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast +}: UseMobileTerminalPasteOptions): () => Promise { + return useCallback(async () => { + if (!client || !activeHandle || !canSend) { + return + } + const targetHandle = activeHandle + try { + const text = await Clipboard.getStringAsync() + let payload: string | null = null + if (text.length > 0) { + payload = buildMobileTerminalClipboardTextPayload( + text, + ptyModesRef.current.get(targetHandle) + ) + } else { + const image = await Clipboard.getImageAsync({ format: 'png' }) + if (!image) { + refreshCanPaste() + return + } + const connectionId = await getActiveWorktreeConnectionId() + const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) + const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { + connectionId + }) + payload = buildMobileImagePastePayload(imagePath) + } + + const wrappedBytes = new TextEncoder().encode(payload).byteLength + if (wrappedBytes > 256 * 1024) { + onError() + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste oversized', { wrappedBytes }) + showToast('Paste too large (max 256 KiB)', 1500) + return + } + // Why: paste lives in the accessory row and must not overtake pending IME text. + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + if (!flushedPendingInput) { + return + } + const currentClient = clientRef.current + if ( + !currentClient || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + await currentClient.sendRequest('terminal.send', { + terminal: targetHandle, + text: payload, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + onSuccess() + refreshCanPaste() + } catch (e) { + onError() + const err = e as { name?: string; message?: string } + const isDisconnected = connState !== 'connected' + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) + if (isDisconnected) { + showToast('Paste failed (disconnected)', 1500) + } else if (err.message === 'Clipboard image is too large') { + showToast('Image too large to paste', 1500) + } else { + showToast('Paste failed', 1500) + } + } + }, [ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast + ]) +} diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts new file mode 100644 index 00000000000..5f483ebc0cf --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-input.ts @@ -0,0 +1,21 @@ +import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit' + +export type TerminalLiveAccessoryInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit +} + +type TerminalLiveAccessoryKey = { + readonly bytes: string + readonly id: string +} + +export function createTerminalLiveAccessoryInput( + key: TerminalLiveAccessoryKey +): TerminalLiveAccessoryInput { + if (key.id === 'backspace' || key.id === 'delete') { + return { bytes: key.bytes, localEdit: key.id } + } + + return { bytes: key.bytes } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts new file mode 100644 index 00000000000..5e484c354bc --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' + +describe('terminal live accessory raw send target', () => { + it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBe(targetHandle) + }) + + it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: 'terminal-b', + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBeNull() + }) + + it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'browser' + }) + + // Then + expect(inactiveTabTarget).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts new file mode 100644 index 00000000000..f87138d210e --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts @@ -0,0 +1,17 @@ +type TerminalLiveAccessoryRawSendTargetInput = { + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: TTabType | null +} + +export function getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle, + activeSessionTabType +}: TerminalLiveAccessoryRawSendTargetInput): string | null { + if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') { + return null + } + + return targetHandle +} diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts new file mode 100644 index 00000000000..8f1797c7ca0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' + +describe('terminal live control send order', () => { + it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return false + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(sendControlBytes).not.toHaveBeenCalled() + expect(events).toEqual(['flush']) + }) + + it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(true) + expect(events).toEqual(['flush', 'control']) + }) + + it('Given a failed control byte send When pending text flushed Then reports failure', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return false + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(events).toEqual(['flush', 'control']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts new file mode 100644 index 00000000000..185b49ee48e --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.ts @@ -0,0 +1,12 @@ +export type TerminalLiveAsyncSendStep = () => Promise + +export async function sendTerminalLiveControlAfterPendingFlush( + flushPendingText: TerminalLiveAsyncSendStep, + sendControlBytes: TerminalLiveAsyncSendStep +): Promise { + const flushed = await flushPendingText() + if (!flushed) { + return false + } + return sendControlBytes() +} diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts new file mode 100644 index 00000000000..c3538d1f723 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-sender.ts @@ -0,0 +1 @@ +export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts new file mode 100644 index 00000000000..27dd4147d09 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush, + type TerminalLivePendingFlushState +} from './terminal-live-pending-flush-state' + +describe('terminal live pending flush state', () => { + it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When / Then + await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) + }) + + it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFlush(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['control']) + }) + + it('Given an in-flight flush fails When control input waits Then control is skipped', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFlush(false) + + // Then + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) + + it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => { + // Given + const resolvedState: TerminalLivePendingFlushState = { current: null } + const rejectedState: TerminalLivePendingFlushState = { current: null } + + // When + await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) + await expect( + queueTerminalLivePendingFlush(rejectedState, async () => { + throw new Error('send failed') + }) + ).resolves.toBe(false) + await Promise.resolve() + + // Then + expect(resolvedState.current).toBeNull() + expect(rejectedState.current).toBeNull() + }) + + it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFirstFlush(true) + await expect(secondFlush).resolves.toBe(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['second-flush', 'control']) + }) + + it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFirstFlush(false) + + // Then + await expect(secondFlush).resolves.toBe(false) + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts new file mode 100644 index 00000000000..ea760fcee54 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -0,0 +1,29 @@ +export type TerminalLivePendingFlushState = { + current: Promise | null +} + +export function waitForTerminalLivePendingFlush( + state: TerminalLivePendingFlushState +): Promise { + return state.current ?? Promise.resolve(true) +} + +export function queueTerminalLivePendingFlush( + state: TerminalLivePendingFlushState, + sendPendingText: () => Promise +): Promise { + const previousFlush = state.current + const flushPromise = (async () => { + if (previousFlush && !(await previousFlush)) { + return false + } + return sendPendingText() + })().catch(() => false) + state.current = flushPromise + void flushPromise.then(() => { + if (state.current === flushPromise) { + state.current = null + } + }) + return flushPromise +} diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts new file mode 100644 index 00000000000..00435fb2230 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS, + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs, + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision, + isTerminalLiveTextHangulCandidate, + isTerminalLiveTextImeCandidate +} from './terminal-live-text-commit' + +describe('terminal live text commit', () => { + it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => { + // Given + const koreanCompositionSteps = ['ㅎ', '하', '한'] as const + + // When + const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision) + const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now') + const submitSequence = getTerminalLiveSubmitSequence('한') + const composedWordDecision = getTerminalLiveTextChangeDecision('한글') + const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글') + + // Then + expect(decisions).toEqual([ + { kind: 'defer', text: 'ㅎ', delayMs: null }, + { kind: 'defer', text: '하', delayMs: null }, + { kind: 'defer', text: '한', delayMs: null } + ]) + expect(sentImmediately).toEqual([]) + expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true) + expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull() + expect(submitSequence).toEqual(['한', '\r']) + expect(composedWordDecision).toEqual({ + kind: 'defer', + text: '한글', + delayMs: null + }) + expect(composedWordSubmitSequence).toEqual(['한글', '\r']) + }) + + it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => { + // Given + const text = 'あ' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }) + }) + + it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => { + // Given + const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const + + for (const text of nonHangulImeTexts) { + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ + kind: 'defer', + text, + delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS + }) + } + }) + + it('Given ASCII text When live text changes Then sends immediately', () => { + // Given + const text = 'abc123' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'send-now', text }) + }) + + it('Given empty text When live text changes Then ignores the change', () => { + // Given + const text = '' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => { + // Given + const pendingText = '한' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit' }) + expect(deleteDecision).toEqual({ kind: 'local-edit' }) + }) + + it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' }) + }) + + it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => { + // Given + const pendingText = '한' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText }) + + // Then + expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + }) + + it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => { + // Given + const pendingText = '한글' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText }) + const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' }) + expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' }) + }) + + it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => { + // Given + const pendingText = '한글' + + // When + const backspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + pendingText + }) + const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\b', + localEdit: 'backspace', + pendingText + }) + const deleteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b[3~', + localEdit: 'delete', + pendingText + }) + const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + pendingText + }) + const backspaceText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'backspace', + pendingText + }) + const deleteText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'delete', + pendingText + }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' }) + expect(customDeleteByteDecision).toEqual({ + kind: 'flush-then-send', + pendingText, + bytes: '\x7f' + }) + expect(backspaceText).toBe('한') + expect(deleteText).toBe('한글') + expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull() + expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull() + }) + + it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' }) + }) + + it('Given a non-special key When key decision is requested Then ignores it', () => { + // Given + const key = 'a' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) + + // Then + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given no pending text When submit is requested Then sends only carriage return', () => { + // Given + const pendingText = '' + + // When + const sequence = getTerminalLiveSubmitSequence(pendingText) + + // Then + expect(sequence).toEqual(['\r']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts new file mode 100644 index 00000000000..aac74b8931b --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -0,0 +1,145 @@ +import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' + +// Why: React Native does not expose portable composition events here, so +// non-Hangul IME text gets a short settle window before being sent to the PTY. +export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150 + +export type TerminalLiveTextChangeDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'send-now'; readonly text: string } + | { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null } + +export type TerminalLiveSpecialKeyDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'local-edit' } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveSpecialKeyDecisionInput = { + readonly key: string + readonly pendingText: string +} + +export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' + +export type TerminalLiveAccessoryBytesDecision = + | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveAccessoryBytesDecisionInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +} + +export function isTerminalLiveTextImeCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && codePoint > 0x7f) { + return true + } + } + return false +} + +function isHangulCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) +} + +export function isTerminalLiveTextHangulCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && isHangulCodePoint(codePoint)) { + return true + } + } + return false +} + +export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision { + if (text.length === 0) { + return { kind: 'ignore' } + } + + if (isTerminalLiveTextHangulCandidate(text)) { + return { kind: 'defer', text, delayMs: null } + } + + if (isTerminalLiveTextImeCandidate(text)) { + return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } + } + + return { kind: 'send-now', text } +} + +export function getTerminalLiveDeferredTextDelayMs(text: string): number | null { + return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS +} + +export function getTerminalLiveSpecialKeyDecision({ + key, + pendingText +}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { + const bytes = getTerminalLiveSpecialKeyBytes(key) + if (bytes === null) { + return { kind: 'ignore' } + } + + if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) { + return { kind: 'local-edit' } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryBytesDecision({ + bytes, + localEdit, + pendingText +}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { + if (pendingText.length > 0 && localEdit) { + return { kind: 'local-edit', localEdit } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryLocalEditText({ + localEdit, + pendingText +}: { + readonly localEdit: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +}): string { + if (localEdit === 'delete') { + // Why: accessory Delete mirrors forward-delete at the hidden input's end; + // it stays local but does not remove the pending IME text. + return pendingText + } + + return Array.from(pendingText).slice(0, -1).join('') +} + +export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r'] + +export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence { + if (pendingText.length === 0) { + return ['\r'] + } + + return [pendingText, '\r'] +} diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts new file mode 100644 index 00000000000..5ef459a6fde --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +const runtimeMeta = { runtimeId: 'test-runtime' } as const + +describe('terminal send RPC response', () => { + it('Given accepted terminal send response When checked Then reports success', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(true) + }) + + it('Given rejected terminal send response When checked Then reports failure', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(false) + }) + + it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => { + // Given + const rpcFailure: RpcResponse = { + id: '1', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: runtimeMeta + } + const malformedSuccess: RpcResponse = { + id: '2', + ok: true, + result: {}, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false) + expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts new file mode 100644 index 00000000000..454c5e32972 --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -0,0 +1,15 @@ +import type { RpcResponse } from '../transport/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { + if (!response.ok) { + return false + } + if (!isRecord(response.result) || !isRecord(response.result.send)) { + return false + } + return response.result.send.accepted === true +} diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts new file mode 100644 index 00000000000..b6bcb380607 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' + +type DeferredBoolean = { + readonly promise: Promise + readonly resolve: (value: boolean) => void +} + +function createDeferredBoolean(): DeferredBoolean { + let resolvePromise: (value: boolean) => void = () => { + throw new Error('deferred promise was resolved before initialization') + } + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +describe('terminal live accessory input commit', () => { + it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => { + // Given + const deferredFlush = createDeferredBoolean() + let settled = false + + // When + const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult( + () => deferredFlush.promise + ) + void resultPromise.then(() => { + settled = true + }) + await Promise.resolve() + + // Then + expect(settled).toBe(false) + deferredFlush.resolve(true) + await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' }) + }) + + it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => { + // Given + const waitForPendingLiveInputFlush = async (): Promise => false + + // When + const result = await getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush + ) + + // Then + expect(result).toEqual({ kind: 'suppress-raw' }) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts new file mode 100644 index 00000000000..b7e8cc8b214 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -0,0 +1,124 @@ +import { useCallback, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs +} from './terminal-live-text-commit' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' + +export type TerminalLiveAccessoryInputCommitResult = + | { readonly kind: 'allow-raw' } + | { readonly kind: 'handled' } + | { readonly kind: 'suppress-raw' } + +type TerminalLiveInputCommitScheduler = ( + handle: string, + text: string, + delayMs: number | null +) => void + +export async function getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush: () => Promise +): Promise { + return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } +} + +type TerminalLiveAccessoryInputCommitOptions = { + readonly activeHandle: string | null + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush +}: TerminalLiveAccessoryInputCommitOptions): ( + input: TerminalLiveAccessoryInput +) => Promise { + return useCallback( + async (input: TerminalLiveAccessoryInput): Promise => { + if (!activeHandle) { + return { kind: 'allow-raw' } + } + if (!liveInputTerminalHandles.has(activeHandle)) { + return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText }) + switch (decision.kind) { + case 'send-now': + // Why: raw accessory bytes must wait behind any in-flight IME text + // flush so composed Hangul reaches the PTY before follow-up controls. + return (await waitForPendingLiveInputFlush()) + ? { kind: 'allow-raw' } + : { kind: 'suppress-raw' } + case 'local-edit': { + const editedText = getTerminalLiveAccessoryLocalEditText({ + localEdit: decision.localEdit, + pendingText + }) + if (editedText.length === 0) { + clearPendingLiveInputCommit() + return { kind: 'handled' } + } + // Why: accessory buttons do not emit native TextInput edits, so the + // pending IME buffer must be edited and rescheduled here. + setLiveInputCapture(editedText) + liveInputRef.current?.setNativeProps({ text: editedText }) + schedulePendingLiveInputCommit( + activeHandle, + editedText, + getTerminalLiveDeferredTextDelayMs(editedText) + ) + return { kind: 'handled' } + } + case 'flush-then-send': + await sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return { kind: 'handled' } + default: + decision satisfies never + return { kind: 'handled' } + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts new file mode 100644 index 00000000000..005d4b77f15 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -0,0 +1,184 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { TextInput } from 'react-native' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit' +import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' + +type TerminalLiveInputCommitHarness = { + readonly captures: readonly string[] + readonly handlers: ReturnType> + readonly sent: readonly string[] + readonly unmount: () => void +} + +type TerminalLiveInputCommitHarnessOptions = { + readonly sendResult?: boolean +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputCommitHarness({ + sendResult = true +}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { + const activeHandle = 'terminal-a' + const activeHandleRef: RefObject = { current: activeHandle } + const activeSessionTabTypeRef: RefObject = { current: 'terminal' } + const captures: string[] = [] + const liveInputRef: RefObject = { current: null } + const liveInputTerminalHandles = new Set([activeHandle]) + const liveInputTerminalHandlesRef: RefObject> = { + current: new Set([activeHandle]) + } + const sent: string[] = [] + const sendLiveTerminalInputRef: RefObject = { + current: async (_handle, bytes) => { + sent.push(bytes) + return sendResult + } + } + let handlers: ReturnType> | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + handlers = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: 'terminal', + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture: (text) => captures.push(text) + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!handlers || !renderer) { + throw new Error('terminal live input hook did not render') + } + + return { + captures, + handlers, + sent, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input commit hook', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('ㅎ') + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(captures).toEqual(['ㅎ']) + expect(sent).toEqual([]) + }) + + it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputSubmit() + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) + }) + + it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(true) + expect(sent).toEqual(['한']) + }) + + it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(false) + expect(sent).toEqual(['한']) + }) + + it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('你好') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1) + + // Then + expect(captures).toEqual(['你好']) + expect(sent).toEqual([]) + + // When + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(sent).toEqual(['你好'])) + handlers.handleLiveInputChange('tiếng Việt') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt'])) + }) + + it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('é') + + // When + unmount() + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(sent).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts new file mode 100644 index 00000000000..2e2dd9e1589 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -0,0 +1,236 @@ +import { useCallback, useEffect, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision +} from './terminal-live-text-commit' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { normalizeTerminalTextInput } from './terminal-text-input-normalization' +import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush' +import { + useTerminalLiveAccessoryInputCommit, + type TerminalLiveAccessoryInputCommitResult +} from './use-terminal-live-accessory-input-commit' + +type TerminalLiveInputKeyPressEvent = { + readonly nativeEvent: { + readonly key: string + } +} + +type TerminalLiveInputCommitOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabType: TTabType | null | undefined + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLiveInputCommitHandlers = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly handleLiveInputAccessoryBytes: ( + input: TerminalLiveAccessoryInput + ) => Promise + readonly handleLiveInputChange: (text: string) => void + readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void + readonly handleLiveInputSubmit: () => void +} + +export function useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { + const { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } = useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) + + useEffect(() => { + const pendingHandle = pendingLiveInputHandleRef.current + if (!pendingHandle) { + return + } + if ( + !activeHandle || + pendingHandle !== activeHandle || + activeSessionTabType !== 'terminal' || + !liveInputTerminalHandles.has(activeHandle) + ) { + clearPendingLiveInputCommit() + } + }, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles]) + + const flushPendingLiveInputBeforeExternalSend = useCallback( + async (handle: string): Promise => { + const pendingHandle = pendingLiveInputHandleRef.current + if (pendingHandle && pendingHandle !== handle) { + clearPendingLiveInputCommit() + return waitForPendingLiveInputFlush() + } + if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) { + return flushPendingLiveInputText(handle) + } + return waitForPendingLiveInputFlush() + }, + [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush] + ) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + clearPendingLiveInputCommit() + return + } + const normalizedText = normalizeTerminalTextInput(text) + const decision = getTerminalLiveTextChangeDecision(normalizedText) + switch (decision.kind) { + case 'ignore': + clearPendingLiveInputCommit() + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.text) + ) + return + case 'defer': + // Why: React Native does not expose composition events here, so keep + // probable IME text in the native field until the commit timer settles. + setLiveInputCapture(decision.text) + schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + liveInputTerminalHandles, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputKeyPress = useCallback( + (event: TerminalLiveInputKeyPressEvent) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveSpecialKeyDecision({ + key: event.nativeEvent.key, + pendingText + }) + switch (decision.kind) { + case 'ignore': + case 'local-edit': + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + case 'flush-then-send': + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + }) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + const sequence = getTerminalLiveSubmitSequence(pendingText) + if (sequence.length === 2) { + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, sequence[1]) + ) + return + } + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, sequence[0]) + ) + }, [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ]) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } +} diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts new file mode 100644 index 00000000000..2c6998c99a0 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush +} from './terminal-live-pending-flush-state' + +type TerminalLivePendingInputFlushOptions = { + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLivePendingInputFlush = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: ( + handle: string, + text: string, + delayMs: number | null + ) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { + const liveInputCommitTimerRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputTextRef = useRef('') + const pendingLiveInputHandleRef = useRef(null) + + const clearPendingLiveInputCommit = useCallback(() => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputTextRef.current = '' + pendingLiveInputHandleRef.current = null + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [liveInputRef, setLiveInputCapture]) + + const waitForPendingLiveInputFlush = useCallback(async (): Promise => { + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + }, []) + + const flushPendingLiveInputText = useCallback( + async (expectedHandle: string | null): Promise => { + const existingFlush = pendingLiveInputFlushRef.current + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + + const handle = pendingLiveInputHandleRef.current + const text = pendingLiveInputTextRef.current + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + + if (!handle || text.length === 0) { + return existingFlush ?? false + } + if ( + (expectedHandle !== null && handle !== expectedHandle) || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' || + !liveInputTerminalHandlesRef.current.has(handle) + ) { + return false + } + + return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () => + sendLiveTerminalInputRef.current(handle, text) + ) + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + ] + ) + + const schedulePendingLiveInputCommit = useCallback( + (handle: string, text: string, delayMs: number | null) => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + } + pendingLiveInputHandleRef.current = handle + pendingLiveInputTextRef.current = text + if (delayMs === null) { + liveInputCommitTimerRef.current = null + return + } + liveInputCommitTimerRef.current = setTimeout(() => { + liveInputCommitTimerRef.current = null + void flushPendingLiveInputText(handle) + }, delayMs) + }, + [flushPendingLiveInputText] + ) + + useEffect(() => { + return () => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + pendingLiveInputFlushRef.current = null + } + }, []) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } +} diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 79bf8a978a8..5a304b41846 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -8,6 +8,11 @@ export type TerminalCommandFinishedEventDetail = { // decoupled consumers (e.g. git status refresh) react to shell commands // finishing without reaching into terminal internals. export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + // Why: unit tests and non-DOM renderer shims may expose only the preload API. + if (typeof window.dispatchEvent !== 'function') { + return + } + window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { detail: { worktreeId } From 443d6efb50d17f7fb3b19cd597f4807f08675a58 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:34:10 -0700 Subject: [PATCH 032/233] Tune worktree card drag targets (#7095) --- .../src/components/sidebar/WorktreeList.tsx | 82 ++++++++++++--- .../sidebar/worktree-drag-preview-offsets.ts | 41 +++++++- .../worktree-lineage-drag-drop.test.ts | 99 +++++++++++++++++++ .../sidebar/worktree-lineage-drag-drop.ts | 68 +++++++++++++ .../sidebar/worktree-manual-order.test.ts | 58 +++++++++++ .../worktree-sidebar-drag-autoscroll.test.ts | 35 ++++++- .../worktree-sidebar-drag-autoscroll.ts | 7 +- .../worktree-sidebar-drop-preview.test.ts | 52 ++++++++++ .../sidebar/worktree-sidebar-drop-preview.ts | 2 + 9 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 75cab5bcc2c..2296df9064b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -180,6 +180,10 @@ import { type WorktreeSidebarTrackedStatusDropTarget, type WorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId +} from './worktree-lineage-drag-drop' import { resolveProjectGroupHeaderColor } from './project-header-color' import { REPO_HEADER_ACTION_BUTTON_CLASS, @@ -984,11 +988,11 @@ function getPointerDropStatusTarget(args: { if (pinTarget && args.container.contains(pinTarget)) { return { status: null, isPinDrop: true, lineageParentId: null } } - const rowTarget = target.closest('[data-worktree-drag-id]') - let lineageParentId: string | null = null - if (rowTarget && args.container.contains(rowTarget)) { - lineageParentId = rowTarget.dataset.worktreeDragId ?? null - } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) const statusTarget = target.closest('[data-workspace-status-drop-target]') return { status: @@ -1314,6 +1318,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -1494,6 +1499,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupKey: string rects: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null => { const container = scrollRef.current if (!container) { @@ -1510,7 +1516,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp scrollTop: container.scrollTop, rects: args.rects, groupIds: group.worktreeIds, - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId }) }, [worktreeDragUnitGroups] @@ -1525,7 +1532,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY, groupKey: session.sourceGroupKey, rects: session.rects, - draggedIds: session.reorderUnitDraggedIds + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId }) }, [computeWorktreeDropForGroup] @@ -1545,7 +1553,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: args.pointerY, groupKey, rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null }) }, [computeWorktreeDropForGroup] @@ -2587,6 +2596,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [assignWorktreeParent, getEligibleLineageDropTarget] ) + const clearReorderedWorktreeParents = useCallback( + (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: args.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card onto a reorder line is the escape hatch + // from accidental nesting, so clear only the directly dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + ) + const flushWorktreePointerDrag = useCallback(() => { const drag = worktreePointerDragRef.current if (!drag) { @@ -3101,6 +3141,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) } else if (scrollRef.current) { const currentTarget = preferredStatusTarget const currentPreview = currentTarget.status @@ -3157,6 +3201,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [ beginWorktreePointerDrag, clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3504,10 +3549,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3757,6 +3807,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() } @@ -3764,6 +3818,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => document.removeEventListener('drop', handleDocumentDrop, true) }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -4615,11 +4670,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onClickCapture={handleWorktreeRowClickCapture} onDoubleClick={nested ? stopNestedWorktreeCardBubble : undefined} onDragStart={nested ? stopNestedWorktreeCardBubble : undefined} - onPointerDown={(event) => - nested - ? undefined - : handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - } + onPointerDown={(event) => { + if (nested) { + event.stopPropagation() + } + handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} style={{ paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined }} diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index 13d5281d1ad..678cee74421 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -38,18 +38,55 @@ function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { return gaps[Math.floor(gaps.length / 2)]! } +function getPreviewLayoutDraggedIds( + groupIds: readonly string[], + draggedIds: readonly string[], + draggingWorktreeId?: string | null +): readonly string[] { + if (draggedIds.length <= 1) { + return draggedIds + } + const draggedSet = new Set(draggedIds) + if ( + draggingWorktreeId && + draggedSet.has(draggingWorktreeId) && + groupIds.includes(draggingWorktreeId) + ) { + return [draggingWorktreeId] + } + const firstVisibleDraggedId = groupIds.find((id) => draggedSet.has(id)) + return firstVisibleDraggedId ? [firstVisibleDraggedId] : draggedIds.slice(0, 1) +} + export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): Map { - const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, args.draggedIds, args.dropIndex) + const committedNextIds = moveWorktreeIdsWithinGroup( + args.groupIds, + args.draggedIds, + args.dropIndex + ) + if (arraysEqual(committedNextIds, args.groupIds)) { + return new Map() + } + + // Why: dragging a large multi-select batch should advertise the insertion + // point without opening a giant hole that makes the sidebar jump around. + const layoutDraggedIds = getPreviewLayoutDraggedIds( + args.groupIds, + args.draggedIds, + args.draggingWorktreeId + ) + const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, layoutDraggedIds, args.dropIndex) if (arraysEqual(nextIds, args.groupIds)) { return new Map() } - const draggedSet = new Set(args.draggedIds) + const draggedSet = new Set(layoutDraggedIds) const newIndexById = new Map() nextIds.forEach((id, index) => newIndexById.set(id, index)) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts new file mode 100644 index 00000000000..0f70912df1c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId, + isWorktreeLineageDropZoneHit +} from './worktree-lineage-drag-drop' + +describe('isWorktreeLineageDropZoneHit', () => { + it('keeps the top and bottom of a card available for reorder drops', () => { + const rect = { top: 100, bottom: 200 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + }) + + it('caps the parent-drop band on tall cards', () => { + const rect = { top: 0, bottom: 180 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + }) +}) + +describe('getWorktreeLineageDropTargetId', () => { + it('returns the row id only when the pointer is in the card content middle band', () => { + const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + }) + + it('ignores content targets outside the sidebar container', () => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + contained: false + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() + }) +}) + +describe('getReorderedWorktreeIdsToUnnest', () => { + it('clears parents only for directly dragged nested cards', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['child', 'child', 'root', 'grandchild'], + sourceGroupIds: ['child', 'root', 'grandchild'], + lineageById: { + child: true, + grandchild: true + } + }) + ).toEqual(['child', 'grandchild']) + }) + + it('does not clear selected nested cards outside the reordered source group', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['source-child', 'other-child'], + sourceGroupIds: ['source-child'], + lineageById: { + 'source-child': true, + 'other-child': true + } + }) + ).toEqual(['source-child']) + }) +}) + +function makeTarget(args: { + worktreeId: string + top: number + bottom: number + contained?: boolean +}): { + container: HTMLElement + target: Element +} { + const row = { + getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) + } as HTMLElement + const content = { + getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), + closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) + } as HTMLElement + const target = { + closest: (selector: string) => + selector === '[data-worktree-card-hover-trigger]' ? content : null + } as Element + const contained = args.contained ?? true + const container = { + contains: (element: Element) => contained && (element === content || element === row) + } as HTMLElement + return { container, target } +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts new file mode 100644 index 00000000000..dc011d33a77 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -0,0 +1,68 @@ +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' + +const LINEAGE_DROP_ZONE_RATIO = 0.4 +const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 + +type VerticalRect = Pick + +export function isWorktreeLineageDropZoneHit(args: { + pointerY: number + rect: VerticalRect +}): boolean { + const height = Math.max(0, args.rect.bottom - args.rect.top) + if (height <= 0) { + return false + } + + const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + (height - zoneHeight) / 2 + const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + return args.pointerY >= zoneTop && args.pointerY <= zoneBottom +} + +export function getWorktreeLineageDropTargetId(args: { + container: HTMLElement + target: Element + pointerY: number +}): string | null { + const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || !args.container.contains(contentTarget)) { + return null + } + + // Why: nesting should be deliberate; the top/bottom of a card stays available + // for reorder drops instead of treating the whole card as a parent target. + if ( + !isWorktreeLineageDropZoneHit({ + pointerY: args.pointerY, + rect: contentTarget.getBoundingClientRect() + }) + ) { + return null + } + + const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { + return null + } + return rowTarget.getAttribute('data-worktree-drag-id') +} + +export function getReorderedWorktreeIdsToUnnest(args: { + draggedIds: readonly string[] + sourceGroupIds: readonly string[] + lineageById: Readonly> +}): string[] { + const ids: string[] = [] + const seen = new Set() + const sourceGroupIdSet = new Set(args.sourceGroupIds) + for (const id of args.draggedIds) { + if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + continue + } + seen.add(id) + ids.push(id) + } + return ids +} diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 7753afd8cd3..3847c2168b6 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -146,6 +146,64 @@ describe('buildWorktreeDragPreviewOffsets', () => { expect(Array.from(offsets)).toEqual([['parent', 108]]) }) + + it('reserves one card-height slot while previewing a multi-select batch', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the one preview placeholder', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([['e', -56]]) + }) + + it('returns no preview offsets for a no-op multi-select hover', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 4, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(offsets.size).toBe(0) + }) }) describe('buildManualOrderUpdatesForVisibleGroups', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index 2e3c5582abc..a197759a245 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -183,7 +183,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ).toBeNull() expect( refreshWorktreeSidebarDragSession({ - session: SESSION, + session: { ...SESSION, reorderUnitDraggedIds: ['missing'] }, groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }], rects: [] @@ -201,6 +201,39 @@ describe('refreshWorktreeSidebarDragSession', () => { }) ).toEqual({ ...SESSION, rects: [] }) }) + + it('keeps child-card reorder drags even when the child is not a top-level unit', () => { + const childSession: WorktreeSidebarDragSession = { + ...SESSION, + draggingWorktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'] + } + const rects: WorktreeSidebarDragRect[] = [ + { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 }, + { worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 }, + { worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 } + ] + + expect( + refreshWorktreeSidebarDragSession({ + session: childSession, + groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }], + unitGroups: [ + { + key: 'repo:one', + worktreeIds: ['parent', 'sibling'], + units: [ + { worktreeId: 'parent', worktreeIds: ['parent', 'child'] }, + { worktreeId: 'sibling', worktreeIds: ['sibling'] } + ] + } + ], + rects + }) + ).toEqual({ ...childSession, rects }) + }) }) function makeContainer(elements: readonly ReturnType[]): HTMLElement { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 4ccbf340549..27e2a3d7d2d 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -180,8 +180,13 @@ export function refreshWorktreeSidebarDragSession(args: { return null } + const sourceGroupIds = new Set(sourceGroup.worktreeIds) const sourceUnitIds = new Set(sourceUnitGroup.worktreeIds) - if (args.session.reorderUnitDraggedIds.some((worktreeId) => !sourceUnitIds.has(worktreeId))) { + if ( + args.session.reorderUnitDraggedIds.some( + (worktreeId) => !sourceUnitIds.has(worktreeId) && !sourceGroupIds.has(worktreeId) + ) + ) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index 69f4c693de0..9fb1e630276 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -60,6 +60,58 @@ describe('computeWorktreeSidebarDropPreview', () => { }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) }) + + it('uses one card-height placeholder for multi-select reorder previews', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the multi-select preview placeholder', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index b150188bdb9..d3702968e59 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -99,6 +99,7 @@ export function computeWorktreeSidebarDropPreview(args: { rects: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null { const rects = getWorktreeSidebarDragUnitRects({ rects: args.rects, @@ -139,6 +140,7 @@ export function computeWorktreeSidebarDropPreview(args: { const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId, dropIndex, rects }) From aa1744e30e30e282b52cc3ae71746b783a410a8b Mon Sep 17 00:00:00 2001 From: lvfen <284437334@qq.com> Date: Thu, 2 Jul 2026 17:41:07 +0800 Subject: [PATCH 033/233] Fix cursor agent ime anchor (#7061) * fix: correct IME cursor after terminal reattach * fix: stabilize Cursor Agent IME anchor * Fix Cursor Agent reattach IME detection * Harden live-agent reattach detection and always restore cursor visibility - POST_REPLAY_LIVE_AGENT_REATTACH_RESET now includes ?25h so a false-positive agent signal (or dead TUI leftovers) can never leave a shell with a permanently hidden cursor; only focus reporting (?1004h) is preserved. - Replace the broad getAgentLabel token match (fires on titles like 'ssh devin@host') with an exact cursor-agent native-title check. - Anchor the replay-payload screen signal on the LAST 'Cursor Agent' header so scrollback from an earlier finished run does not classify the current screen. - Make the payload-signal flag replay-shape-aware: incremental (clearBeforeReplay:false) frames can only add evidence; full-screen replays remain authoritative. Co-authored-by: Orca * Fix anchor scan direction and drop non-decaying launch metadata from liveness - Scan the visible screen bottom-up in resolveCursorAgentImeAnchor so a transcript line containing an arrow (e.g. a rename diff) cannot hijack the IME anchor away from the input box. - Remove getAuthoritativePaneAgent() from hasLiveAgentReattachSignal: tab.launchAgent never decays after the agent exits, so it would preserve ?1004h and inject focus-in into the shell left behind by a dead agent. - Clear a stale replay-payload agent signal on an authoritative empty clearing frame. Co-authored-by: Orca * Veto scrollback-only Cursor Agent signal against the parsed viewport The replay-byte screen signal also matches a dead run's screen sitting in scrollback (daemon snapshots serialize full scrollback; the relay buffer is never cleared). After xterm parses the replay, confirm a payload-only signal against the visible screen via resolveCursorAgentImeAnchor; when the viewport shows a plain shell, drop focus reporting (?1004l) and skip the focus-in so a bare shell never inherits stale focus modes or receives stray focus bytes. Co-authored-by: Orca * Make the reattach viewport veto shape-based and latest-frame-only - parsedViewportShowsCursorAgentScreen now checks the screen shape (header + input row) via viewportShowsCursorAgentScreen instead of requiring the parked-cursor precondition, so a live agent whose cursor sits at the caret is not downgraded. - Add a replay-signal generation counter so a stale post-parse callback stands down when a newer replay frame has updated the signal. Co-authored-by: Orca * Require the parked cursor in the reattach viewport confirmation A dead cursor-agent screen can still be painted in the viewport with the shell prompt below it, which passes a shape-only check and would preserve ?1004h and inject a focus-in into the shell. Confirm a payload-only signal with resolveCursorAgentImeAnchor (shape + parked cursor): a live agent that needs the focus-in is by definition parked, and an unparked live agent only falls back to the pre-fix reattach behavior. Co-authored-by: Orca * Complete window stub in pty-connection tests for command-finished event Main's onCommandFinished now dispatches a window CustomEvent synchronously, but the node-env connectPanePty window stub omits dispatchEvent. These tests only passed when another test file in the same vitest worker happened to leave a window with dispatchEvent; this branch's added test files shift sharding and expose the gap. Add dispatchEvent/addEventListener/removeEventListener to the stub so the command-finished path is covered deterministically. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../terminal-pane/layout-serialization.ts | 27 +- .../terminal-pane/pty-connection.test.ts | 368 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 219 ++++++++++- .../terminal-replay-cursor-state.test.ts | 111 ++++++ .../src/lib/pane-manager/pane-lifecycle.ts | 44 ++- .../pane-manager/terminal-ime-anchor.test.ts | 156 ++++++++ .../lib/pane-manager/terminal-ime-anchor.ts | 122 ++++++ 7 files changed, 1015 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 423a08b8697..b2ab8d1e2ab 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -58,27 +58,32 @@ export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KIT export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the -// full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still -// rely on mouse or bracketed-paste modes. Four exceptions are safe to reset: +// full POST_REPLAY_MODE_RESET bundle there. The default still clears the two +// stale mode bits most harmful to a plain shell after a TUI exits badly: // // 0 q — DECSCUSR cursor style/blink reset: raw replay can contain a stale // steady cursor override, while SerializeAddon does not preserve an // authoritative current cursor style. Reset to the user's configured // xterm cursor; the post-reattach SIGWINCH lets live TUIs repaint if // they need a different cursor. -// 25 — DECTCEM cursor visibility: SerializeAddon bakes `?25l` into the -// snapshot when the cursor was hidden at capture time. Without `?25h` -// here the cursor stays invisible after reattach. If a TUI is still -// running and wants the cursor hidden, the SIGWINCH sent immediately -// after restore triggers a repaint that re-hides it — a brief flash -// that is far less harmful than a permanently invisible cursor. -// 1004 — focus event reporting: preserving `?1004h` makes restored shells -// ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` -// as unbound key input). +// 25 — DECTCEM cursor visibility: snapshots can preserve hidden cursor +// state from a no-longer-running TUI; reset by default so shells do +// not inherit a permanently invisible cursor. +// 1004 — focus event reporting: snapshots can preserve focus reporting from +// a no-longer-running TUI; reset by default so shells do not receive +// stray focus-in/focus-out bytes. // <99u/=0u — Kitty keyboard mode is renderer-side xterm state; stale copies // can make the next Ctrl+C encode as CSI-u after reattach. export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` +// Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h` +// would suppress the post-reattach focus-in the agent needs to move its real +// cursor back to the input caret (the IME anchor). Cursor visibility is still +// reset: agent detection can false-positive on a dead TUI's leftovers, and a +// permanently invisible shell cursor is far worse than the brief flash a live +// agent re-hides on its post-reattach SIGWINCH repaint. +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / 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 01ad840dcbe..cd806774737 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, @@ -330,7 +331,8 @@ function createPane(paneId: number) { active: activeBuffer }, modes: { - bracketedPasteMode: false + bracketedPasteMode: false, + sendFocusMode: false }, options: { ignoreBracketedPasteMode: false, @@ -449,6 +451,52 @@ function createDeps(overrides: Record = {}) { } } +function setReattachPaneTitle(title: string): void { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: title } + } + } as StoreState +} + +async function withMockedDocumentActiveElement( + activeElement: unknown, + run: () => Promise +): Promise { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { activeElement } + }) + try { + return await run() + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument) + } else { + Reflect.deleteProperty(globalThis, 'document') + } + } +} + +function configureTerminalFocusMode( + pane: ReturnType, + textarea: HTMLTextAreaElement +): void { + Object.assign(pane.terminal, { textarea }) + Object.assign(pane.terminal.modes, { sendFocusMode: true }) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) +} + +const ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN = + '\x1b[4;3HCursor Agent\x1b[5;3Hv2026.06.29\x1b[9;3H→ Plan, search, build anything' + // Why: setting activeRuntimeEnvironmentId in mockStoreState exercises the // remote-runtime path where the renderer still owns OSC 9999 status. function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { @@ -709,7 +757,10 @@ describe('connectPanePty', () => { agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false) } - } + }, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() } globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) @@ -4023,7 +4074,7 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledTimes(1) }) - it('resets reattach cursor/focus state after daemon snapshot replay without applying the full mode reset', async () => { + it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { @@ -4068,6 +4119,241 @@ describe('connectPanePty', () => { ) }) + it('preserves live modes and injects focus-in after focused agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + // Why: public xterm modes plus an agent title are the stable signal for a + // live focus-driven TUI; avoid private `_core` field probes. + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + // Why: a different element owns focus, so the reattach must not send a stray + // focus-in to a background pane. + await withMockedDocumentActiveElement({}, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: launchAgent is launch ownership metadata that never decays after + // the agent exits; it must not preserve stale modes for the shell left + // behind after an unclean agent death. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'zsh' } + } + } as StoreState + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: broad token matching would classify this ordinary ssh title as an + // agent and preserve stale modes / inject focus-in into a bare shell. + setReattachPaneTitle('ssh devin@host') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: + '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') @@ -8260,6 +8546,82 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + // Why: an inspectable buffer whose visible rows carry no Cursor Agent + // screen models a shell foreground after a dead run left its screen in + // scrollback. + Object.assign(pane.terminal.buffer.active, { + cursorX: 2, + getLine: () => undefined + }) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + it('does not clear restored scrollback when eager metadata replay opts out', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f9b025da2c5..85c50f6fe8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,8 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IDisposable } from '@xterm/xterm' +import type { IBuffer, IDisposable } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -49,6 +50,7 @@ import { type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -187,6 +189,8 @@ const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' +const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' +const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 @@ -212,6 +216,103 @@ type E2eTerminalPtyDataInjectionApi = { keys: () => string[] } +type TerminalWithFocusMode = { + textarea?: HTMLTextAreaElement | null + modes?: { + sendFocusMode?: boolean + } +} + +type TerminalWithInspectableBuffer = { + cols: number + rows: number + buffer?: { + active?: IBuffer + } +} + +// Why: replay bytes can carry a dead run's screen in scrollback — or still +// painted in the viewport with a shell prompt below it — so once xterm has +// parsed the replay the confirmation needs both the cursor-agent screen shape +// AND the parked cursor. A dead screen leaves the shell cursor after its +// prompt; a live agent that needs the focus-in is by definition parked, and a +// live agent that is not parked only loses focus reporting the way the +// pre-fix reattach always did. Returns null when the buffer is not +// inspectable (e.g. test doubles). +function parsedViewportShowsParkedCursorAgentScreen( + terminal: TerminalWithInspectableBuffer +): boolean | null { + const buffer = terminal.buffer?.active + if ( + !buffer || + typeof buffer.getLine !== 'function' || + typeof buffer.cursorX !== 'number' || + typeof buffer.cursorY !== 'number' + ) { + return null + } + return ( + resolveCursorAgentImeAnchor({ + buffer, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY + }) !== null + ) +} + +function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { + return terminal.modes?.sendFocusMode === true +} + +function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { + if (typeof document === 'undefined' || !terminal.textarea) { + return false + } + return document.activeElement === terminal.textarea +} + +function stripAnsiCsiSequences(data: string): string { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' +const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' +const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 + +function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { + const normalized = stripAnsiCsiSequences(data) + // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, + // and an earlier finished run must not classify the current screen. + const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) + if (headerIndex === -1) { + return false + } + const screenTail = normalized.slice( + headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, + headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS + ) + return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) +} + type E2eTerminalPtyDataInjectionWindow = Window & { __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi @@ -1228,6 +1329,52 @@ export function connectPanePty( )?.title return runtimeTitle ?? tabTitle ?? null } + let reattachReplayPayloadHasCursorAgentSignal = false + // Why: post-parse veto callbacks must only judge the latest replay frame; a + // newer frame bumps the generation so a stale callback stands down. + let reattachReplayPayloadSignalGeneration = 0 + const rememberReattachPayloadAgentSignal = ( + data: string, + opts: { fullScreenReplay: boolean } + ): void => { + reattachReplayPayloadSignalGeneration += 1 + // Why: ordinary scrollback can mention agent names. Treat replay bytes as + // a live Cursor Agent signal only when they look like its restored screen. + const signal = hasCursorAgentReattachPayloadScreenSignal(data) + // Why: incremental (non-clearing) replay frames repaint only part of the + // screen, so their bytes can only add evidence — a full-screen replay is + // the authoritative repaint that may clear the flag. + reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay + ? signal + : reattachReplayPayloadHasCursorAgentSignal || signal + } + const isCursorAgentNativeTitle = (title: string): boolean => { + return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() + } + const hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + // Why: launch ownership (tab.launchAgent) never decays after the agent + // exits, so it must not count as liveness here — only live status, live + // titles, and the replayed screen shape do. + if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { + return true + } + const title = getCurrentTerminalTitle() ?? '' + // Why: broad token matching (getAgentLabel) fires on titles like + // "ssh devin@host"; that surface is too loose to gate mode preservation + // and PTY byte injection, so only exact/status titles count here. + return detectAgentStatusFromTitle(title) !== null || isCursorAgentNativeTitle(title) + } + const hasLiveAgentReattachSignal = (): boolean => { + return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal + } + const shouldPreserveAgentReattachModes = (): boolean => { + // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. + // Preserve those modes only when reattach still looks agent-owned. + return hasLiveAgentReattachSignal() + } + const shouldSendFocusedAgentReattachFocusIn = (): boolean => { + return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() + } const scheduleReattachIdleAgentCursorReset = (): void => { const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') if (status !== 'idle' && status !== 'permission') { @@ -3359,6 +3506,52 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } + const reattachReplayResetSequence = (): string => { + return shouldPreserveAgentReattachModes() + ? POST_REPLAY_LIVE_AGENT_REATTACH_RESET + : POST_REPLAY_REATTACH_RESET + } + + const sendFocusedReattachFocusInAfterReplay = (): void => { + const scheduledGeneration = reattachReplayPayloadSignalGeneration + void waitForTerminalOutputParsed(pane.terminal).then(() => { + if (disposed) { + return + } + // Why: a newer replay frame owns the judgment; its own post-parse + // callback will re-evaluate against its own viewport. + if (scheduledGeneration !== reattachReplayPayloadSignalGeneration) { + return + } + // Why: the replay-byte signal also matches a dead run's screen — in + // scrollback or still painted above a fresh shell prompt. The parsed + // viewport is the ground truth; unless it shows a parked-cursor + // cursor-agent screen and no status/title corroborates, downgrade to + // the plain-shell behavior (drop focus reporting, skip focus-in). + if ( + !hasLiveAgentReattachStatusOrTitleSignal() && + reattachReplayPayloadHasCursorAgentSignal + ) { + if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { + reattachReplayPayloadHasCursorAgentSignal = false + writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE) + return + } + } + // Why: a live TUI such as cursor-agent parks the real terminal cursor off + // its own input caret and moves it back only on a focus-in. Reattach + // reuses the same live PTY and the xterm textarea already holds DOM + // focus, so xterm never emits the focus-in the agent needs and the parked + // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a + // bare shell never receives a stray \x1b[I. + const sendFocusMode = terminalHasFocusReportingEnabled(pane.terminal) + if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + return + } + transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + }) + } + let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string @@ -3377,9 +3570,15 @@ export function connectPanePty( if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') } + if (clearBeforeReplay || data.length > 0) { + // Why: an empty clearing frame is still an authoritative repaint and + // must clear a stale agent signal from an earlier payload. + rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + } await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + await writeReplayDataAsync(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() } if (disposed) { pendingReplayData = null @@ -4557,12 +4756,14 @@ export function connectPanePty( // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop stale cursor/focus state that should not leak - // from replay bytes into the restored renderer terminal. - writeReplayData(POST_REPLAY_REATTACH_RESET) + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. @@ -4571,13 +4772,15 @@ export function connectPanePty( } } } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) // Relay replay holds the last 100 KB of raw output. The xterm may // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset prevents stale cursor/focus mode - // bits in the replayed data from leaking into the restored terminal. + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.replay) - writeReplayData(POST_REPLAY_REATTACH_RESET) + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 933de17d5fd..9e927552049 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -13,6 +14,7 @@ const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' type DecPrivateCursorState = { cursorStyle?: string cursorBlink?: boolean + sendFocus?: boolean } type KittyKeyboardState = { @@ -28,10 +30,12 @@ type XtermWithCoreService = Terminal & { coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } _coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } } } @@ -42,6 +46,15 @@ function readDecPrivateCursorState(term: Terminal): DecPrivateCursorState { return cursorState ? { ...cursorState } : {} } +function readCursorHidden(term: Terminal): boolean | undefined { + const core = (term as XtermWithCoreService)._core + return core?.coreService?.isCursorHidden ?? core?._coreService?.isCursorHidden +} + +function readSendFocus(term: Terminal): boolean | undefined { + return readDecPrivateCursorState(term).sendFocus +} + function readKittyKeyboardState(term: Terminal): KittyKeyboardState | null { const core = (term as XtermWithCoreService)._core const keyboardState = core?.coreService?.kittyKeyboard ?? core?._coreService?.kittyKeyboard @@ -180,6 +193,104 @@ describe('terminal replay state reset', () => { } }) + it('resets hidden cursor visibility after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets focus reporting after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readSendFocus(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets hidden cursor visibility after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: agent detection can false-positive on a dead TUI's leftovers, so + // even the live-agent reset must not leave a shell cursor permanently + // invisible; a live agent re-hides it on the post-reattach repaint. + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('preserves live focus reporting after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: live TUIs such as cursor-agent can rely on focus events to repaint + // their own hidden-cursor input caret after renderer reattach. + expect(readSendFocus(term)).toBe(true) + } finally { + term.dispose() + } + }) + + it('shows the cursor after cold-restore replay reset', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_MODE_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + it('preserves active-buffer Kitty keyboard state after hidden-output snapshot replay', async () => { const term = new Terminal({ cols: 80, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index eb296a1d675..efdbea455ff 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -17,6 +17,7 @@ import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -70,11 +71,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { activateOrcaTerminalUnicodeProvider(terminal) // Why: the OS reads the focused textarea's screen rect at compositionstart to - // decide where to display the IME candidate window. xterm.js only repositions - // the textarea on compositionupdate (via updateCompositionElements), not on - // compositionstart, so the window can appear at a stale cursor position. We - // force-sync the textarea position in a capture-phase listener so the OS sees - // the correct location before it opens the candidate window. + // decide where to display the IME candidate window. xterm positions that + // textarea from its own cursor, which can be stale or intentionally hidden by + // TUIs. We force-sync after xterm's own composition handlers so the OS sees + // the corrected location before it opens the candidate window. // // Cell dimensions are derived from the public .xterm-screen element's bounds // (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than @@ -94,11 +94,34 @@ export function openTerminal(pane: ManagedPaneInternal): void { return } const buf = terminal.buffer.active - const x = Math.min(buf.cursorX, terminal.cols - 1) - textarea.style.top = `${buf.cursorY * cellHeight}px` - textarea.style.left = `${x * cellWidth}px` + // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor + // on a blank row, so the OS IME anchor needs the rendered prompt row instead. + const cursorAgentAnchor = resolveCursorAgentImeAnchor({ + buffer: buf, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buf.cursorX, + cursorY: buf.cursorY + }) + const anchor = cursorAgentAnchor ?? { + row: buf.cursorY, + column: Math.min(buf.cursorX, terminal.cols - 1) + } + const applyAnchor = (): void => { + textarea.style.top = `${anchor.row * cellHeight}px` + textarea.style.left = `${anchor.column * cellWidth}px` + } + applyAnchor() + if (cursorAgentAnchor) { + window.setTimeout(() => { + if (textarea.isConnected) { + applyAnchor() + } + }, 0) + } } - terminal.element.addEventListener('compositionstart', handler, true) + terminal.element.addEventListener('compositionstart', handler) + terminal.element.addEventListener('compositionupdate', handler) // Store so disposePane() can remove it and avoid a memory leak. pane.compositionHandler = handler } @@ -200,7 +223,8 @@ export function disposePane( pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null if (pane.compositionHandler) { - pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) + pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) + pane.terminal.element?.removeEventListener('compositionupdate', pane.compositionHandler) pane.compositionHandler = null } try { diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts new file mode 100644 index 00000000000..19de8afef2c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' + +type FakeCell = { + chars: string + width: number +} + +function makeCell(cell: FakeCell): IBufferCell { + return { + getWidth: () => cell.width, + getChars: () => cell.chars + } as IBufferCell +} + +function makeLine(text: string, cols = 80): IBufferLine { + const cells: FakeCell[] = [] + for (const char of Array.from(text)) { + const width = char === '你' ? 2 : 1 + cells.push({ chars: char, width }) + if (width === 2) { + cells.push({ chars: '', width: 0 }) + } + } + while (cells.length < cols) { + cells.push({ chars: '', width: 1 }) + } + + return { + isWrapped: false, + length: cells.length, + getCell: (column: number) => { + const cell = cells[column] + return cell ? makeCell(cell) : undefined + }, + translateToString: (trimRight = false, startColumn = 0, endColumn = cells.length) => { + let result = '' + for (let column = startColumn; column < endColumn; column++) { + const cell = cells[column] + if (!cell || cell.width === 0) { + continue + } + result += cell.chars || ' ' + } + return trimRight ? result.replace(/\s+$/, '') : result + } + } as IBufferLine +} + +function makeBuffer(lines: string[], cols = 80): IBuffer { + const bufferLines = lines.map((line) => makeLine(line, cols)) + return { + baseY: 0, + cursorX: 0, + cursorY: 0, + length: bufferLines.length, + getLine: (row: number) => bufferLines[row], + getNullCell: () => makeCell({ chars: '', width: 1 }) + } as IBuffer +} + +describe('resolveCursorAgentImeAnchor', () => { + it('anchors an empty Cursor Agent prompt at the visible prompt caret', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + ' v2026.06.29-2ad2186', + ' Tip: Use /config to customize Cursor settings and behavior.', + '', + '', + '', + '', + ' → Plan, search, build anything', + '', + '', + ' Composer 2.5', + ' ~/development/code/xinyue/app_android · develop/app6.5.1', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 14, + cols: 80, + cursorX: 0, + cursorY: 13 + }) + ).toEqual({ row: 8, column: 4 }) + }) + + it('does not override normal terminal cursor positioning', () => { + const buffer = makeBuffer(['', ' → Plan, search, build anything', '', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 0, + cursorY: 3 + }) + ).toBeNull() + }) + + it('does not override when xterm already exposes a non-stale cursor position', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 4, + cursorY: 3 + }) + ).toBeNull() + }) + + it('anchors the input row, not a transcript line containing an arrow', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + '', + ' Renamed a.ts → b.ts', + '', + ' → Plan, search, build anything', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 7, + cols: 80, + cursorX: 0, + cursorY: 6 + }) + ).toEqual({ row: 5, column: 4 }) + }) + + it('uses cell width when anchoring after typed Cursor Agent input', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → hi你', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 5, + cols: 80, + cursorX: 0, + cursorY: 4 + }) + ).toEqual({ row: 3, column: 8 }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts new file mode 100644 index 00000000000..bda4c751a88 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -0,0 +1,122 @@ +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' + +export type TerminalImeAnchor = { + row: number + column: number +} + +const CURSOR_AGENT_HEADER = 'Cursor Agent' +const CURSOR_AGENT_INPUT_MARKER = '→' +const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything' +const CURSOR_AGENT_HEADER_SCAN_ROWS = 6 + +export function resolveCursorAgentImeAnchor(args: { + buffer: IBuffer + rows: number + cols: number + cursorX: number + cursorY: number +}): TerminalImeAnchor | null { + const cursorLine = getVisibleLine(args.buffer, args.cursorY) + if (args.cursorX !== 0 || !isBlankLine(cursorLine)) { + return null + } + return findCursorAgentScreenInputAnchor(args) +} + +function findCursorAgentScreenInputAnchor(args: { + buffer: IBuffer + rows: number + cols: number +}): TerminalImeAnchor | null { + if (!hasCursorAgentHeader(args.buffer, args.rows)) { + return null + } + + // Why: the input box sits below the transcript, so scan bottom-up — a + // transcript line containing "→ " (e.g. a rename diff) must not win. + for (let row = args.rows - 1; row >= 0; row--) { + const line = getVisibleLine(args.buffer, row) + if (!line) { + continue + } + const column = resolveCursorAgentInputColumn(line, args.cols) + if (column !== null) { + return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) } + } + } + + return null +} + +function getVisibleLine(buffer: IBuffer, row: number): IBufferLine | undefined { + return buffer.getLine(buffer.baseY + row) +} + +function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean { + const scanRows = Math.min(rows, CURSOR_AGENT_HEADER_SCAN_ROWS) + for (let row = 0; row < scanRows; row++) { + if (getVisibleLine(buffer, row)?.translateToString(true).trim() === CURSOR_AGENT_HEADER) { + return true + } + } + return false +} + +function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null { + const inputColumn = findCursorAgentInputStartColumn(line, cols) + if (inputColumn === null) { + return null + } + + const inputText = line.translateToString(true, inputColumn, cols) + if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) { + return inputColumn + } + + return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn +} + +function findCursorAgentInputStartColumn(line: IBufferLine, cols: number): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = 0; column < maxColumn - 1; column++) { + const markerCell = line.getCell(column) + if (!isCellChar(markerCell, CURSOR_AGENT_INPUT_MARKER)) { + continue + } + + const nextColumn = column + Math.max(markerCell.getWidth(), 1) + if (nextColumn < maxColumn && isCellChar(line.getCell(nextColumn), ' ')) { + return nextColumn + 1 + } + } + return null +} + +function findLineContentEndColumn( + line: IBufferLine, + startColumn: number, + cols: number +): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = maxColumn - 1; column >= startColumn; column--) { + const cell = line.getCell(column) + if (!cell || cell.getWidth() === 0 || getCellChars(cell) === ' ') { + continue + } + return column + Math.max(cell.getWidth(), 1) + } + return null +} + +function isBlankLine(line: IBufferLine | undefined): boolean { + return !line || line.translateToString(true).trim() === '' +} + +function isCellChar(cell: IBufferCell | undefined, expected: string): cell is IBufferCell { + return !!cell && cell.getWidth() > 0 && getCellChars(cell) === expected +} + +function getCellChars(cell: IBufferCell): string { + return cell.getChars() || ' ' +} From e3f99a747276f2c6a2c6042ef8504c7142cd9e99 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:42:52 -0700 Subject: [PATCH 034/233] Bump mobile app.json to 0.0.20 (#7100) --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index 9394a9a63dc..3a09a5531c8 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.18", + "version": "0.0.20", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From 3ba36301509ef3ac8bd8698bad52bed882d8384c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:51:09 -0700 Subject: [PATCH 035/233] Move githubCache out of the durable state file into a quit-time sidecar (#7101) * Move githubCache out of the durable state file into a quit-time sidecar Every PR/issue poll restamps fetchedAt even when nothing changed, which defeated the content-hash write guard by construction: ~100KB of refetchable 5-min-TTL cache rewrote the whole 1.6MB orca-data.json once per refresh cycle - the last remaining non-user-driven writer. The cache is now memory-only during the session (setGitHubCache no longer schedules a save; the durable payload and state hash both omit it) and is snapshotted best-effort to orca-github-cache.json at flush (quit/reload), so PR/issue badges still paint instantly on next launch. A legacy in-file cache is used as the seed and stripped by the next durable write; downgrades just recreate the key, which the next upgrade strips again. Sidecar loss costs nothing - the data is refetchable. Co-authored-by: Orca * Reject null/array pr-issue shapes when reading the github cache sidecar A corrupt-but-valid-JSON sidecar ({"pr":null}) would seed state.githubCache.pr = null and crash mobile worktree.ps PR grouping until the renderer's first cache save repaired it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 43 ++++++++++++++++++ src/main/persistence.ts | 88 ++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b1856b1a8c4..60ab8fb3a19 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4584,6 +4584,49 @@ describe('Store', () => { expect(statSync(dataFile()).ino).toBe(inoBefore) }) + // ── GitHub cache sidecar ─────────────────────────────────────────── + + it('cache refreshes never rewrite the durable state file', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + store.updateUI({ sidebarWidth: 411 }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const inoBefore = statSync(dataFile()).ino + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + + store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} }) + vi.advanceTimersByTime(6000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + } finally { + vi.useRealTimers() + } + }) + + it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => { + const store = await createStore() + store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} }) + store.flush() + expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true) + + const restarted = await createStore() + expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) + }) + + it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { + writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) + + const store = await createStore() + expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 }) + + // The legacy key marks the state dirty at load; the next write drops it. + store.flush() + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + }) + // ── UI state ─────────────────────────────────────────────────────── it('updateUI merges partial updates', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5d9ef610f48..fb25286488c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -325,6 +325,34 @@ function getDataFile(): string { return _dataFile } +// Why a sidecar: githubCache is a refetchable 5-min-TTL poll cache whose +// fetchedAt stamps change on every refresh — keeping it inside orca-data.json +// made every poll cycle rewrite the whole multi-MB durable state (defeating +// the content-hash guard by design). It lives in memory during the session +// and is snapshotted here best-effort at quit so PR/issue badges still paint +// instantly on the next launch. Loss of this file costs nothing. +function getGithubCacheFile(): string { + return join(dirname(getDataFile()), 'orca-github-cache.json') +} + +function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { + try { + const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown + const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + if ( + isPlainRecord(parsed) && + isPlainRecord((parsed as { pr?: unknown }).pr) && + isPlainRecord((parsed as { issue?: unknown }).issue) + ) { + return parsed as PersistedState['githubCache'] + } + } catch { + // Missing or corrupt snapshot: start with an empty cache and refetch. + } + return null +} + /** * Return the userData directory captured at initDataPath() time, before * app.setName() can change how app.getPath('userData') resolves. @@ -2451,6 +2479,7 @@ export class Store { // on-disk bytes differ even for identical state. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null + private githubCacheDirty = false private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -3294,6 +3323,24 @@ export class Store { result = folderScopeConnectionMigration.state const migrated = this.migrateTelemetry(result, fileExistedOnLoad) + + // githubCache lives in a sidecar file now (see getGithubCacheFile). A + // legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is + // kept as this session's seed and stripped from the durable file by the + // save scheduled below; otherwise seed from the sidecar snapshot. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even if no + // poll refresh happens this session — the seed survives the migration. + this.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache + } + logPersistenceStartupMilestone('persistence-load-done', { repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) @@ -3403,8 +3450,16 @@ export class Store { } } + // Why githubCache is omitted: it is memory-only during the session (see + // getGithubCacheFile) — excluding it from both the payload and the hash + // keeps cache refreshes from ever touching the durable file. + private getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.state + return durable + } + private computeStateHash(): string { - return createHash('sha1').update(JSON.stringify(this.state)).digest('hex') + return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex') } // Why: builds the on-disk payload synchronously so the hash and the @@ -3414,7 +3469,7 @@ export class Store { // Why: secrets must be encrypted on disk. Clone state so the in-memory // this.state stays plaintext for the rest of the app. const stateToSave = { - ...this.state, + ...this.getDurableState(), settings: { ...this.state.settings, opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), @@ -5241,8 +5296,12 @@ export class Store { } setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: the cache is memory-only during the session and + // snapshotted to its sidecar file at flush (quit/reload) time. Every poll + // refresh restamps fetchedAt, so persisting here rewrote the whole + // durable state file once per poll cycle for refetchable data. this.state.githubCache = cache - this.scheduleSave() + this.githubCacheDirty = true } // ── Workspace Session ───────────────────────────────────────────── @@ -5947,6 +6006,29 @@ export class Store { } catch (err) { console.error('[persistence] Failed to flush state:', err) } + this.writeGithubCacheSnapshotSync() + } + + // Why best-effort: the sidecar is a refetchable cache — a failed write only + // costs a cold badge paint on next launch, never data. + private writeGithubCacheSnapshotSync(): void { + if (!this.githubCacheDirty) { + return + } + const cacheFile = getGithubCacheFile() + const tmpFile = `${cacheFile}.${process.pid}.tmp` + try { + writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + renameSync(tmpFile, cacheFile) + this.githubCacheDirty = false + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } } } From ad13532ab1f8ea31fc434da4d503ba0c93260fb3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:12:26 -0700 Subject: [PATCH 036/233] Garbage-collect stale worktreeMeta for externally-deleted worktrees (#7107) * Garbage-collect stale worktreeMeta for externally-deleted worktrees at load worktreeMeta rows are minted for every worktree Orca ever discovers but were only deleted by Orca-initiated removal flows. Worktrees deleted outside Orca (git worktree remove, rm -rf, agent scripts) purge renderer session state without ever removing the meta, so the map grew monotonically - 318 of 503 entries (63%, ~110KB) on the reference machine pointed at long-deleted paths. These entries never reach the renderer; they only cost disk bytes, parse time, and memory. GC at load is deliberately narrow: local-host entries only (SSH/runtime repos, remote meta.hostId, and WSL UNC paths are skipped - a local existsSync would falsely condemn remote paths), only when the checkout path no longer exists, only after a 30-day idle grace (preserving pushTarget cleanup for recently-vanished worktrees and quick recreations), and never when the entry lacks timestamps to prove idleness. Lineage rows cascade like removeWorktreeMeta. Co-authored-by: Orca * Fix GC false positives: folder-workspace instances, WSL paths on Windows From adversarial review of the GC commit: - Folder-project workspace instances are keyed repoId::path::workspace: ; splitting on the first :: made the existence probe test a path-with-suffix that never exists, deleting idle folder workspaces - whose meta IS the workspace record - and cascading into terminal- history deletion. Instance keys are now skipped entirely. - On Windows, WSL linked worktrees carry Linux-style paths from git porcelain that a Windows existsSync cannot probe; skip non-Windows paths on win32. - Idle-grace check now runs before existsSync (skips the stat fan-out for active entries and shrinks the slow-NFS startup tail), and a null worktreeMeta map in the file is normalized instead of throwing outside the parse guard. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 86 +++++++++++++++++++++++++++++++++++ src/main/persistence.ts | 88 +++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 60ab8fb3a19..419016f6f83 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4584,6 +4584,92 @@ describe('Store', () => { expect(statSync(dataFile()).ino).toBe(inoBefore) }) + // ── worktreeMeta startup GC ──────────────────────────────────────── + + it('garbage-collects stale local worktreeMeta at load with a 30-day grace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const RECENT = Date.now() - 1 * 24 * 60 * 60 * 1000 + const missing = (name: string): string => join(testState.dir, 'gone', name) + const meta = (lastActivityAt: number, extra: Record = {}) => ({ + displayName: '', + comment: '', + lastActivityAt, + ...extra + }) + const liveKey = `r1::${testState.dir}` + const deadKey = `r1::${missing('dead')}` + const recentKey = `r1::${missing('recent')}` + const sshKey = `ssh-repo::/home/alice/gone` + const remoteHostKey = `r1::${missing('remote-host')}` + const orphanKey = `removed-repo::${missing('orphan')}` + const wslKey = `r1::\\\\wsl$\\Ubuntu\\home\\gone` + + writeDataFile({ + repos: [ + makeRepo(), + makeRepo({ id: 'ssh-repo', path: '/home/alice/repo', connectionId: 'conn-1' }) + ], + worktreeMeta: { + [liveKey]: meta(OLD), + [deadKey]: meta(OLD), + [recentKey]: meta(RECENT), + [sshKey]: meta(OLD), + [remoteHostKey]: meta(OLD, { hostId: 'ssh:conn-1' }), + [orphanKey]: meta(OLD), + [wslKey]: meta(OLD) + }, + worktreeLineageById: { [deadKey]: { parentWorktreeId: liveKey } } + }) + + const store = await createStore() + const kept = Object.keys(store.getAllWorktreeMeta()) + + expect(kept).toContain(liveKey) // path exists + expect(kept).toContain(recentKey) // inside the grace window + expect(kept).toContain(sshKey) // SSH repo: remote paths never checked locally + expect(kept).toContain(remoteHostKey) // remote hostId on the meta itself + expect(kept).toContain(wslKey) // WSL UNC path + expect(kept).not.toContain(deadKey) + expect(kept).not.toContain(orphanKey) + expect(store.getWorktreeLineage(deadKey)).toBeUndefined() + }) + + it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111` + writeDataFile({ + repos: [makeRepo({ kind: 'folder' })], + worktreeMeta: { + [folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD } + } + }) + + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey) + }) + + it('never GCs Linux-style WSL worktree paths on Windows', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const wslLinkedKey = 'r1::/home/user/gone-worktree' + writeDataFile({ + repos: [makeRepo()], + worktreeMeta: { + [wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD } + } + }) + + await withPlatform('win32', async () => { + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey) + }) + }) + + it('tolerates a null worktreeMeta map in the durable file', async () => { + writeDataFile({ worktreeMeta: null }) + const store = await createStore() + expect(store.getAllWorktreeMeta()).toEqual({}) + }) + // ── GitHub cache sidecar ─────────────────────────────────────────── it('cache refreshes never rewrite the durable state file', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index fb25286488c..14f3da845bc 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -112,6 +112,7 @@ import { type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, makePaneKey, @@ -125,9 +126,14 @@ import { import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../shared/worktree-id' import { isPathInsideOrEqual, + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' @@ -335,6 +341,82 @@ function getGithubCacheFile(): string { return join(dirname(getDataFile()), 'orca-github-cache.json') } +// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf, +// agent scripts) purge renderer session state but nothing removed their +// worktreeMeta, so the map grew monotonically (63% dead entries measured on +// a heavy install). GC is deliberately narrow: local-host entries only +// (SSH/runtime metas embed remote paths a local existsSync would falsely +// condemn; WSL UNC paths are skipped the same way), and only after a +// 30-day idle grace so pushTarget cleanup for recently-vanished worktrees +// and quick recreations keep their metadata. +const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 + +function gcStaleWorktreeMeta(state: PersistedState): number { + // Why: a hand-corrupted file with `"worktreeMeta": null` overrides the + // defaults merge; normalize instead of throwing outside the parse guard. + state.worktreeMeta ??= {} + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const projectIds = new Set((state.projects ?? []).map((project) => project.id)) + const now = Date.now() + let removed = 0 + for (const key of Object.keys(state.worktreeMeta)) { + // Why: folder-project workspace instances are keyed + // `repoId::path::workspace:` and their meta IS the workspace + // record — never a filesystem-checkout row. Skip them entirely. + if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { + continue + } + const separator = key.indexOf('::') + if (separator === -1) { + continue + } + const ownerId = key.slice(0, separator) + const worktreePath = key.slice(separator + 2) + const meta = state.worktreeMeta[key] + const repo = repoById.get(ownerId) + if (repo) { + if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + } else if (projectIds.has(ownerId)) { + // Project-owned metas keep project/host semantics on the entry itself; + // stay conservative and leave them to their own lifecycle. + continue + } + // Unowned entries (repo removed before removeProject pruned metas) fall + // through to the same missing-path + idle-grace gate. + if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { + continue + } + if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { + continue + } + // Why: WSL linked worktrees on Windows carry Linux-style paths from git + // porcelain; a Windows existsSync cannot probe those and would falsely + // condemn live worktrees. + if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { + continue + } + // Why keep timestamp-less entries: without lastActivityAt/createdAt we + // cannot prove the 30-day idle grace elapsed; the measured dead entries + // all carry timestamps, so this costs almost nothing in reclaimed bytes. + // Grace runs before the stat so healthy profiles skip the existsSync + // fan-out (and its slow-NFS tail) for active entries entirely. + const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) + if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { + continue + } + if (existsSync(worktreePath)) { + continue + } + delete state.worktreeMeta[key] + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + removed++ + } + return removed +} + function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { try { const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown @@ -3322,6 +3404,10 @@ export class Store { } result = folderScopeConnectionMigration.state + if (gcStaleWorktreeMeta(result) > 0) { + this.loadNeedsSave = true + } + const migrated = this.migrateTelemetry(result, fileExistedOnLoad) // githubCache lives in a sidecar file now (see getGithubCacheFile). A From dbf80864feee7d769fdbd0d2d5cb115f0fa02d78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:58:11 +0000 Subject: [PATCH 037/233] release: v1.4.118-rc.0 [rc-slot:2026-07-02-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88f598a7d08..3f8d4cf4f96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.117-rc.3", + "version": "1.4.118-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 85cec26773196cfd2d08923f412150cfa76ade9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:54:19 -0700 Subject: [PATCH 038/233] Prevent unsigned Windows app releases (#6806) * Verify Windows app executable signing * Isolate Windows signing verifier tests * Handle direct Windows installer extraction --------- Co-authored-by: Neil --- .github/workflows/release-cut.yml | 45 ++++ ...package-electron-runtime-contract.test.mjs | 47 ++++ .../verify-windows-inner-signature.mjs | 200 +++++++++++++++++ .../verify-windows-inner-signature.test.mjs | 211 ++++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 config/scripts/verify-windows-inner-signature.mjs create mode 100644 config/scripts/verify-windows-inner-signature.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index df152e9fb31..957ae1704fa 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1062,6 +1062,51 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint + - name: Verify signed Windows inner executable + if: matrix.platform == 'win' + shell: pwsh + env: + ORCA_WINDOWS_EXPECTED_SIGNERS: CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US + run: | + # Why: SignPath must recursively sign nested PE files in the dashboard + # artifact configuration; this gate keeps unsigned app payloads out of releases. + $installer = Join-Path -Path (Get-Location) -ChildPath 'dist/orca-windows-setup.exe' + $setupExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-setup' + $appExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-app' + + Remove-Item -LiteralPath $setupExtractDir, $appExtractDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $setupExtractDir, $appExtractDir -Force | Out-Null + + & 7z x -y "-o$setupExtractDir" $installer + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract signed Windows installer with 7z. Exit code: $LASTEXITCODE" + } + + # Why: 7-Zip can expose electron-builder NSIS payloads either as an + # app-root tree directly or as a nested app-*.7z archive. + $innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe') + if ($innerExecutables.Count -eq 0) { + $appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z') + if ($appArchives.Count -ne 1) { + $matches = ($appArchives | ForEach-Object { $_.FullName }) -join [Environment]::NewLine + throw "Expected app-root Orca.exe or exactly one app-*.7z payload in signed Windows installer; found $($appArchives.Count) app archive(s).$([Environment]::NewLine)$matches" + } + + & 7z x -y "-o$appExtractDir" $($appArchives[0].FullName) + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract signed Windows app payload with 7z. Exit code: $LASTEXITCODE" + } + + $innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe') + } + + if ($innerExecutables.Count -ne 1) { + $matches = ($innerExecutables | ForEach-Object { $_.FullName }) -join [Environment]::NewLine + throw "Expected exactly one app-root Orca.exe in signed Windows app payload; found $($innerExecutables.Count).$([Environment]::NewLine)$matches" + } + + node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName) + - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 5ee9f1a3017..d3b3e1226a6 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -217,6 +217,53 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) + it('verifies the SignPath-signed Windows inner executable before publishing', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') + const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable') + const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') + + expect(outerVerifyIndex).toBeGreaterThan(-1) + expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) + expect(innerVerifyIndex).toBeLessThan(publishIndex) + + const innerVerifyStep = steps[innerVerifyIndex] + const innerVerifyRun = innerVerifyStep.run + + expect(innerVerifyStep.if).toBe("matrix.platform == 'win'") + expect(innerVerifyStep.shell).toBe('pwsh') + expect(innerVerifyStep.env.ORCA_WINDOWS_EXPECTED_SIGNERS).toBe( + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US' + ) + expect(innerVerifyRun).toContain('& 7z x -y "-o$setupExtractDir" $installer') + expect(innerVerifyRun).toContain( + "$innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe')" + ) + expect(innerVerifyRun).toContain('if ($innerExecutables.Count -eq 0)') + expect(innerVerifyRun).toContain( + "$appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z')" + ) + expect(innerVerifyRun).toContain('if ($appArchives.Count -ne 1)') + expect(innerVerifyRun).toContain('Expected app-root Orca.exe or exactly one app-*.7z') + expect(innerVerifyRun).toContain('& 7z x -y "-o$appExtractDir" $($appArchives[0].FullName)') + expect(innerVerifyRun).toContain( + "$innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe')" + ) + expect(innerVerifyRun.indexOf("-Filter 'Orca.exe'")).toBeLessThan( + innerVerifyRun.indexOf("-Filter 'app-*.7z'") + ) + expect(innerVerifyRun).toContain('if ($innerExecutables.Count -ne 1)') + expect(innerVerifyRun).toContain( + 'node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName)' + ) + }) + it('publishes both Linux release matrix entries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs new file mode 100644 index 00000000000..0f3520c1dee --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -0,0 +1,200 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +export const DEFAULT_EXPECTED_SIGNER = + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + +const POWERSHELL_SIGNATURE_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -FilePath $args[0] +$certificate = $signature.SignerCertificate +[pscustomobject]@{ + status = $signature.Status.ToString() + statusMessage = $signature.StatusMessage + signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject } + signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer } + signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint } + notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') } + notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') } +} | ConvertTo-Json -Compress +` + +export function normalizeSignerSubject(subject) { + if (typeof subject !== 'string') { + return '' + } + + return subject + .split(',') + .map((part) => part.trim().replace(/\s*=\s*/u, '=')) + .filter(Boolean) + .join(', ') +} + +export function normalizeThumbprint(thumbprint) { + if (typeof thumbprint !== 'string') { + return '' + } + + return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase() +} + +export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) { + const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER + + return source + .split(/[\r\n;]+/u) + .map(normalizeSignerSubject) + .filter(Boolean) +} + +export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) { + if (typeof value !== 'string' || value.trim() === '') { + return [] + } + + return value + .split(/[\r\n,;]+/u) + .map(normalizeThumbprint) + .filter(Boolean) +} + +export function parseSignatureJson(stdout) { + const trimmed = typeof stdout === 'string' ? stdout.trim() : '' + if (trimmed === '') { + throw new Error('PowerShell did not return signature JSON.') + } + + try { + return JSON.parse(trimmed) + } catch (error) { + throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`) + } +} + +export function classifySignature(signature, options = {}) { + const expectedSigners = options.expectedSigners ?? parseExpectedSigners() + const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints() + const status = typeof signature?.status === 'string' ? signature.status : '' + const signerSubject = normalizeSignerSubject(signature?.signerSubject) + const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint) + const subjectAllowed = expectedSigners.includes(signerSubject) + const thumbprintAllowed = + expectedThumbprints.length > 0 && + signerThumbprint !== '' && + expectedThumbprints.includes(signerThumbprint) + + if (status !== 'Valid') { + return { + ok: false, + message: `Windows inner executable signature status is ${status || ''}.`, + signature + } + } + + if (!subjectAllowed && !thumbprintAllowed) { + return { + ok: false, + message: `Unexpected Windows inner executable signer: ${signerSubject || ''}.`, + signature + } + } + + return { ok: true, signature } +} + +export function formatSignatureSummary(signature) { + return [ + `Status: ${signature.status ?? ''}`, + `Subject: ${normalizeSignerSubject(signature.signerSubject) || ''}`, + `Issuer: ${signature.signerIssuer ?? ''}`, + `Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || ''}`, + `NotBefore: ${signature.notBefore ?? ''}`, + `NotAfter: ${signature.notAfter ?? ''}` + ].join('\n') +} + +export function validateExecutablePath(executablePath) { + if (typeof executablePath !== 'string' || executablePath.trim() === '') { + throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ') + } + + if (!existsSync(executablePath)) { + throw new Error(`Windows inner executable does not exist: ${executablePath}`) + } + + if (!statSync(executablePath).isFile()) { + throw new Error(`Windows inner executable path is not a file: ${executablePath}`) + } +} + +export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) { + const result = spawnSyncImpl( + 'pwsh', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + POWERSHELL_SIGNATURE_SCRIPT, + executablePath + ], + { encoding: 'utf8' } + ) + + if (result.error) { + throw result.error + } + + if (result.stderr?.trim()) { + throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`) + } + + if (result.status !== 0) { + throw new Error( + `PowerShell signature check failed with exit code ${result.status ?? ''}.` + ) + } + + return result.stdout +} + +export function verifyWindowsInnerSignature({ + executablePath, + platform = process.platform, + spawnSyncImpl = spawnSync, + expectedSigners = parseExpectedSigners(), + expectedThumbprints = parseExpectedThumbprints() +}) { + validateExecutablePath(executablePath) + + if (platform !== 'win32') { + throw new Error('Windows inner executable signature verification requires Windows.') + } + + const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl)) + const classification = classifySignature(signature, { expectedSigners, expectedThumbprints }) + if (!classification.ok) { + throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`) + } + + return signature +} + +export function main(argv = process.argv.slice(2)) { + try { + const signature = verifyWindowsInnerSignature({ executablePath: argv[0] }) + console.log('Verified Windows inner executable signature.') + console.log(formatSignatureSummary(signature)) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs new file mode 100644 index 00000000000..aa0e1e7ae12 --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -0,0 +1,211 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + DEFAULT_EXPECTED_SIGNER, + classifySignature, + getPowerShellSignatureJson, + normalizeSignerSubject, + normalizeThumbprint, + parseExpectedSigners, + parseExpectedThumbprints, + parseSignatureJson, + validateExecutablePath, + verifyWindowsInnerSignature +} from './verify-windows-inner-signature.mjs' + +const validSignature = { + status: 'Valid', + statusMessage: 'Signature verified.', + signerSubject: DEFAULT_EXPECTED_SIGNER, + signerIssuer: 'CN=SignPath Foundation Root', + signerThumbprint: 'aa bb cc dd', + notBefore: '2026-01-01T00:00:00.0000000Z', + notAfter: '2027-01-01T00:00:00.0000000Z' +} + +function withTempFile(callback) { + const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-')) + const filePath = join(dir, 'Orca.exe') + writeFileSync(filePath, 'placeholder executable') + + try { + return callback(filePath, dir) + } finally { + rmSync(dir, { force: true, recursive: true }) + } +} + +describe('verify-windows-inner-signature', () => { + const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + + beforeEach(() => { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + }) + + afterEach(() => { + if (originalExpectedSigners === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + } else { + process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners + } + + if (originalExpectedThumbprints === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + } else { + process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints + } + }) + + it('normalizes signer subjects without widening exact matching', () => { + expect( + normalizeSignerSubject( + ' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US ' + ) + ).toBe(DEFAULT_EXPECTED_SIGNER) + expect( + normalizeSignerSubject( + 'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + ) + ).not.toBe(DEFAULT_EXPECTED_SIGNER) + }) + + it('defaults the expected signer allowlist to SignPath Foundation', () => { + expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER]) + expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER]) + }) + + it('parses semicolon and newline separated signer allowlists', () => { + expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([ + DEFAULT_EXPECTED_SIGNER, + 'CN=Backup, O=Backup' + ]) + }) + + it('normalizes optional thumbprint allowlists', () => { + expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC') + expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233']) + }) + + it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => { + expect(() => validateExecutablePath('')).toThrow(/Usage:/) + expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow( + /does not exist/ + ) + + withTempFile((filePath, dir) => { + expect(() => validateExecutablePath(dir)).toThrow(/not a file/) + expect(() => validateExecutablePath(filePath)).not.toThrow() + }) + }) + + it('parses the exact JSON emitted by PowerShell', () => { + expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature) + expect(() => parseSignatureJson('')).toThrow(/did not return/) + expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow( + /malformed/ + ) + }) + + it('accepts a valid signature with an exact normalized signer subject', () => { + const result = classifySignature({ + ...validSignature, + signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US ' + }) + + expect(result.ok).toBe(true) + }) + + it('rejects invalid status and unexpected signer subjects', () => { + expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch( + /status is NotSigned/ + ) + expect( + classifySignature({ + ...validSignature, + signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT' + }).message + ).toMatch(/Unexpected Windows inner executable signer/) + }) + + it('accepts an expected thumbprint as an alternate explicit allowlist', () => { + expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['AABBCCDD'] + } + ).ok + ).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + } + ).message + ).toMatch(/Unexpected Windows inner executable signer/) + expect( + classifySignature(validSignature, { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + }).ok + ).toBe(true) + }) + + it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => { + const calls = [] + const spawnSyncImpl = (command, args, options) => { + calls.push({ command, args, options }) + return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' } + } + + expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe( + JSON.stringify(validSignature) + ) + expect(calls[0].command).toBe('pwsh') + expect(calls[0].args).toContain('-Command') + expect(calls[0].args.at(-1)).toBe('C:\\Path With Spaces\\Orca.exe') + expect(calls[0].options).toEqual({ encoding: 'utf8' }) + + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' })) + ).toThrow(/stderr/) + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' })) + ).toThrow(/exit code 7/) + }) + + it('verifies with injected Windows platform and spawn implementation', () => { + withTempFile((filePath) => { + const signature = verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'win32', + spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' }) + }) + + expect(signature).toEqual(validSignature) + }) + }) + + it('does not attempt real Authenticode verification outside Windows', () => { + withTempFile((filePath) => { + expect(() => + verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'linux', + spawnSyncImpl: () => { + throw new Error('should not spawn') + } + }) + ).toThrow(/requires Windows/) + }) + }) +}) From 1ac6de1ccb9e9348c2a0b90b0faa794f9b13fcad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:01:05 +0000 Subject: [PATCH 039/233] release: v1.4.118-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3f8d4cf4f96..7c5842845da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.0", + "version": "1.4.118-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4c03924618dfc444bfcf6ff7824604ea7bbfe271 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:24:07 -0700 Subject: [PATCH 040/233] Show Git-created worktrees in external discovery (#7078) --- src/main/ipc/worktrees.ts | 7 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/worktree-removal-authority.test.ts | 49 +++++----- src/main/worktree-removal-safety.test.ts | 2 - src/main/worktree-removal-safety.ts | 13 +-- src/shared/external-worktree-inbox.test.ts | 82 +++++++++++++++- ...ktree-ownership-worktree-base-path.test.ts | 4 +- src/shared/worktree-ownership.test.ts | 96 ++++++++++++++++++- src/shared/worktree-ownership.ts | 49 +--------- 9 files changed, 209 insertions(+), 100 deletions(-) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 890449f51b6..c7ff8f45c82 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1402,13 +1402,9 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -1481,7 +1477,6 @@ export function registerWorktreeHandlers( runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 819fb34ea69..69dbea1eb3b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14713,13 +14713,9 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -14792,7 +14788,6 @@ export class OrcaRuntimeService { runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index acb8d2f3f3c..cbc9e2ed24c 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -4,6 +4,7 @@ import { isWorktreePathMissing, stripOrcaProvenanceMetaUpdates } from './worktree-removal-safety' +import type { WorktreeMeta } from '../shared/types' describe('isWorktreePathMissing', () => { it('recognizes missing-path errors from local and remote stat providers', async () => { @@ -33,10 +34,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('does not treat orcaCreatedAt alone as cleanup authority', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { orcaCreatedAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { orcaCreatedAt: Date.now() } }) ).toBe(false) expect( @@ -44,10 +42,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { meta: { orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' - }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + } }) ).toBe(true) }) @@ -55,32 +50,40 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('accepts legacy Orca-created metadata before explicit provenance existed', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { createdAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { createdAt: Date.now() } }) ).toBe(true) }) - it('accepts legacy repo-nested Orca workspace paths without metadata provenance', () => { + it('does not treat creation layout metadata alone as cleanup authority', () => { + const layoutOnlyMeta: WorktreeMeta = { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + } + expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/app/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: true }] + meta: layoutOnlyMeta }) - ).toBe(true) + ).toBe(false) }) - it('does not trust flat workspace-root paths without legacy metadata', () => { + it('does not trust paths without provenance or legacy metadata', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: false }] + meta: undefined }) ).toBe(false) }) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index d008b952089..5a64b8c90e5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -325,7 +325,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { runtimeWorktreePath: '/workspaces/orca-owned', repo, runtimeRepoPath: repo.path, - knownOrcaLayouts: [], registeredWorktrees: [makeGitWorktree(repo.path, true)] } @@ -376,7 +375,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { canCleanupUnregisteredOrcaLeftoverDirectory({ ...baseArgs, meta: undefined, - knownOrcaLayouts: [{ path: '/workspaces', nestWorkspaces: false }], statPath: makeStatPath([], ['/workspaces/orca-owned']), isGitRepository }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a8236ea4908..a589bce9e10 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,8 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { GitWorktreeInfo, OrcaWorkspaceLayout, Repo, WorktreeMeta } from '../shared/types' -import { matchesStrongOrcaCreatePath } from '../shared/worktree-ownership' +import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, @@ -172,9 +171,6 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { meta: UnregisteredOrcaCleanupMeta | null | undefined - worktreePath: string - repo: Pick - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] }): boolean { if (hasCurrentOrcaCreationProvenance(args.meta)) { return true @@ -184,9 +180,9 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return true } - // Why: profiles created before explicit provenance can still contain Orca - // workspaces at the repo-specific workspaceDir// path shape. - return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) + // Why: path shape alone is not authority; users can create plain Git + // worktrees inside Orca's workspace directory too. + return false } export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { @@ -195,7 +191,6 @@ export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { runtimeWorktreePath: string repo: Pick runtimeRepoPath: string - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] registeredWorktrees: readonly GitWorktreeInfo[] statPath: StatPath isGitRepository: (runtimeWorktreePath: string) => Promise diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 029e04c3959..1e16c6d5134 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + GlobalSettings, + Repo, + Worktree +} from './types' import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' -import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership' +import { + buildKnownOrcaWorkspaceLayouts, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + toDetectedWorktree +} from './worktree-ownership' const repo: Repo = { id: 'repo-1', @@ -54,6 +64,55 @@ function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResu } } +function makeSettings(): GlobalSettings { + return { + workspaceDir: '/orca/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + branchPrefix: 'none', + branchPrefixCustom: '', + enableGitHubAttribution: false, + theme: 'system', + appFontFamily: 'Geist', + editorAutoSave: false, + editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, + markdownReviewToolsEnabled: true, + terminalFontSize: 14, + terminalFontFamily: 'monospace', + terminalFontWeight: 400, + terminalLineHeight: 1.2 + } as unknown as GlobalSettings +} + +function makeGitWorktree(overrides: Partial = {}): Worktree { + return { + id: `repo-1::${overrides.path ?? '/repo'}`, + repoId: repo.id, + path: '/repo', + displayName: 'repo', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + ...overrides + } +} + describe('external worktree inbox', () => { it('merges inbox paths without duplicates when paths match after normalization', () => { expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([ @@ -103,6 +162,25 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { + const settings = makeSettings() + const manual = toDetectedWorktree({ + repo, + settings, + worktree: makeGitWorktree({ + path: '/orca/workspaces/orca/manual-from-git', + displayName: 'manual-from-git', + branch: 'refs/heads/manual-from-git', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(manual.ownership).toBe('external') + expect(manual.visible).toBe(false) + expect(getNewExternalWorktreeInboxWorktrees(detectedResult([manual]), repo)).toEqual([manual]) + }) + it('suppresses non-authoritative detected results', () => { expect( getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), { diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts index 968b1f3dab2..d6af4dab77d 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -48,7 +48,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('/projects/a/worktrees/repo/feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo: repoB, @@ -73,7 +73,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c7c77ea5937..0b41859d28b 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -108,7 +108,7 @@ describe('worktree ownership classification', () => { ).toBe('orca-managed') }) - it('requires the nested repo-specific path shape for path-only ownership', () => { + it('treats nested Orca workspace paths without metadata as external', () => { const repo = makeRepo() const settings = makeSettings() const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -119,7 +119,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), knownOrcaLayouts: layouts }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo, @@ -130,6 +130,94 @@ describe('worktree ownership classification', () => { ).toBe('external') }) + it('treats explicit Orca creation layout metadata as managed', () => { + const repo = makeRepo() + const settings = makeSettings() + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), + meta: makeMeta({ + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + meta: makeMeta({ displayName: 'manual-git-worktree' }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => { + const repo = makeRepo() + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(true) + }) + + it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: true + }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => { const repo = makeRepo() const settings = makeSettings({ nestWorkspaces: false }) @@ -172,7 +260,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/old/workspaces/app/feature' }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('builds known layouts from large workspace history lists', () => { @@ -214,7 +302,7 @@ describe('worktree ownership classification', () => { }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('keeps selected linked checkouts visible without trusting Git main-worktree', () => { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9140f41795b..94ba1d908ff 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,5 +1,4 @@ import { - getRuntimePathBasename, isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, @@ -160,15 +159,13 @@ export function classifyWorktreeOwnership(args: { return 'orca-managed' } - if (matchesStrongOrcaCreatePath(args.worktree.path, args.knownOrcaLayouts, args.repo)) { - return 'orca-managed' - } - if (isUnderFlatOrUntrustedOrcaRoot(args.worktree.path, args.knownOrcaLayouts)) { return 'unknown-legacy' } if (canClassifyAsExternal(args.worktree.path, args.knownOrcaLayouts)) { + // Why: a plain `git worktree add` can target Orca's nested workspace + // folder. Only metadata proves Orca created it. return 'external' } @@ -240,6 +237,7 @@ export function areRuntimePathsEqual(leftPath: string, rightPath: string): boole function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { return Boolean( meta?.orcaCreatedAt || + meta?.orcaCreationWorkspaceLayout || meta?.createdAt || meta?.createdWithAgent || meta?.pushTarget || @@ -249,38 +247,6 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { ) } -export function matchesStrongOrcaCreatePath( - worktreePath: string, - knownOrcaLayouts: readonly OrcaWorkspaceLayout[], - repo: Pick -): boolean { - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '') - if (!repoName) { - return false - } - for (const layout of knownOrcaLayouts) { - if (!layout.nestWorkspaces) { - continue - } - const relative = relativePathInsideRoot(layout.path, worktreePath) - if (relative === null) { - continue - } - const segments = splitNormalizedPath(relative) - const caseInsensitive = - isWindowsAbsolutePathLike(layout.path) || isWindowsAbsolutePathLike(worktreePath) - if ( - segments.length === 2 && - normalizePathSegment(segments[0], caseInsensitive) === - normalizePathSegment(repoName, caseInsensitive) && - segments[1].length > 0 - ) { - return true - } - } - return false -} - function isUnderFlatOrUntrustedOrcaRoot( worktreePath: string, knownOrcaLayouts: OrcaWorkspaceLayout[] @@ -313,12 +279,3 @@ function canClassifyAsExternal( } return true } - -function splitNormalizedPath(value: string): string[] { - return normalizeRuntimePathSeparators(value).split('/').filter(Boolean) -} - -function normalizePathSegment(value: string, caseInsensitive: boolean): string { - const normalized = normalizeRuntimePathSeparators(value) - return caseInsensitive ? normalized.toLowerCase() : normalized -} From fc17c57b6bef355f9860f359c1c3851bb7deff6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:42:44 -0700 Subject: [PATCH 041/233] Fix Windows SignPath PSGallery preflight (#7135) * Verify Windows app executable signing * Isolate Windows signing verifier tests * Handle direct Windows installer extraction * Fix SignPath PowerShell gallery preflight --------- Co-authored-by: Neil --- .github/workflows/release-cut.yml | 5 +++++ config/scripts/package-electron-runtime-contract.test.mjs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 957ae1704fa..2822b71e9dc 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -889,6 +889,11 @@ jobs: if: matrix.platform == 'win' shell: pwsh run: | + # Why: Some hosted Windows images start without PSGallery registered. + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index d3b3e1226a6..e18765e99d1 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => { expect(installStep.if).toBe("matrix.platform == 'win'") expect(installStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) expect(installRun).toContain( From 33fda893e968e7fdcf519d86c926ace4ddf897f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:43:30 +0000 Subject: [PATCH 042/233] release: v1.4.118-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c5842845da..eda77782c2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.1", + "version": "1.4.118-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 568a86919563ddf1441ae451d29308f58f400278 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:53 +0000 Subject: [PATCH 043/233] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 79f778f0a38..f83dbfcb7ae 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.2m + + downloads: 3.3m @@ -15,7 +15,7 @@ downloads downloads - 3.2m - 3.2m + 3.3m + 3.3m From 186f6c9e3de878b08cc90d998744e91d7f3bb6ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:27 -0700 Subject: [PATCH 044/233] Fix stale terminal frames on worktree return: clear alt screen on snapshot restore + WebGL reveal hardening (#7133) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 15 +- .../terminal-pane/pty-connection.ts | 7 + .../terminal-visibility-resume.test.ts | 85 ++++++++++ .../terminal-visibility-resume.ts | 5 + .../use-terminal-pane-global-effects.test.ts | 20 ++- .../src/lib/pane-manager/pane-manager.ts | 8 + .../pane-manager/pane-reveal-repaint.test.ts | 147 ++++++++++++++++++ .../lib/pane-manager/pane-reveal-repaint.ts | 39 +++++ .../pane-manager/pane-webgl-renderer.test.ts | 126 +++++++++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 38 +++-- 10 files changed, 478 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts 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 cd806774737..211e6c1c819 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7316,7 +7316,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => { + it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -7365,6 +7365,19 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) + // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized + // frames skip blank cells, so the pre-hide frame bleeds through unless the + // alt screen is cleared (without \x1b[3J) before the snapshot paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049h\x1b[2J\x1b[H', + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + writes.indexOf('altscreen-snapshot\r\n') + ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 85c50f6fe8a..01f0132c3fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4393,6 +4393,13 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts new file mode 100644 index 00000000000..d4b9762163a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + recoverVisibleTerminalWindowWake, + resumeTerminalVisibility +} from './terminal-visibility-resume' + +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn() +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: vi.fn(), + requestTerminalBacklogRecovery: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: vi.fn() +})) +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + focusActivePane: vi.fn() +})) +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery: vi.fn() +})) + +type FakeManager = { + getPanes: ReturnType + resumeRendering: ReturnType + scheduleRevealRepaint: ReturnType +} + +function createManager(order: string[] = []): FakeManager { + return { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(() => order.push('resume-rendering')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + } +} + +function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { + return { + manager: manager as never as PaneManager, + isActive: true, + wasVisible: false, + shouldUseLightTabResume, + captureViewportPositions: vi.fn(() => new Map()), + withSuppressedScrollTracking: (callback: () => void) => callback() + } +} + +describe('resumeTerminalVisibility reveal repaint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('schedules a pane-scoped repaint on a light tab reveal', () => { + // The light path is the "click the tab that was not open" gesture: it has + // no rendering resume or fit, so without this repaint a hidden-while- + // working pane keeps compositing pre-hide pixels. + const manager = createManager() + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.resumeRendering).not.toHaveBeenCalled() + }) + + it('schedules the repaint after rendering resumes on a heavy reveal', () => { + const order: string[] = [] + const manager = createManager(order) + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(order).toEqual(['resume-rendering', 'reveal-repaint']) + }) + + it('schedules the repaint on window-wake recovery', () => { + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false + }) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 408f8afe952..fd9ff1260f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -75,6 +75,10 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases() } + // Why: the synchronous recovery above can fire before the revealed pane is + // attached and laid out, where the WebGL renderer drops redraw requests + // without retry. Follow up with a settled-frame, pane-scoped repaint. + manager.scheduleRevealRepaint() }) } @@ -133,6 +137,7 @@ export function recoverVisibleTerminalWindowWake({ } enforceTerminalViewportIntents(manager) resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() } function requestLightTabBacklogRecovery(manager: PaneManager): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 61dc3a1533d..0ad8f80d141 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -130,6 +130,7 @@ function useMountForFileDrop( getPanes: ReturnType resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType + scheduleRevealRepaint: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -146,6 +147,7 @@ function useMountForFileDrop( getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -222,6 +224,7 @@ describe('useTerminalPaneGlobalEffects', () => { ]), resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -280,7 +283,8 @@ describe('useTerminalPaneGlobalEffects', () => { 'intent:terminal-a', 'intent:terminal-b', 'reset-atlas', - 'refresh' + 'refresh', + 'reveal-repaint' ]) expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled() expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, { @@ -308,6 +312,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -387,6 +392,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -448,6 +454,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -501,6 +508,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -578,6 +586,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -609,6 +618,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -667,6 +677,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -711,6 +722,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -775,6 +787,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -829,6 +842,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -874,6 +888,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -929,6 +944,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1044,6 +1060,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1077,6 +1094,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 4824af7cf57..61ff5a7d42d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -38,6 +38,7 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, splitManagedPane } from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' @@ -290,6 +291,13 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + scheduleRevealRepaint(): void { + // Why: the settled-frame callback can fire after destroy(); repainting + // disposed panes could throw in attach and latch the global WebGL + // attach backoff, downgrading unrelated new panes to the DOM renderer. + schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts new file mode 100644 index 00000000000..564a57e9875 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +type FakeWebglAddon = { clearTextureAtlas: ReturnType } + +function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { + const leafId = '33333333-3333-4333-8333-333333333333' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: (options.webglAddon ?? null) as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('schedulePaneRevealRepaint', () => { + let rafQueue: FrameRequestCallback[] + + function flushFrame(): void { + const queue = rafQueue + rafQueue = [] + for (const callback of queue) { + callback(16) + } + } + + beforeEach(() => { + resetTerminalWebglSuggestion() + rafQueue = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafQueue.push(callback) + return rafQueue.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('repaints only after the post-reveal frame has settled', () => { + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [pane]) + + // First frame: reveal layout may still be in flight; redraw requests fired + // here can be dropped by the renderer without retry. + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('reattaches a missing WebGL addon before repainting', () => { + const pane = createPane() + schedulePaneRevealRepaint(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + + it('resolves the pane list at repaint time, not at scheduling time', () => { + const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const panes = [stalePane] + schedulePaneRevealRepaint(() => panes) + panes.splice(0, panes.length, livePane) + + flushFrame() + flushFrame() + + expect( + (stalePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).not.toHaveBeenCalled() + expect( + (livePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).toHaveBeenCalledTimes(1) + }) + + it('keeps repainting remaining panes when one pane throws', () => { + const explosivePane = { + get gpuRenderingEnabled(): boolean { + throw new Error('pane torn down mid-frame') + } + } as never as ManagedPaneInternal + const webglAddon = { clearTextureAtlas: vi.fn() } + const livePane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [explosivePane, livePane]) + + flushFrame() + flushFrame() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(livePane.terminal.refresh).toHaveBeenCalled() + }) + + it('falls back to a timeout when animation frames are unavailable', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + + schedulePaneRevealRepaint(() => [pane]) + vi.runAllTimers() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts new file mode 100644 index 00000000000..48df07b9b0c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -0,0 +1,39 @@ +import type { ManagedPaneInternal } from './pane-manager-types' +import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { resetWebglTextureAtlas } from './pane-webgl-renderer' + +function scheduleSettledFrame(callback: () => void): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + globalThis.setTimeout(callback, 0) + return + } + // Why: the first frame after a reveal can still be laying out the tab + // overlay; the WebGL renderer silently drops redraw requests until the pane + // is attached and measured, so repaint on the frame after layout settles. + globalThis.requestAnimationFrame(() => { + globalThis.requestAnimationFrame(callback) + }) +} + +/** + * Repaints a revealed tab's panes from their xterm buffers. + * + * Why: while a pane is hidden, parsed output can update the WebGL renderer's + * per-cell model without ever presenting a frame. At reveal the model diff + * reports those cells unchanged, so plain refreshes skip them and the canvas + * keeps compositing pre-hide pixels until a selection or resize rebuilds the + * model. Clearing the model per pane — after (re)attach, once layout has + * settled — forces a full rebuild from the buffer without any PTY resize. + */ +export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts new file mode 100644 index 00000000000..26919ed4d2d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebglAddon } from '@xterm/addon-webgl' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + attachWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '22222222-2222-4222-8222-222222222222' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('terminal WebGL addon lifecycle', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('disposes a live addon when attach bails instead of orphaning it', () => { + const pane = createPane() + attachWebgl(pane) + const liveAddon = pane.webglAddon + expect(liveAddon).not.toBeNull() + const disposeSpy = vi.spyOn(liveAddon as WebglAddon, 'dispose') + + // An undisposed addon here kept painting stale frames while atlas resets, + // reattach checks, and diagnostics all treated the pane as DOM-rendered. + pane.webglAttachmentDeferred = true + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes the previous addon before attaching a replacement', () => { + const pane = createPane() + attachWebgl(pane) + const firstAddon = pane.webglAddon + expect(firstAddon).not.toBeNull() + const disposeSpy = vi.spyOn(firstAddon as WebglAddon, 'dispose') + + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).not.toBeNull() + expect(pane.webglAddon).not.toBe(firstAddon) + }) + + it('disposes the constructed addon when loading it fails', () => { + const disposeSpy = vi.spyOn(WebglAddon.prototype, 'dispose') + const pane = createPane({ + loadAddon: () => { + throw new Error('WebGL2 not supported null') + } + }) + + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) + + it('still refreshes the terminal when resetting a pane without a WebGL addon', () => { + const pane = createPane() + expect(pane.webglAddon).toBeNull() + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('skips the reset while WebGL is latched off after a context loss', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index b84ba23590b..cca7cc9dd17 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -47,10 +47,13 @@ function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { } export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void { - if (pane.pendingWebglRefreshRafId != null) { - cancelAnimationFrame(pane.pendingWebglRefreshRafId) - pane.pendingWebglRefreshRafId = null + if (pane.pendingWebglRefreshRafId == null) { + return } + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(pane.pendingWebglRefreshRafId) + } + pane.pendingWebglRefreshRafId = null } export function disposeWebgl( @@ -87,14 +90,16 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void { } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) { + if (pane.webglDisabledAfterContextLoss) { return } try { // Why: rapid TUI redraws can corrupt xterm's WebGL glyph atlas without a // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. - pane.webglAddon.clearTextureAtlas() + pane.webglAddon?.clearTextureAtlas() + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { /* ignore — pane may have been disposed in the meantime */ @@ -110,12 +115,20 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss || webglAttachFailedSinceRecovery ) { - pane.webglAddon = null + // Why: nulling the reference here used to leak a still-loaded addon that + // kept painting stale frames while every recovery path (atlas reset, + // reattach, diagnostics) treated the pane as DOM-rendered. Dispose so the + // pane genuinely falls back to the DOM renderer. + disposeWebgl(pane, { refreshDimensions: true }) return } + // Single-addon invariant: never stack a second addon on a live one. + disposeWebgl(pane) + let webglAddon: WebglAddon | null = null try { - const webglAddon = new WebglAddon() - webglAddon.onContextLoss(() => { + webglAddon = new WebglAddon() + const addon = webglAddon + addon.onContextLoss(() => { console.warn( '[terminal] WebGL context lost for pane', pane.id, @@ -128,8 +141,8 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) - pane.terminal.loadAddon(webglAddon) - pane.webglAddon = webglAddon + pane.terminal.loadAddon(addon) + pane.webglAddon = addon refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { @@ -140,6 +153,11 @@ export function attachWebgl(pane: ManagedPaneInternal): void { webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) + try { + webglAddon?.dispose() + } catch { + /* ignore — a half-constructed addon may throw on dispose */ + } pane.webglAddon = null } } From d2d3e90c960979dd9380668d6c4d012e62e3684f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:58 -0700 Subject: [PATCH 045/233] Fix Windows inner signature PowerShell path (#7136) Co-authored-by: Neil --- config/scripts/verify-windows-inner-signature.mjs | 14 ++++++++++---- .../verify-windows-inner-signature.test.mjs | 11 +++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs index 0f3520c1dee..e0c5e303dbc 100644 --- a/config/scripts/verify-windows-inner-signature.mjs +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -7,7 +7,7 @@ export const DEFAULT_EXPECTED_SIGNER = const POWERSHELL_SIGNATURE_SCRIPT = String.raw` $ErrorActionPreference = 'Stop' -$signature = Get-AuthenticodeSignature -FilePath $args[0] +$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE $certificate = $signature.SignerCertificate [pscustomobject]@{ status = $signature.Status.ToString() @@ -130,6 +130,7 @@ export function validateExecutablePath(executablePath) { } export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) { + // Why: pwsh -Command does not reliably expose trailing process args to string commands. const result = spawnSyncImpl( 'pwsh', [ @@ -139,10 +140,15 @@ export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawn '-ExecutionPolicy', 'Bypass', '-Command', - POWERSHELL_SIGNATURE_SCRIPT, - executablePath + POWERSHELL_SIGNATURE_SCRIPT ], - { encoding: 'utf8' } + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_WINDOWS_INNER_EXECUTABLE: executablePath + } + } ) if (result.error) { diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs index aa0e1e7ae12..d833fb64774 100644 --- a/config/scripts/verify-windows-inner-signature.test.mjs +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -172,8 +172,15 @@ describe('verify-windows-inner-signature', () => { ) expect(calls[0].command).toBe('pwsh') expect(calls[0].args).toContain('-Command') - expect(calls[0].args.at(-1)).toBe('C:\\Path With Spaces\\Orca.exe') - expect(calls[0].options).toEqual({ encoding: 'utf8' }) + expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe') + expect(calls[0].options).toEqual( + expect.objectContaining({ + encoding: 'utf8', + env: expect.objectContaining({ + ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe' + }) + }) + ) expect(() => getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' })) From 6bca68ae09b75147e657ad5c894e4c3bbec3c4f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:13:04 +0000 Subject: [PATCH 046/233] release: v1.4.118-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eda77782c2b..73cef4c1637 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.2", + "version": "1.4.118-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 49a8e805265f74e82318148740514e8e8067f525 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:34:35 -0700 Subject: [PATCH 047/233] Temporarily bypass Windows inner signature gate (#7137) Co-authored-by: Neil --- .github/workflows/release-cut.yml | 45 ------------------- ...package-electron-runtime-contract.test.mjs | 36 ++------------- 2 files changed, 3 insertions(+), 78 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 2822b71e9dc..a7b8fe1814b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1067,51 +1067,6 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint - - name: Verify signed Windows inner executable - if: matrix.platform == 'win' - shell: pwsh - env: - ORCA_WINDOWS_EXPECTED_SIGNERS: CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US - run: | - # Why: SignPath must recursively sign nested PE files in the dashboard - # artifact configuration; this gate keeps unsigned app payloads out of releases. - $installer = Join-Path -Path (Get-Location) -ChildPath 'dist/orca-windows-setup.exe' - $setupExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-setup' - $appExtractDir = Join-Path -Path $env:RUNNER_TEMP -ChildPath 'orca-signed-app' - - Remove-Item -LiteralPath $setupExtractDir, $appExtractDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path $setupExtractDir, $appExtractDir -Force | Out-Null - - & 7z x -y "-o$setupExtractDir" $installer - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract signed Windows installer with 7z. Exit code: $LASTEXITCODE" - } - - # Why: 7-Zip can expose electron-builder NSIS payloads either as an - # app-root tree directly or as a nested app-*.7z archive. - $innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe') - if ($innerExecutables.Count -eq 0) { - $appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z') - if ($appArchives.Count -ne 1) { - $matches = ($appArchives | ForEach-Object { $_.FullName }) -join [Environment]::NewLine - throw "Expected app-root Orca.exe or exactly one app-*.7z payload in signed Windows installer; found $($appArchives.Count) app archive(s).$([Environment]::NewLine)$matches" - } - - & 7z x -y "-o$appExtractDir" $($appArchives[0].FullName) - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract signed Windows app payload with 7z. Exit code: $LASTEXITCODE" - } - - $innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe') - } - - if ($innerExecutables.Count -ne 1) { - $matches = ($innerExecutables | ForEach-Object { $_.FullName }) -join [Environment]::NewLine - throw "Expected exactly one app-root Orca.exe in signed Windows app payload; found $($innerExecutables.Count).$([Environment]::NewLine)$matches" - } - - node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName) - - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index e18765e99d1..c106b166feb 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) - it('verifies the SignPath-signed Windows inner executable before publishing', () => { + it('temporarily allows publishing Windows after verifying the signed installer only', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' @@ -234,38 +234,8 @@ describe('Electron runtime package contract', () => { const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(innerVerifyIndex).toBeLessThan(publishIndex) - - const innerVerifyStep = steps[innerVerifyIndex] - const innerVerifyRun = innerVerifyStep.run - - expect(innerVerifyStep.if).toBe("matrix.platform == 'win'") - expect(innerVerifyStep.shell).toBe('pwsh') - expect(innerVerifyStep.env.ORCA_WINDOWS_EXPECTED_SIGNERS).toBe( - 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US;CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, ST=Delaware, C=US' - ) - expect(innerVerifyRun).toContain('& 7z x -y "-o$setupExtractDir" $installer') - expect(innerVerifyRun).toContain( - "$innerExecutables = @(Get-ChildItem -LiteralPath $setupExtractDir -File -Filter 'Orca.exe')" - ) - expect(innerVerifyRun).toContain('if ($innerExecutables.Count -eq 0)') - expect(innerVerifyRun).toContain( - "$appArchives = @(Get-ChildItem -LiteralPath $setupExtractDir -Recurse -File -Filter 'app-*.7z')" - ) - expect(innerVerifyRun).toContain('if ($appArchives.Count -ne 1)') - expect(innerVerifyRun).toContain('Expected app-root Orca.exe or exactly one app-*.7z') - expect(innerVerifyRun).toContain('& 7z x -y "-o$appExtractDir" $($appArchives[0].FullName)') - expect(innerVerifyRun).toContain( - "$innerExecutables = @(Get-ChildItem -LiteralPath $appExtractDir -File -Filter 'Orca.exe')" - ) - expect(innerVerifyRun.indexOf("-Filter 'Orca.exe'")).toBeLessThan( - innerVerifyRun.indexOf("-Filter 'app-*.7z'") - ) - expect(innerVerifyRun).toContain('if ($innerExecutables.Count -ne 1)') - expect(innerVerifyRun).toContain( - 'node config/scripts/verify-windows-inner-signature.mjs $($innerExecutables[0].FullName)' - ) + expect(innerVerifyIndex).toBe(-1) + expect(publishIndex).toBe(outerVerifyIndex + 1) }) it('publishes both Linux release matrix entries', () => { From a81629e8620c8090e93be37326f04bab8ef6f6eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:35:52 +0000 Subject: [PATCH 048/233] release: v1.4.118-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 73cef4c1637..626d6642a6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.3", + "version": "1.4.118-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 98a78f1e0c4d40917ad3b5c7dee3e67ff2354e0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:12:17 +0000 Subject: [PATCH 049/233] release: v1.4.118 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 626d6642a6a..484c9df78fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118-rc.5", + "version": "1.4.118", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From e6ca3087c58533a2b5de4fae1f0106844399d2ac Mon Sep 17 00:00:00 2001 From: Brandon Barker Date: Thu, 2 Jul 2026 16:55:36 -0400 Subject: [PATCH 050/233] perf(ssh): resolve node path concurrently with remote install state (#6952) Run independent SSH relay bootstrap probes concurrently when the connection can safely support overlapping execs. Preserve the old sequential path for system SSH without reusable ControlMaster and for remotes that reject concurrent session channels. --- src/main/ssh/ssh-connection-utils.ts | 7 + src/main/ssh/ssh-connection.test.ts | 45 ++- src/main/ssh/ssh-connection.ts | 48 +++- .../ssh-relay-cross-version-isolation.test.ts | 1 + src/main/ssh/ssh-relay-deploy-helpers.test.ts | 49 ++++ src/main/ssh/ssh-relay-deploy-helpers.ts | 15 +- src/main/ssh/ssh-relay-deploy.test.ts | 256 ++++++++++++++++++ src/main/ssh/ssh-relay-deploy.ts | 145 ++++++++-- .../ssh/ssh-relay-native-deps-install.test.ts | 1 + .../ssh/ssh-relay-versioned-install.test.ts | 20 ++ src/main/ssh/ssh-relay-versioned-install.ts | 25 +- .../ssh/ssh-remote-node-resolution.test.ts | 61 +++++ src/main/ssh/ssh-remote-node-resolution.ts | 73 +++-- src/main/ssh/ssh-session-limit-error.test.ts | 57 ++++ src/main/ssh/ssh-session-limit-error.ts | 19 ++ 15 files changed, 773 insertions(+), 49 deletions(-) create mode 100644 src/main/ssh/ssh-session-limit-error.test.ts create mode 100644 src/main/ssh/ssh-session-limit-error.ts diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index aea238ef234..cc8269c1b48 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -100,6 +100,13 @@ export function wrapRemoteCommandForPosixShell(command: string): string { export type SshExecOptions = { wrapCommand?: boolean + signal?: AbortSignal +} + +export function createSshOperationAbortError(): Error & { name: string } { + const error = new Error('SSH operation was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error } function cmdEscape(s: string): string { diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index ade239d56f6..f40d27077a8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -302,6 +302,14 @@ describe('SshConnection', () => { expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) }) + it('allows concurrent exec commands for ssh2 transport', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(false) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + it('removes startup listeners after ssh2 connect succeeds', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) @@ -762,8 +770,36 @@ describe('SshConnection', () => { ) }) + it('allows concurrent exec commands for system SSH with an Orca ControlMaster socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/live-socket') + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + + it('keeps concurrent exec commands disabled for system SSH without a reusable socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue(null) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection( + createTarget({ configHost: 'fdpass-host', systemSshConnectionReuse: false }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(false) + }) + it('retries a failed system SSH probe without ControlMaster and disables mux for the session', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshCommandMock .mockImplementationOnce(() => createFailingSystemCommandChannel(255, 'mux client failed')) .mockImplementation(() => createSystemCommandChannel()) @@ -812,6 +848,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) }) ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('uses system SSH transport for ProxyCommand targets before ssh2 auth', async () => { @@ -952,7 +989,10 @@ describe('SshConnection', () => { }) it('retries direct system SSH connections without ControlMaster after mux startup failure', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshMock .mockReturnValueOnce(createFailingSystemSshProcess(255)) .mockImplementation(() => createSystemSshProcess()) @@ -977,6 +1017,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) } ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('kills delayed direct system SSH startup on disconnect and ignores late stdout', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index bdc7543c5fc..528f03c4c58 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -30,6 +30,7 @@ import { resolveEffectiveProxy, spawnProxyCommand, wrapRemoteCommandForPosixShell, + createSshOperationAbortError, type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' @@ -85,6 +86,16 @@ export class SshConnection { usesSystemSshTransport(): boolean { return this.useSystemSshTransport } + canRunConcurrentExecCommands(): boolean { + if (!this.useSystemSshTransport) { + return true + } + return ( + getOrcaControlSocketPath(this.target, { + ...this.getSystemSshBuildArgsOptions() + }) !== null + ) + } getTarget(): SshTarget { return { ...this.target } } @@ -107,6 +118,9 @@ export class SshConnection { } async exec(cmd: string, options?: SshExecOptions): Promise { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } if (this.useSystemSshTransport) { if (this.disposed || this.state.status !== 'connected') { throw new Error('Not connected') @@ -121,7 +135,8 @@ export class SshConnection { return this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), - (channel) => channel.close() + (channel) => channel.close(), + options?.signal ) } @@ -143,12 +158,26 @@ export class SshConnection { private waitForSshCallback( timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { let settled = false + const cleanup = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createSshOperationAbortError()) + } const timer = setTimeout(() => { settled = true + signal?.removeEventListener('abort', onAbort) reject(new Error(timeoutMessage)) }, CONNECT_TIMEOUT_MS) const finish = (error: Error | undefined, value?: T): void => { @@ -166,13 +195,18 @@ export class SshConnection { return } settled = true - clearTimeout(timer) + cleanup() if (error) { reject(error) return } resolve(value as T) } + if (signal?.aborted) { + onAbort() + return + } + signal?.addEventListener('abort', onAbort, { once: true }) try { // Why: higher-level channel timers start only after ssh2 invokes its @@ -686,6 +720,9 @@ export class SshConnection { } private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } const buildArgsOptions = this.getSystemSshBuildArgsOptions() const commandOptions = options === undefined && Object.keys(buildArgsOptions).length === 0 @@ -696,9 +733,14 @@ export class SshConnection { ? spawnSystemSshCommand(this.target, command) : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) + const onAbort = (): void => { + channel.close() + } const cleanup = (): void => { + options?.signal?.removeEventListener('abort', onAbort) this.systemCommandChannels.delete(channel) } + options?.signal?.addEventListener('abort', onAbort, { once: true }) channel.once('close', cleanup) channel.once('error', cleanup) return channel diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index e1f93d8ec6d..c426e616fed 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -50,6 +50,7 @@ import type { SshConnection } from './ssh-connection' function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 4b4a3783ca8..8bd40901dae 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -217,6 +217,55 @@ describe('execCommand', () => { } }) + it('closes and rejects with AbortError when a command is aborted', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('handles aborts that happen while the SSH exec channel is still opening', async () => { + const channel = createMockChannel() + const controller = new AbortController() + let resolveExec: (channel: ClientChannel) => void = () => {} + const conn = { + exec: vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveExec = resolve + }) + ) + } + + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + controller.abort() + resolveExec(channel) + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index ecc7f27677c..1473e3233cd 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,6 +1,6 @@ import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' -import type { SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' @@ -247,6 +247,10 @@ export async function execCommand( options?: ExecCommandOptions ): Promise { const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } const channel = await conn.exec(command, execOptions) return new Promise((resolve, reject) => { let stdout = '' @@ -255,6 +259,7 @@ export async function execCommand( const cleanup = (): void => { clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) channel.off('error', fail) channel.stderr.off('error', fail) channel.off('data', onStdoutData) @@ -272,6 +277,10 @@ export async function execCommand( const fail = (err: Error): void => { settle(reject, err) } + const onAbort = (): void => { + channel.close() + settle(reject, createSshOperationAbortError()) + } const onStdoutData = (data: Buffer): void => { stdout += data.toString('utf-8') } @@ -293,10 +302,14 @@ export async function execCommand( // Why: remote reboot tears down exec channels with stream errors. Without // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) channel.on('error', fail) channel.stderr.on('error', fail) channel.on('data', onStdoutData) channel.stderr.on('data', onStderrData) channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } }) } diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index eddb0e9f0ba..ec825cdc4b6 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -69,7 +69,9 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' +import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS @@ -90,6 +92,7 @@ function extractWindowsMarkerPath(script: string): string { function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, @@ -161,6 +164,259 @@ describe('deployAndLaunchRelay', () => { expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) }) + it('resolves node concurrently with remote home, not after the install-state chain', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + + let markNodeResolutionStarted: () => void = () => {} + const nodeResolutionStarted = new Promise((resolve) => { + markNodeResolutionStarted = resolve + }) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + markNodeResolutionStarted() + return Promise.resolve('/usr/bin/node') + }) + + // Hold the first install-state step open. The optimization starts the node + // branch before the remote-home -> install-check chain finishes. + let releaseRemoteHome: (home: string) => void = () => {} + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + let assertionError: unknown + let deployError: unknown + try { + await nodeResolutionStarted + + expect(isRelayAlreadyInstalled).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + } catch (err) { + assertionError = err + } finally { + // Drain the rest of the happy path so a failed assertion does not leave + // the deploy promise pending until its 300s timeout. + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + deployError = await deployPromise.then( + () => undefined, + (err: unknown) => err + ) + } + if (assertionError) { + throw assertionError + } + if (deployError) { + throw deployError + } + }) + + it('keeps bootstrap sequential when the connection cannot run concurrent exec commands', async () => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeProbeStarted: () => void = () => {} + const remoteHomeProbeStartedPromise = new Promise((resolve) => { + remoteHomeProbeStarted = resolve + }) + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + remoteHomeProbeStarted() + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + await remoteHomeProbeStartedPromise + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('falls back to sequential bootstrap when concurrent SSH sessions are refused', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const { resolveRemoteNodePath: resolveRemoteNodePathActual } = await vi.importActual< + typeof SshRemoteNodeResolution + >('./ssh-remote-node-resolution') + let fallbackInstallStateCompleted = false + vi.mocked(resolveRemoteNodePath) + .mockImplementationOnce(resolveRemoteNodePathActual) + .mockImplementationOnce(() => { + if (!fallbackInstallStateCompleted) { + throw new Error('Sequential fallback resolved node before install state finished') + } + return Promise.resolve('/usr/bin/node') + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBe(true) + return true + }) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBeUndefined() + fallbackInstallStateCompleted = true + return true + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('falls back to sequential bootstrap when the install-state probe hits a session limit', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + if (!options?.rethrowSessionLimitErrors) { + return true + } + throw sessionLimitError + }) + .mockResolvedValueOnce(true) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('does not retry bootstrap for non-session failures', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const nodeError = new Error('Node.js not found on remote host') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('aborts a pending sibling probe and preserves a non-session install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + let nodeProbeAborted = false + vi.mocked(resolveRemoteNodePath).mockImplementationOnce((_conn, _host, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + nodeProbeAborted = true + const abortError = new Error('aborted') + abortError.name = 'AbortError' + reject(abortError) + }) + }) + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME + + const timedDeploy = Promise.race([ + deployAndLaunchRelay(conn), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('deploy did not fail promptly')), 100) + }) + ]) + + await expect(timedDeploy).rejects.toThrow(/Remote home is not a valid path/) + expect(nodeProbeAborted).toBe(true) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry when a session-limit failure races with a real install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const installError = new Error('permission denied while checking relay install') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) + vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry until the surviving first-attempt probe settles', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeSettled = false + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = (home: string) => { + remoteHomeSettled = true + resolve(home) + } + }) + ) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + if (!remoteHomeSettled) { + throw new Error('Sequential fallback started before first install-state probe settled') + } + return Promise.resolve('/usr/bin/node') + }) + + const deployPromise = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index bee9f63de8b..78e7dec4443 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -42,6 +42,7 @@ import { import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { isWindowsRelayPipePath, relayEndpointForHost, @@ -80,11 +81,12 @@ function execHostCommand( conn: SshConnection, hostPlatform: RemoteHostPlatform, command: string, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise { return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform), - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs, + signal: options?.signal }) } @@ -122,6 +124,119 @@ export async function deployAndLaunchRelay( } } +/** + * Resolve the remote home, derive the versioned relay directory, and check + * whether the relay is already installed there. + * + * Why: extracted so the deploy can run this chain concurrently with node-path + * resolution (the two are independent). Home and install-check stay sequential + * here because the install-check needs the resolved directory. + */ +async function resolveRemoteInstallState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string, + options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } +): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { + // Why: SFTP does not expand `~`, so we must resolve the remote home + // explicitly with the host's native shell and normalize it before use. + const remoteHome = normalizeRemoteHome( + await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { + signal: options?.signal + }), + hostPlatform + ) + // Why: we only interpolate $HOME into single-quoted shell strings later, so + // this validation only needs to reject obviously unsafe control characters. + // Allow spaces and non-ASCII so valid home directories are not rejected. + if (!validateRemoteHome(remoteHome, hostPlatform)) { + throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) + } + const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) + const probeOptions = + options?.rethrowSessionLimitErrors || options?.signal + ? { + rethrowSessionLimitErrors: options.rethrowSessionLimitErrors, + signal: options.signal + } + : undefined + const alreadyInstalled = await isRelayAlreadyInstalled( + conn, + remoteRelayDir, + hostPlatform, + probeOptions + ) + return { remoteHome, remoteRelayDir, alreadyInstalled } +} + +type RelayBootstrapState = { + remoteHome: string + remoteRelayDir: string + alreadyInstalled: boolean + nodePath: string +} + +async function resolveRelayBootstrapStateSequentially( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + return { ...installState, nodePath } +} + +async function resolveRelayBootstrapState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + if (!conn.canRunConcurrentExecCommands()) { + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } + const abortController = new AbortController() + const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + try { + const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + return { ...installState, nodePath } + } catch (err) { + abortController.abort() + const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + if (!isSshSessionLimitError(err)) { + throw err + } + const nonSessionFailure = settled.find( + (result) => + result.status === 'rejected' && + !isSshSessionLimitError(result.reason) && + !isAbortError(result.reason) + ) + if (nonSessionFailure?.status === 'rejected') { + throw nonSessionFailure.reason + } + console.warn( + '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' + ) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError' +} + +/** + * Detect the remote platform, resolve install state and node path, install the + * relay if it is not already present, then launch it and return the transport. + * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. + */ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, @@ -152,25 +267,17 @@ async function deployAndLaunchRelayInner( // docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error". const fullVersion = readLocalFullVersion(localRelayDir) - // Why: SFTP does not expand `~`, so we must resolve the remote home - // explicitly with the host's native shell and normalize it before use. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform)), - hostPlatform - ) - // Why: we only interpolate $HOME into single-quoted shell strings later, so - // this validation only needs to reject obviously unsafe control characters. - // Allow spaces and non-ASCII so valid home directories are not rejected. - if (!validateRemoteHome(remoteHome, hostPlatform)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) - console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) - onProgress?.('Checking existing relay...') - const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform) + // Why: the remote-home -> install-check chain and node resolution are + // independent (both only need hostPlatform, not each other's results), yet + // each is a separate SSH exec round trip. Run them concurrently so the deploy + // pays one round trip instead of two for this phase. Most failures stay + // fail-fast; remotes that reject overlapping session channels retry the old + // sequential order so restrictive SSH servers keep working. + const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) if (alreadyInstalled) { await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index b8bfcfeb7dc..0cedf1aff05 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -109,6 +109,7 @@ function makeMockConnection(capture: SftpWriteCapture): SshConnection { end: vi.fn() }) return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 29ea274c309..8cc46e8b151 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -88,6 +88,26 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false) + }) + + it('rethrows SSH session-limit errors in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('checks for relay.js AND .install-complete in addition to the dir', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index ab3c88fb6ee..4074e31f673 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -36,6 +36,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a // relay install dir. Single source of truth for both. The pattern matches the @@ -64,12 +65,21 @@ const INSTALL_LOCK_TIMEOUT_MS = 120_000 const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +type RelayInstalledProbeOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, - command: string + command: string, + options?: { signal?: AbortSignal } ): Promise { - return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) } /** @@ -124,16 +134,21 @@ export function computeRemoteRelayDir( export async function isRelayAlreadyInstalled( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: RelayInstalledProbeOptions ): Promise { try { const probe = await execHostCommand( conn, host, - probeRelayInstalledCommand(host, remoteRelayDir) + probeRelayInstalledCommand(host, remoteRelayDir), + { signal: options?.signal } ) return probe.trim() === 'OK' - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } return false } } diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b007c8fd17c..23ea9097540 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' const execCommandMock = vi.hoisted(() => vi.fn()) @@ -270,6 +271,66 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) }) + it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockRejectedValueOnce(sessionLimitError) + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('rethrows SSH session-limit errors from the path probe in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('throws when every candidate across both strategies is below the minimum', async () => { execCommandMock .mockResolvedValueOnce('/old/node\n') // path probe diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a5ec9b9fd05..4117ddf97ef 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -4,6 +4,7 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the relay requires Node.js 18+. Version managers like nvm keep every // installed version on disk, so a naive "highest version" glob can hand back @@ -15,12 +16,18 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 +type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + export async function resolveRemoteNodePath( conn: SshConnection, - host?: RemoteHostPlatform + host?: RemoteHostPlatform, + options?: RemoteNodeResolutionOptions ): Promise { if (host && isWindowsRemoteHost(host)) { - return resolveRemoteWindowsNodePath(conn) + return resolveRemoteWindowsNodePath(conn, options) } // Strategy 1: probe well-known install directories for every common Node @@ -28,14 +35,14 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const probedPath = await tryResolveViaKnownPaths(conn) + const probedPath = await tryResolveViaKnownPaths(conn, options) if (probedPath) { return probedPath } // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH // setups in ~/.profile / ~/.bash_profile that the probes don't cover. - const loginShellPath = await tryResolveViaLoginShell(conn) + const loginShellPath = await tryResolveViaLoginShell(conn, options) if (loginShellPath) { return loginShellPath } @@ -47,7 +54,10 @@ export async function resolveRemoteNodePath( // plus system package-manager locations. Every probe runs unconditionally so // a missing directory prints nothing rather than short-circuiting later // probes. Returns the first candidate that meets the minimum version. -async function tryResolveViaKnownPaths(conn: SshConnection): Promise { +async function tryResolveViaKnownPaths( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = ` command -v node 2>/dev/null nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} @@ -94,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script) + const result = await execCommand(conn, script, { signal: options?.signal }) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -102,12 +112,15 @@ true continue } seen.add(candidate) - if (await nodeMeetsVersionRequirement(conn, candidate)) { + if (await nodeMeetsVersionRequirement(conn, candidate, options)) { console.log(`[ssh-relay] Found node via path probe: ${candidate}`) return candidate } } - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Fall through to login shell. } return null @@ -116,14 +129,18 @@ true // Run `command -v node` under the user's login shell, then verify the result // meets the minimum version. Returns null on any failure (shell missing, no // node found, version too old, timeout) so callers fall through to the error. -async function tryResolveViaLoginShell(conn: SshConnection): Promise { +async function tryResolveViaLoginShell( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { try { // Why: $SHELL is the user's configured login shell (set by chsh / passwd). // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, + signal: options?.signal }) const shell = shellResult.trim().split('\n')[0] if (!shell) { @@ -132,18 +149,22 @@ async function tryResolveViaLoginShell(conn: SshConnection): Promise { try { const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false + wrapCommand: false, + signal: options?.signal }) const match = versionOutput.trim().match(/^v?(\d+)/) if (!match) { @@ -174,13 +197,19 @@ async function nodeMeetsVersionRequirement( } const major = Number.parseInt(match[1]!, 10) return major >= MIN_NODE_MAJOR - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Binary missing or fails to run — not usable. return false } } -async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { +async function resolveRemoteWindowsNodePath( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = [ '$paths = @()', '$cmd = Get-Command node.exe -ErrorAction SilentlyContinue', @@ -199,14 +228,20 @@ async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { + it('matches ssh2 open failures with the resource-shortage reason code', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) + ) + ).toBe(true) + }) + + it('matches OpenSSH mux and MaxSessions failures', () => { + expect( + isSshSessionLimitError( + new Error( + 'mux_client_request_session: session request failed: Session open refused by peer' + ) + ) + ).toBe(true) + expect( + isSshSessionLimitError(new Error('open failed: MaxSessions limit reached on remote host')) + ).toBe(true) + }) + + it('does not match generic channel-open failures without a session-limit reason', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 1 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 3 }) + ) + ).toBe(false) + }) + + it('does not match unrelated command failures', () => { + expect( + isSshSessionLimitError(new Error('Command "node" failed (exit 1): Node.js not found')) + ).toBe(false) + expect(isSshSessionLimitError(new Error('channel open failure while parsing output'))).toBe( + false + ) + expect( + isSshSessionLimitError( + new Error('open failed: administratively prohibited: forwarding disabled') + ) + ).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-session-limit-error.ts b/src/main/ssh/ssh-session-limit-error.ts new file mode 100644 index 00000000000..aed848525b8 --- /dev/null +++ b/src/main/ssh/ssh-session-limit-error.ts @@ -0,0 +1,19 @@ +export function isSshSessionLimitError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const reason = (err as { reason?: unknown }).reason + const message = err.message.toLowerCase() + if ( + reason === 4 && + (message.includes('channel open failure') || message.includes('open failed')) + ) { + return true + } + return ( + message.includes('no free channels available') || + message.includes('maxsessions') || + message.includes('session open refused') || + (message.includes('mux_client_request_session') && message.includes('session request failed')) + ) +} From e44daf37a3a61ab53cde98953406d2c935db48b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:50:15 -0700 Subject: [PATCH 051/233] Match main terminal mirror character widths to the renderer (#7148) --- .../headless-emulator-unicode-width.test.ts | 28 +++++++++++++++++++ src/main/daemon/headless-emulator.ts | 10 +++++++ .../src/lib/pane-manager/pane-lifecycle.ts | 2 +- .../lib/pane-manager/pane-terminal-options.ts | 2 +- .../terminal-unicode-provider.ts} | 0 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/main/daemon/headless-emulator-unicode-width.test.ts rename src/{renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts => shared/terminal-unicode-provider.ts} (100%) diff --git a/src/main/daemon/headless-emulator-unicode-width.test.ts b/src/main/daemon/headless-emulator-unicode-width.test.ts new file mode 100644 index 00000000000..f358ce79189 --- /dev/null +++ b/src/main/daemon/headless-emulator-unicode-width.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why these oracles: agent TUIs position the cursor assuming the widths the +// on-screen xterm uses (Unicode 11 + ZWJ joining). If this mirror measures a +// preceding emoji differently, the positioned write lands on the wrong cell +// and the mirrored row tears — which snapshot restores then paint back. +describe('headless emulator unicode widths', () => { + it('advances emoji as two cells so positioned writes land like the renderer', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 🤖 occupies columns 1-2 under Unicode 11 (one cell under the v6 default), + // so "A" is at column 3, "B" at 4, and column 5 overwrites just past "B". + await emulator.write('\x1b[H\u{1F916}AB') + await emulator.write('\x1b[1;5HZ') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F916}ABZ') + emulator.dispose() + }) + + it('joins ZWJ emoji into one wide pair like the renderer provider', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 👩‍💻 (woman + ZWJ + laptop) renders as a single two-cell glyph in CLIs; + // plain Unicode 11 would budget four cells and shift everything after it. + await emulator.write('\x1b[H\u{1F469}\u{200D}\u{1F4BB}X') + await emulator.write('\x1b[1;3HY') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F469}\u{200D}\u{1F4BB}Y') + emulator.dispose() + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e2e396170aa..ed9e9345d87 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,6 +1,8 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -52,6 +54,14 @@ export class HeadlessEmulator { this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) + // Why: this mirror must measure character widths exactly like the + // renderer's xterm (Unicode 11 + ZWJ emoji joining). With the default v6 + // tables, emoji-dense rows (agent status lines) advance the cursor + // differently here than on screen, so the mirrored buffer accumulates + // cell-shifted tears that snapshot restores then paint back as garbage. + this.terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(this.terminal) + // Why no onData wiring: this emulator exists purely for state tracking // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index efdbea455ff..7756e7904c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -12,7 +12,7 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' -import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 89bbaf4b118..911d3fa7545 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -59,7 +59,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { // this as a gutter, costing ~1 column per pane — accepted tradeoff so the // scrollbar never covers content (evidence in PR #5051). The v1.4.51 // table corruption #4877 fixed by zeroing this was actually the ZWJ - // width bug; it stays fixed by pane-terminal-unicode-provider.ts. Width + // width bug; it stays fixed by shared/terminal-unicode-provider.ts. Width // also enables the overview ruler, whose border is hidden in // composeActiveTerminalTheme. width: 7 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts similarity index 100% rename from src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts rename to src/shared/terminal-unicode-provider.ts From 2d4b46b44ffe7b47668fdc899601b250ac081b5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:51:38 +0000 Subject: [PATCH 052/233] release: v1.4.119-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 484c9df78fb..7d68654a7ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.118", + "version": "1.4.119-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4782b8d3bf51ef6b0b426cf4563a6567a90a4578 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:21:07 +0000 Subject: [PATCH 053/233] release: v1.4.119 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d68654a7ca..57b6c4638c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.119-rc.0", + "version": "1.4.119", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From ce997b001dc5552c7038231961c3a0ec5dfaa1db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:35:04 +0000 Subject: [PATCH 054/233] release: v1.4.120-rc.0 [rc-slot:2026-07-02-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57b6c4638c0..2fb3d623d31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.119", + "version": "1.4.120-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 2ce9314acb8ac1852abb306ab596dfbc10d2ecb2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:41:23 -0700 Subject: [PATCH 055/233] Improve Windows terminal performance: retain WebGL contexts, warm first ConPTY (#7085) --- ...ro-windows-apphang-terminal-activation.mjs | 196 +++ .../apphang-report-summary.mjs | 77 ++ .../electron-dev-session.mjs | 331 +++++ .../windows-apphang-repro/repro-timing.mjs | 47 + .../terminal-activation-scenario.mjs | 486 ++++++++ .../wsl-workspace-fixture.mjs | 135 +++ notes/windows-perf-progress.md | 63 + src/main/daemon/daemon-entry.ts | 3 + src/main/daemon/windows-conpty-warmup.test.ts | 91 ++ src/main/daemon/windows-conpty-warmup.ts | 45 + src/main/ipc/pty-spawn-timing.ts | 41 + src/main/ipc/pty.test.ts | 214 +++- src/main/ipc/pty.ts | 166 ++- .../local-pty-utils-windows-fallback.test.ts | 4 +- src/main/providers/local-pty-utils.ts | 20 +- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- .../src/lib/pane-manager/pane-manager.ts | 5 + .../pane-manager/pane-rendering-control.ts | 33 +- .../pane-webgl-context-retention.test.ts | 235 ++++ .../pane-webgl-context-retention.ts | 56 + .../pane-webgl-refresh-lifecycle.test.ts | 24 + .../lib/pane-manager/pane-webgl-renderer.ts | 39 +- ...erf-baseline-2026-07-02T07-23-46-933Z.json | 1041 ++++++++++++++++ ...perf-fix-a-b-2026-07-02T07-46-58-178Z.json | 1058 ++++++++++++++++ ...perf-fix-abd-2026-07-02T07-51-00-936Z.json | 1061 +++++++++++++++++ ...erf-fix-abdf-2026-07-02T07-59-11-820Z.json | 1058 ++++++++++++++++ tools/benchmarks/terminal-perf-bench.mjs | 621 ++++++++++ 27 files changed, 7103 insertions(+), 53 deletions(-) create mode 100644 config/scripts/repro-windows-apphang-terminal-activation.mjs create mode 100644 config/scripts/windows-apphang-repro/apphang-report-summary.mjs create mode 100644 config/scripts/windows-apphang-repro/electron-dev-session.mjs create mode 100644 config/scripts/windows-apphang-repro/repro-timing.mjs create mode 100644 config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs create mode 100644 config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs create mode 100644 src/main/daemon/windows-conpty-warmup.test.ts create mode 100644 src/main/daemon/windows-conpty-warmup.ts create mode 100644 src/main/ipc/pty-spawn-timing.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts create mode 100644 tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json create mode 100644 tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json create mode 100644 tools/benchmarks/terminal-perf-bench.mjs diff --git a/config/scripts/repro-windows-apphang-terminal-activation.mjs b/config/scripts/repro-windows-apphang-terminal-activation.mjs new file mode 100644 index 00000000000..73e7bd4a13f --- /dev/null +++ b/config/scripts/repro-windows-apphang-terminal-activation.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { + createWslFixture, + listWslDistros, + removeWslFixture +} from './windows-apphang-repro/wsl-workspace-fixture.mjs' +import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs' +import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs' + +const defaultCycles = 14 +const defaultOutputLines = 1_600 + +function parseArgs() { + const args = { + cycles: defaultCycles, + distro: null, + expect: 'none', + gpuModes: ['on'], + keep: false, + outputLines: defaultOutputLines, + reportPath: null, + sourceControl: true, + deadPtyReactivate: true + } + + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + if (arg === '--no-source-control') { + args.sourceControl = false + continue + } + if (arg === '--no-dead-pty-reactivate') { + args.deadPtyReactivate = false + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--distro') { + args.distro = value?.trim() || null + continue + } + if (name === '--expect') { + if (!['none', 'repro', 'pass'].includes(value)) { + throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`) + } + args.expect = value + continue + } + if (name === '--gpu') { + const modes = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) { + throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`) + } + args.gpuModes = modes + continue + } + if (name === '--output-lines') { + args.outputLines = parsePositiveInt(name, value) + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + if (!args.reportPath) { + throw new Error('--report requires a file path.') + } + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + + return args +} + +function printHelp() { + console.log(`Usage: + node config/scripts/repro-windows-apphang-terminal-activation.mjs [options] + +Options: + --expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed, + pass exits 1 if hang evidence is observed. Default: none. + --gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on. + --cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}. + --output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}. + --report=PATH Write full JSON evidence to PATH and print a compact summary to stdout. + --distro=NAME WSL distro to use. Default: first non docker-desktop distro. + --no-source-control Do not open Source Control during the stress loop. + --no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation. + --keep Keep disposable WSL/userData fixtures after the run.`) +} + +function parsePositiveInt(name, value) { + const parsed = Number.parseInt(value ?? '', 10) + if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + throw new Error(`${name} requires a positive integer.`) + } + return parsed +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('This repro harness is intentionally Windows-only.') + } + const args = parseArgs() + const distros = listWslDistros() + const distro = args.distro ?? distros[0] + if (!distro) { + throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.') + } + console.log( + `[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}` + ) + const fixture = createWslFixture(distro) + console.log( + `[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}` + ) + + const results = [] + try { + for (const gpuMode of args.gpuModes) { + results.push(await runGpuMode(gpuMode, args, fixture)) + if (args.expect === 'repro' && results.at(-1)?.reproduced) { + break + } + } + } finally { + if (!args.keep) { + removeWslFixture(fixture) + } + } + + const payload = { + issue: 'https://github.com/stablyai/orca/issues/6874', + expect: args.expect, + distro, + fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath }, + summary: results.map(summarizeResult), + results + } + if (args.reportPath) { + const reportPath = path.resolve(args.reportPath) + mkdirSync(path.dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`) + console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2)) + } else { + console.log(JSON.stringify(payload, null, 2)) + } + + // Why: a broken harness run (CDP/setup/selector failure) proves nothing — + // it must fail both --expect=repro and --expect=pass rather than being + // miscounted as hang evidence or as a clean pass. + const harnessErrors = results.filter((result) => result.harnessError) + if (harnessErrors.length > 0) { + const harnessErrorLines = harnessErrors + .map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`) + .join('\n') + console.error( + `[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}` + ) + if (args.expect !== 'none') { + process.exit(1) + } + } + const reproduced = results.some((result) => result.reproduced) + if (args.expect === 'repro' && !reproduced) { + console.error( + '[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.' + ) + process.exit(1) + } + if (args.expect === 'pass' && reproduced) { + console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.') + process.exit(1) + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exit(1) +}) diff --git a/config/scripts/windows-apphang-repro/apphang-report-summary.mjs b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs new file mode 100644 index 00000000000..b6d222cb4de --- /dev/null +++ b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' + +export function summarizeTrace(userDataDir) { + const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson') + if (!existsSync(tracePath)) { + return { tracePath, exists: false } + } + const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean) + const parsed = [] + for (const line of lines.slice(-400)) { + try { + parsed.push(JSON.parse(line)) + } catch { + parsed.push({ raw: line }) + } + } + const interesting = parsed.filter((entry) => { + const name = String(entry.name ?? entry.event ?? entry.type ?? '') + const text = JSON.stringify(entry) + return ( + name.includes('git.exec') || + name.includes('renderer_memory') || + name.includes('sidebar_worktree_activate') || + text.includes('git.exec remote') || + text.includes('git.exec worktree') || + text.includes('sidebar_worktree_activate') + ) + }) + return { + tracePath, + exists: true, + totalLines: lines.length, + interestingTail: interesting.slice(-40) + } +} + +export function summarizeAppLogs(logs) { + const webglContextWarnings = logs.filter((entry) => + entry.line.includes('Too many active WebGL contexts') + ) + const webglContextLosses = logs.filter((entry) => + entry.line.toLowerCase().includes('webgl context lost') + ) + const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line)) + return { + lineCount: logs.length, + webglContextWarningCount: webglContextWarnings.length, + webglContextWarningsTail: webglContextWarnings.slice(-10), + webglContextLossCount: webglContextLosses.length, + webglContextLossesTail: webglContextLosses.slice(-10), + gpuProcessEventCount: gpuProcessEvents.length, + gpuProcessEventsTail: gpuProcessEvents.slice(-20) + } +} + +export function summarizeResult(result) { + const samples = result.samples.filter((sample) => sample && typeof sample === 'object') + const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0)) + const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0)) + const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0)) + const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0)) + return { + gpuMode: result.gpuMode, + reproduced: result.reproduced, + reproductionReason: result.reproductionReason, + harnessError: result.harnessError ?? null, + elapsedMs: result.elapsedMs, + sampleCount: samples.length, + maxActivationMs, + maxPtyWaitMs, + maxMainIpcMs, + maxRendererDriftMs, + finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null, + appLogSummary: result.appLogSummary + } +} diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs new file mode 100644 index 00000000000..89fddf4ef99 --- /dev/null +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -0,0 +1,331 @@ +import { chromium } from '@playwright/test' +import { spawn } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + appShutdownTimeoutMs, + cdpPollTimeoutMs, + delay, + pollUntil, + rendererActionTimeoutMs, + runWithTimeout +} from './repro-timing.mjs' +import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function isPortFree(port) { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => { + try { + server.close() + } catch {} + resolve(false) + }) + server.once('listening', () => server.close(() => resolve(true))) + server.listen(port, '127.0.0.1') + }) +} + +export async function pickFreePort() { + for (let port = 9533; port < 9633; port += 1) { + if (await isPortFree(port)) { + return port + } + } + throw new Error('Could not find a free CDP port in 9533..9632.') +} + +export function createGpuUserDataDirectory(gpuMode) { + const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`)) + createCompletedOnboardingProfile(userDataDir) + return userDataDir +} + +export function launchDevApp({ cdpPort, userDataDir }) { + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + Object.assign(env, { + ELECTRON_ENABLE_LOGGING: '1', + ELECTRON_ENABLE_STACK_DUMPING: '1', + NODE_ENV: 'development', + ORCA_DEV_USER_DATA_PATH: userDataDir, + ORCA_SKIP_DEV_WEB_PREPARE: '1', + ORCA_STARTUP_DIAGNOSTICS: '1', + REMOTE_DEBUGGING_PORT: String(cdpPort), + VITE_EXPOSE_STORE: 'true' + }) + const child = spawn( + process.execPath, + [path.join('config', 'scripts', 'run-electron-vite-dev.mjs')], + { + cwd: rootDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) + const logs = [] + const collect = (source) => (chunk) => { + const text = chunk.toString() + for (const line of text.split(/\r?\n/).filter(Boolean)) { + logs.push({ source, line, at: Date.now() }) + console.log(`[apphang-repro:${source}] ${line}`) + } + } + child.stdout?.on('data', collect('stdout')) + child.stderr?.on('data', collect('stderr')) + return { child, logs } +} + +export async function stopDevApp(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) { + return + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, appShutdownTimeoutMs) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.once('exit', () => undefined) + return + } + child.kill('SIGTERM') + }) +} + +async function waitForCdp(port) { + const url = `http://127.0.0.1:${port}/json` + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + const targets = await response.json() + if (Array.isArray(targets) && targets.some((target) => target.type)) { + return targets + } + } + } catch {} + await delay(500) + } + throw new Error(`Timed out waiting for CDP targets on ${url}`) +} + +async function getMainPage(browser) { + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + for (const context of browser.contexts()) { + const pages = context.pages() + const page = + pages.find((candidate) => + /^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url()) + ) ?? pages[0] + if (page) { + return page + } + } + await delay(250) + } + throw new Error('Timed out waiting for the Electron renderer page.') +} + +export async function connectToApp(cdpPort) { + await waitForCdp(cdpPort) + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const page = await getMainPage(browser) + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }) + return { browser, page } +} + +export async function installRendererProbe(page) { + await page.evaluate(() => { + if (globalThis.__orcaApphangProbe) { + return + } + const probe = { + intervalMs: 50, + last: performance.now(), + maxDriftMs: 0, + samples: 0, + startedAt: performance.now(), + lastTickAt: performance.now() + } + const timer = setInterval(() => { + const now = performance.now() + const drift = Math.max(0, now - probe.last - probe.intervalMs) + probe.maxDriftMs = Math.max(probe.maxDriftMs, drift) + probe.last = now + probe.lastTickAt = now + probe.samples += 1 + }, probe.intervalMs) + globalThis.__orcaApphangProbe = { probe, timer } + }) +} + +export async function waitForStoreReady(page) { + await pollUntil( + 'renderer store exposure', + () => page.evaluate(() => Boolean(window.__store && window.api)), + Boolean, + 30_000 + ) + await pollUntil( + 'workspace session hydration', + () => + page.evaluate(() => { + const state = window.__store?.getState?.() + return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded) + }), + Boolean, + 45_000 + ) +} + +export async function collectRendererDiagnostics(page) { + if (!page) { + return null + } + try { + return await runWithTimeout( + 'renderer diagnostics', + () => + page.evaluate(async () => { + const timed = (label, promise) => + Promise.race([ + Promise.resolve(promise).then( + (value) => ({ value }), + (error) => ({ error: error instanceof Error ? error.message : String(error) }) + ), + new Promise((resolve) => + setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000) + ) + ]) + const readWebglIdentity = () => { + const canvas = document.createElement('canvas') + let gl = null + try { + gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + if (!gl) { + return { available: false, vendor: null, renderer: null } + } + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + if (!debugInfo) { + return { available: true, vendor: null, renderer: null } + } + return { + available: true, + vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null, + renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null + } + } catch (error) { + return { + available: false, + error: error instanceof Error ? error.message : String(error) + } + } finally { + try { + gl?.getExtension('WEBGL_lose_context')?.loseContext() + } catch {} + canvas.width = 0 + canvas.height = 0 + } + } + const allPaneManagersDiagnostics = Array.from( + window.__paneManagers?.entries?.() ?? [] + ).map(([managerTabId, paneManager]) => ({ + tabId: managerTabId, + diagnostics: paneManager?.getRenderingDiagnostics?.() ?? [] + })) + const webglContextCounts = allPaneManagersDiagnostics.reduce( + (acc, entry) => { + acc.managerCount += 1 + acc.paneCount += entry.diagnostics.length + for (const diagnostic of entry.diagnostics) { + if (diagnostic.hasWebgl) { + acc.attachedWebglCount += 1 + } + if (diagnostic.webglAttachmentDeferred) { + acc.deferredWebglCount += 1 + } + } + return acc + }, + { attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 } + ) + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId ?? null + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const buffer = activePane?.terminal?.buffer?.active ?? null + return { + hasStore: Boolean(window.__store), + workspaceSessionReady: state?.workspaceSessionReady ?? null, + hydrationSucceeded: state?.hydrationSucceeded ?? null, + activeView: state?.activeView ?? null, + activeRepoId: state?.activeRepoId ?? null, + activeWorktreeId: worktreeId, + activeTabType: state?.activeTabType ?? null, + activeTabId: tabId, + terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null, + repoCount: state?.repos?.length ?? null, + worktreeCountsByRepo: Object.fromEntries( + Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + worktrees.length + ]) + ), + ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [], + paneManagerCount: window.__paneManagers?.size ?? null, + activePane: activePane + ? { + id: activePane.id ?? null, + leafId: activePane.leafId ?? null, + ptyId: activePane.container?.dataset?.ptyId ?? null, + cols: activePane.terminal?.cols ?? null, + rows: activePane.terminal?.rows ?? null, + buffer: buffer + ? { + baseY: buffer.baseY, + viewportY: buffer.viewportY, + cursorY: buffer.cursorY, + length: buffer.length + } + : null + } + : null, + renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null, + allPaneManagersDiagnostics, + webglContextCounts, + webglIdentity: readWebglIdentity(), + rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null, + ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()), + rendererDeliveryDebug: await timed( + 'renderer delivery debug', + window.api?.pty?.getRendererDeliveryDebugSnapshot?.() + ) + } + }), + rendererActionTimeoutMs + ) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/config/scripts/windows-apphang-repro/repro-timing.mjs b/config/scripts/windows-apphang-repro/repro-timing.mjs new file mode 100644 index 00000000000..9f335d61435 --- /dev/null +++ b/config/scripts/windows-apphang-repro/repro-timing.mjs @@ -0,0 +1,47 @@ +export const cdpPollTimeoutMs = 90_000 +export const rendererActionTimeoutMs = 5_000 +export const activationTimeoutMs = 15_000 +export const ptyWaitTimeoutMs = 20_000 +export const terminalMarkerTimeoutMs = 45_000 +export const setupTimeoutMs = 90_000 +export const appShutdownTimeoutMs = 8_000 +export const severeRendererDriftMs = 2_000 +export const severeActivationMs = 5_000 +export const severeMainIpcMs = 2_000 +export const severePtyWaitMs = 10_000 + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function runWithTimeout(label, action, timeoutMs) { + const actionPromise = Promise.resolve().then(action) + actionPromise.catch(() => undefined) + const result = await Promise.race([ + actionPromise.then( + (value) => ({ timedOut: false, value }), + (error) => ({ timedOut: false, error }) + ), + delay(timeoutMs).then(() => ({ timedOut: true })) + ]) + if (result.timedOut) { + throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`) + } + if ('error' in result) { + throw result.error + } + return result.value +} + +export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) { + const startedAt = Date.now() + let lastValue = null + while (Date.now() - startedAt < timeoutMs) { + lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs) + if (predicate(lastValue)) { + return lastValue + } + await delay(intervalMs) + } + throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`) +} diff --git a/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs new file mode 100644 index 00000000000..db92c7cdf31 --- /dev/null +++ b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs @@ -0,0 +1,486 @@ +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from './electron-dev-session.mjs' +import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs' +import { + activationTimeoutMs, + pollUntil, + ptyWaitTimeoutMs, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs, + severeActivationMs, + severeMainIpcMs, + severePtyWaitMs, + severeRendererDriftMs, + terminalMarkerTimeoutMs +} from './repro-timing.mjs' +import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs' + +class ReproductionObservedError extends Error { + constructor(message, evidence) { + super(message) + this.name = 'ReproductionObservedError' + this.evidence = evidence + } +} + +async function setupAppFixture(page, fixture, gpuMode, sourceControl) { + return await runWithTimeout( + 'fixture registration in Orca', + () => + page.evaluate( + async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is unavailable.') + } + const state = store.getState() + await state.fetchSettings?.() + await store.getState().updateSettings({ terminalGpuAcceleration: mode }) + + const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' }) + if ('error' in addResult) { + throw new Error(addResult.error) + } + await store.getState().fetchRepos() + let nextState = store.getState() + const repo = + nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo + await nextState.updateRepo(repo.id, { + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityPromptDismissedAt: Date.now(), + importedExternalWorktreePaths: importedWorktreePaths, + externalWorktreeInboxBaselinePaths: importedWorktreePaths + }) + await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true }) + + const plainRepo = await store.getState().addNonGitFolder(plainPath) + if (!plainRepo) { + throw new Error('addNonGitFolder returned null.') + } + await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true }) + + nextState = store.getState() + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setActiveView('terminal') + if (openSourceControl) { + nextState.setRightSidebarOpen(true) + nextState.setRightSidebarTab('source-control') + } + + const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? [] + const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? [] + return { + repoId: repo.id, + repoPath: repo.path, + plainRepoId: plainRepo.id, + gitWorktrees: gitWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })), + plainWorktrees: plainWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })) + } + }, + { + repoPath: fixture.repoUncPath, + plainPath: fixture.plainUncPath, + importedWorktreePaths: fixture.worktreeUncPaths, + mode: gpuMode, + openSourceControl: sourceControl + } + ), + setupTimeoutMs + ) +} + +async function clickWorktreeCard(page, worktreeId) { + const rect = await runWithTimeout( + `locate worktree card ${worktreeId}`, + () => + page.evaluate((id) => { + const rows = Array.from(document.querySelectorAll('[data-worktree-id]')) + const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id) + if (!row) { + return null + } + row.scrollIntoView({ block: 'center', inline: 'nearest' }) + const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row + const bounds = surface.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return null + } + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + width: bounds.width, + height: bounds.height + } + }, worktreeId), + rendererActionTimeoutMs + ) + if (!rect) { + throw new Error(`Could not find rendered worktree card for ${worktreeId}`) + } + await runWithTimeout( + `click worktree card ${worktreeId}`, + () => page.mouse.click(rect.x, rect.y), + rendererActionTimeoutMs + ) +} + +async function waitForActiveWorktree(page, worktreeId) { + return await pollUntil( + `active worktree ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + return { + activeWorktreeId: state?.activeWorktreeId ?? null, + activeTabId: state?.activeTabId ?? null, + activeTabType: state?.activeTabType ?? null, + tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? [] + } + }, worktreeId), + (value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal', + activationTimeoutMs + ) +} + +async function waitForActivePty(page, worktreeId) { + const startedAt = Date.now() + const value = await pollUntil( + `active PTY for ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + const tabId = + state?.activeWorktreeId === id && state.activeTabType === 'terminal' + ? state.activeTabId + : (state?.activeTabIdByWorktree?.[id] ?? null) + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const ptyId = pane?.container?.dataset?.ptyId ?? null + return { + tabId, + ptyId, + hasManager: Boolean(manager), + paneCount: manager?.getPanes?.()?.length ?? 0, + ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + } + }, worktreeId), + (value) => Boolean(value?.ptyId), + ptyWaitTimeoutMs + ) + return { ...value, waitMs: Date.now() - startedAt } +} + +function makeOutputCommand(marker, outputLines) { + const payload = 'x'.repeat(180) + return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r` +} + +async function getTerminalContent(page, charLimit = 20_000) { + return await runWithTimeout( + 'terminal content', + () => + page.evaluate((limit) => { + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit) + }, charLimit), + rendererActionTimeoutMs + ) +} + +async function sendOutputStress(page, ptyId, marker, outputLines) { + await runWithTimeout( + `write terminal stress ${marker}`, + () => + page.evaluate( + ({ id, command }) => { + window.api.pty.write(id, command) + }, + { id: ptyId, command: makeOutputCommand(marker, outputLines) } + ), + rendererActionTimeoutMs + ) + await pollUntil( + `terminal stress marker ${marker}`, + async () => (await getTerminalContent(page)).includes(`${marker}_DONE`), + Boolean, + terminalMarkerTimeoutMs, + 200 + ) +} + +async function pingMainIpc(page) { + const startedAt = Date.now() + await runWithTimeout( + 'main IPC ping', + () => page.evaluate(() => window.api.pty.listSessions()), + rendererActionTimeoutMs + ) + return Date.now() - startedAt +} + +async function readRendererProbe(page) { + return await runWithTimeout( + 'renderer probe read', + () => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null), + rendererActionTimeoutMs + ) +} + +async function killActivePty(page) { + return await runWithTimeout( + 'kill active PTY', + () => + page.evaluate(async () => { + const state = window.__store?.getState?.() + const tabId = state?.activeTabId ?? null + const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + const ptyId = ptyIds[0] ?? null + if (!ptyId) { + return null + } + await window.api.pty.kill(ptyId) + return ptyId + }), + rendererActionTimeoutMs + ) +} + +async function resetRendererProbe(page) { + // Why: probe drift is cumulative since installation; without a per-cycle + // reset, one startup/setup stall gets re-reported as a hang in every + // later cycle's rendererMaxDriftMs check. + await runWithTimeout( + 'renderer probe reset', + () => + page.evaluate(() => { + const probe = globalThis.__orcaApphangProbe?.probe + if (!probe) { + return + } + const now = performance.now() + probe.last = now + probe.lastTickAt = now + probe.startedAt = now + probe.maxDriftMs = 0 + probe.samples = 0 + }), + rendererActionTimeoutMs + ) +} + +async function runActivationCycle(page, target, args) { + await resetRendererProbe(page) + const marker = `ORCA_APPHANG_${target.index}_${Date.now()}` + const cycleStartedAt = Date.now() + const activationStartedAt = Date.now() + await clickWorktreeCard(page, target.id) + const activation = await waitForActiveWorktree(page, target.id) + const activationMs = Date.now() - activationStartedAt + const activePty = await waitForActivePty(page, target.id) + const diagnosticsBeforeOutput = await collectRendererDiagnostics(page) + const outputStartedAt = Date.now() + await sendOutputStress(page, activePty.ptyId, marker, args.outputLines) + const outputMs = Date.now() - outputStartedAt + const mainIpcMs = await pingMainIpc(page) + const rendererProbe = await readRendererProbe(page) + const diagnosticsAfterOutput = await collectRendererDiagnostics(page) + const elapsedMs = Date.now() - cycleStartedAt + const sample = { + index: target.index, + kind: target.kind, + worktreeId: target.id, + displayName: target.displayName, + elapsedMs, + activationMs, + outputMs, + activation, + ptyWaitMs: activePty.waitMs, + ptyId: activePty.ptyId, + tabId: activePty.tabId, + mainIpcMs, + rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null, + renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null, + webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null, + diagnosticsBeforeOutput, + diagnosticsAfterOutput + } + + const reproducedReasons = [] + if (activationMs > severeActivationMs) { + reproducedReasons.push(`worktree activation took ${activationMs}ms`) + } + if (activePty.waitMs > severePtyWaitMs) { + reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`) + } + if (mainIpcMs > severeMainIpcMs) { + reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`) + } + if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) { + reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`) + } + if (reproducedReasons.length > 0) { + throw new ReproductionObservedError(reproducedReasons.join('; '), sample) + } + return sample +} + +async function runDeadPtyReactivation(page, targets, args) { + const samples = [] + for (const target of targets) { + await clickWorktreeCard(page, target.id) + await waitForActiveWorktree(page, target.id) + await waitForActivePty(page, target.id) + const killedPtyId = await killActivePty(page) + samples.push({ worktreeId: target.id, killedPtyId }) + } + for (const target of targets) { + samples.push( + await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args) + ) + } + return samples +} + +function selectTargets(setupResult, cycles) { + const gitWorktrees = setupResult.gitWorktrees + .filter((worktree) => worktree.path) + .sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree)) + .slice(0, 5) + .map((worktree) => ({ ...worktree, kind: 'git-wsl' })) + const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({ + ...worktree, + kind: 'plain-folder-wsl' + })) + const baseTargets = [...gitWorktrees, ...plainWorktrees] + if (baseTargets.length === 0) { + throw new Error('No worktrees were discovered for the repro fixture.') + } + return Array.from({ length: cycles }, (_, index) => ({ + ...baseTargets[index % baseTargets.length], + index: index + 1 + })) +} + +export async function runGpuMode(gpuMode, args, fixture) { + const cdpPort = await pickFreePort() + const userDataDir = createGpuUserDataDirectory(gpuMode) + const launched = launchDevApp({ cdpPort, userDataDir }) + let browser = null + let page = null + const startedAt = Date.now() + const result = { + gpuMode, + reproduced: false, + reproductionReason: null, + harnessError: null, + startedAt, + elapsedMs: null, + cdpPort, + userDataDir, + appPid: launched.child.pid ?? null, + setup: null, + samples: [], + finalDiagnostics: null, + trace: null, + appLogSummary: null, + appLogsTail: null, + cleanupErrors: [] + } + + try { + const connected = await connectToApp(cdpPort) + browser = connected.browser + page = connected.page + await waitForStoreReady(page) + await installRendererProbe(page) + const identity = await runWithTimeout( + 'app identity', + () => page.evaluate(() => window.api.app.getIdentity?.()), + rendererActionTimeoutMs + ).catch(() => null) + console.log( + `[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}` + ) + result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl) + const targets = selectTargets(result.setup, args.cycles) + console.log( + `[apphang-repro] targets gpu=${gpuMode}: ${targets + .map((target) => `${target.kind}:${target.displayName}`) + .join(', ')}` + ) + for (const target of targets) { + console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`) + result.samples.push(await runActivationCycle(page, target, args)) + } + if (args.deadPtyReactivate) { + const uniqueTargets = [] + const seen = new Set() + for (const target of targets) { + if (!seen.has(target.id)) { + seen.add(target.id) + uniqueTargets.push(target) + } + } + console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`) + result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args))) + } + } catch (error) { + if (error instanceof ReproductionObservedError) { + result.reproduced = true + result.reproductionReason = error.message + result.samples.push(error.evidence) + } else { + // Why: setup/CDP/selector failures are inconclusive, not hang evidence. + // Conflating them with `reproduced` lets --expect=repro pass on a broken + // harness; callers treat harnessError as a hard failure instead. + result.harnessError = error instanceof Error ? error.stack || error.message : String(error) + } + } finally { + result.elapsedMs = Date.now() - startedAt + result.finalDiagnostics = await collectRendererDiagnostics(page) + result.trace = summarizeTrace(userDataDir) + result.appLogSummary = summarizeAppLogs(launched.logs) + result.appLogsTail = launched.logs.slice(-120) + if (browser) { + await browser.close().catch(() => undefined) + } + await stopDevApp(launched.child) + if (!args.keep) { + safeRemoveLocalDirectory(userDataDir, result.cleanupErrors) + } + } + return result +} diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs new file mode 100644 index 00000000000..cd38f400d47 --- /dev/null +++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs @@ -0,0 +1,135 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function runWsl(distro, script, options = {}) { + return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], { + cwd: rootDir, + encoding: 'utf8', + input: script, + timeout: options.timeoutMs ?? 120_000, + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +export function listWslDistros() { + const output = execFileSync('wsl.exe', ['--list', '--quiet'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000 + }) + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter((line) => line && !line.toLowerCase().startsWith('docker-desktop')) +} + +function linuxPathToWslUnc(distro, linuxPath) { + if (!linuxPath.startsWith('/')) { + throw new Error(`Expected absolute Linux path, got ${linuxPath}`) + } + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + +export function createWslFixture(distro) { + const script = String.raw` +set -euo pipefail +base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)" +repo="$base/repo" +mkdir -p "$repo" +cd "$repo" +git init -q +git config user.email apphang-repro@test.local +git config user.name "AppHang Repro" +mkdir -p src docs +printf '# Orca Windows AppHang repro\n' > README.md +for n in $(seq 1 25); do + printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt +done +git add -A +git commit -q -m "Initial repro fixture" +for n in 1 2 3 4; do + git branch "repro-wt-$n" + git worktree add -q "$base/wt-$n" "repro-wt-$n" + mkdir -p "$base/wt-$n/docs" + printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt" +done +plain="$base/plain-folder" +mkdir -p "$plain/subdir" +printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt" +printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain" +` + const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean) + if (lines.length < 7) { + throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`) + } + const [base, repo, ...rest] = lines + const plain = rest.at(-1) + const worktrees = rest.slice(0, -1) + return { + distro, + baseLinuxPath: base, + repoLinuxPath: repo, + plainLinuxPath: plain, + worktreeLinuxPaths: worktrees, + repoUncPath: linuxPathToWslUnc(distro, repo), + plainUncPath: linuxPathToWslUnc(distro, plain), + worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry)) + } +} + +export function removeWslFixture(fixture) { + if (!fixture?.baseLinuxPath) { + return + } + const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''") + runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 }) +} + +export function createCompletedOnboardingProfile(userDataDir) { + mkdirSync(userDataDir, { recursive: true }) + const profile = { + settings: { + telemetry: { + // Why: synthetic harness/benchmark activity must not send telemetry. + // Explicit false with existedBeforeTelemetryRelease=false also keeps + // the first-launch consent surface from blocking automation. + optedIn: false, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: 4, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: 5 + }, + ui: { + contextualToursAutoEligible: false, + contextualToursSeenIds: [ + 'workspace-board', + 'browser', + 'tasks', + 'automations', + 'workspace-creation' + ], + featureTipsSeenIds: [], + featureInteractions: {}, + projectOrderManualDefaultNoticeDismissed: true + } + } + writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`) +} + +export function safeRemoveLocalDirectory(dir, cleanupErrors) { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } catch (error) { + cleanupErrors.push(error instanceof Error ? error.message : String(error)) + } +} diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md index 5cd3b89ff20..c31c10d243d 100644 --- a/notes/windows-perf-progress.md +++ b/notes/windows-perf-progress.md @@ -4,6 +4,69 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported (2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance. All changes must be proven with before/after benchmark numbers. +## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency + +Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes. +Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase +timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture). +Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines +(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn). + +Findings (baseline, this machine): +- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it — + ~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts + admitted this). Premise (16-context budget) stale since #7064 raised budget to 128. +- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon + provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new + pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first + ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal. +- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs + scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear + + refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts + recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers + risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) — + left as follow-up. +- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system + ConPTY corruption + perf differences on degraded-mode/fresh-local spawns). + +Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis): +- A: WebGL context retention across hide/show (pane-webgl-context-retention.ts, LRU cap 32 — + sized for ~10-16 worktrees × ~2 terminals hot sets; suspend keeps live contexts; resume + repaints instead of recreating). Gated to Windows (getRendererAppPlatform() === 'win32'): + macOS/Linux context creation is cheap, so they keep dispose-on-hide and pay no retention + GPU-memory cost. Peer note: at least one comparable product raises the context budget to + 256 and retains unbounded with a session-wide DOM latch on loss; we bound + recover instead. +- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon. +- D: atlas recovery bursts skip suspended panes (scoped to visible); retained panes repaint + on resume (shared-atlas invariant preserved). +- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the + first user terminal doesn't pay the ~2.7s first-ConPTY cost. + +Follow-ups (documented, not in this branch): +- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual + tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback + when column count changed while hidden. +- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons + + synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF, + pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes. +- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is + Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty + → rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at + that renderer connect layer, not in reconnectPersistedTerminals. +- First terminal opened immediately after launch also waits on the one-time daemon-init + barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured + preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays + it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain + existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it + does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize + on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing + behind the in-flight warm-up, not unwarmed cost. +- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn + storms serializing the daemon loop. + +Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim +PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts). + ## Status - [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 07430b5fa3b..aeffb7aa2a5 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -8,6 +8,7 @@ */ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' +import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { @@ -84,6 +85,8 @@ async function main(): Promise { if (process.send) { process.send({ type: 'ready' }) } + + warmWindowsConptyOnce() } // Only auto-run when executed directly (not imported for testing) diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts new file mode 100644 index 00000000000..1ab19b0c97e --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as pty from 'node-pty' +import { warmWindowsConptyOnce } from './windows-conpty-warmup' + +function setPlatform(platform: NodeJS.Platform): () => void { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + return () => Object.defineProperty(process, 'platform', { configurable: true, value: original }) +} + +function flushImmediates(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +let restorePlatform: (() => void) | null = null +afterEach(() => { + restorePlatform?.() + restorePlatform = null + vi.restoreAllMocks() +}) + +function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { + let exitListener: (() => void) | null = null + const proc = { + pid: 4321, + kill: vi.fn(), + onExit: vi.fn((listener: () => void) => { + exitListener = listener + return { dispose: () => undefined } + }) + } as unknown as pty.IPty + return { proc, fireExit: () => exitListener?.() } +} + +describe('warmWindowsConptyOnce', () => { + it('is a no-op off Windows', async () => { + restorePlatform = setPlatform('darwin') + const spawnPty = vi.fn() as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).not.toHaveBeenCalled() + }) + + it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { + restorePlatform = setPlatform('win32') + const { proc, fireExit } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).toHaveBeenCalledTimes(1) + const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] + expect(String(file).toLowerCase()).toContain('cmd') + expect(args).toEqual(['/c', 'exit']) + expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 }) + + // A clean exit must not leave the kill timer to fire later. + fireExit() + expect(proc.kill).not.toHaveBeenCalled() + }) + + it('kills the warm-up shell if it never exits', async () => { + restorePlatform = setPlatform('win32') + vi.useFakeTimers() + try { + const { proc } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(10_000) + + expect(proc.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('swallows spawn failures', async () => { + restorePlatform = setPlatform('win32') + const spawnPty = vi.fn(() => { + throw new Error('conpty unavailable') + }) as unknown as typeof pty.spawn + + expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow() + await flushImmediates() + }) +}) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts new file mode 100644 index 00000000000..aea1c50b2db --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -0,0 +1,45 @@ +import os from 'node:os' +import * as pty from 'node-pty' + +const WARMUP_KILL_TIMEOUT_MS = 10_000 + +/** + * Pays the one-time cost of the first ConPTY spawn (conpty native module + * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of + * those binaries) at daemon boot instead of on the user's first terminal. + * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. + */ +export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void { + if (process.platform !== 'win32') { + return + } + // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a + // real spawn arriving first simply does the warming itself. + setImmediate(() => { + try { + const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd: os.homedir(), + env: process.env as Record, + // Match real terminal spawns so the bundled ConPTY binaries are the + // ones warmed, not the legacy system ConPTY. + useConptyDll: true + }) + const killTimer = setTimeout(() => { + try { + proc.kill() + } catch { + /* best-effort cleanup of a stuck warm-up shell */ + } + }, WARMUP_KILL_TIMEOUT_MS) + killTimer.unref?.() + proc.onExit(() => { + clearTimeout(killTimer) + }) + } catch { + /* warm-up is best-effort; real spawns surface their own errors */ + } + }) +} diff --git a/src/main/ipc/pty-spawn-timing.ts b/src/main/ipc/pty-spawn-timing.ts new file mode 100644 index 00000000000..0ab0e12c800 --- /dev/null +++ b/src/main/ipc/pty-spawn-timing.ts @@ -0,0 +1,41 @@ +// Why: pty:spawn latency has four very different suspects (startup barrier, +// Claude auth prep, buildPtyHostEnv filesystem work, provider/daemon spawn). +// A single opt-in log line per spawn lets benchmarks attribute the cost +// without a tracing dependency. Enabled via ORCA_PTY_SPAWN_TIMING=1. + +export type PtySpawnTiming = { + mark(phase: string): void + log(id: string, extra?: Record): void +} + +const noopTiming: PtySpawnTiming = { + mark: () => undefined, + log: () => undefined +} + +export function createPtySpawnTiming(): PtySpawnTiming { + const flag = process.env.ORCA_PTY_SPAWN_TIMING + if (!flag || flag === '0' || flag.toLowerCase() === 'false') { + return noopTiming + } + const startedAt = Date.now() + let lastAt = startedAt + const phases: string[] = [] + return { + mark(phase: string): void { + const now = Date.now() + phases.push(`${phase}=${now - lastAt}ms`) + lastAt = now + }, + log(id: string, extra?: Record): void { + const extras = extra + ? ` ${Object.entries(extra) + .map(([key, value]) => `${key}=${value}`) + .join(' ')}` + : '' + console.log( + `[pty-spawn-timing] id=${id} total=${Date.now() - startedAt}ms ${phases.join(' ')}${extras}` + ) + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 0afc0c3e4e0..c12f8466d3f 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2331,7 +2331,9 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() + // Why: kill's shutdown now runs through the exit-detection wrapper, + // which adds async hops; a single microtask flush is no longer enough. + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false }) expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( @@ -2342,6 +2344,113 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) }) + it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + kill: (ptyId: string) => boolean + } + + expect(controller.kill('local-pty')).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + + it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => { + vi.useFakeTimers() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + const stopPromise = controller.stopAndWait('local-pty') + await vi.advanceTimersByTimeAsync(1_200) + await expect(stopPromise).resolves.toBe(true) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + it('passes keepHistory through runtime controller stopAndWait', async () => { vi.useFakeTimers() const shutdown = vi.fn(async () => undefined) @@ -2566,7 +2675,7 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false }) expect(localShutdown).not.toHaveBeenCalled() @@ -2701,8 +2810,7 @@ describe('registerPtyHandlers', () => { try { expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') @@ -2911,6 +3019,104 @@ describe('registerPtyHandlers', () => { keepHistory: true }) expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'local-pty', + code: -1 + }) + }) + + it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: 0 }]] + ) + }) + + it('ignores a late provider exit after synthesizing kill exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + for (const listener of exitListeners) { + listener({ id: 'local-pty', code: 0 }) + } + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: -1 }]] + ) }) it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9f654ea1e1f..af29900b379 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -39,6 +39,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -111,6 +112,7 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() +const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent // write/resize/kill calls only carry the PTY ID. This map lets us route // post-spawn operations to the correct provider without the renderer needing @@ -1656,6 +1658,82 @@ export function registerPtyHandlers( flushTimer = null } + const syntheticKillExitPtyIds = new Map() + + function rememberSyntheticKillExit(id: string): void { + const existing = syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why: some providers can report the real exit after kill has already + // completed; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + syntheticKillExitPtyIds.set(id, cleanupTimer) + } + + function consumeSyntheticKillExit(id: string): boolean { + const cleanupTimer = syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + syntheticKillExitPtyIds.delete(id) + return true + } + + function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + if (mainWindow.isDestroyed()) { + return + } + // Why: flush any batched data for this PTY before sending the exit event, + // otherwise the last <=8ms of output is silently lost because the renderer + // tears down the terminal on pty:exit before the batch timer fires. + const remaining = pendingData.get(payload.id) + if (remaining) { + sendPtyDataToRenderer( + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) + ) + pendingData.delete(payload.id) + } + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + rendererInFlightTotalChars = Math.max( + 0, + rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + ) + rendererInFlightCharsByPty.delete(payload.id) + recordPtyRendererDeliveryPressure() + mainWindow.webContents.send('pty:exit', payload) + } + + async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean } + ): Promise { + let providerExitObserved = false + const unsubscribe = provider.onExit((payload) => { + if (payload.id === id) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved + } + // Why: extracted so the "Restart daemon" flow can rebind against the fresh // adapter after replaceDaemonProvider runs. Both the startup registration // and the post-restart rebind go through the same code path — no risk of @@ -1745,39 +1823,16 @@ export function registerPtyHandlers( } }) localExitUnsub = localProvider.onExit((payload) => { + if (consumeSyntheticKillExit(payload.id)) { + return + } if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) runtime?.onPtyExit(payload.id, payload.code) } - if (!mainWindow.isDestroyed()) { - // Why: flush any batched data for this PTY before sending the exit event, - // otherwise the last ≤8ms of output is silently lost because the renderer - // tears down the terminal on pty:exit before the batch timer fires. - const remaining = pendingData.get(payload.id) - if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput - ) - ) - pendingData.delete(payload.id) - } - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) - ) - rendererInFlightCharsByPty.delete(payload.id) - recordPtyRendererDeliveryPressure() - mainWindow.webContents.send('pty:exit', payload) - } + sendPtyExitToRenderer(payload) }) } @@ -2240,6 +2295,8 @@ export function registerPtyHandlers( // not revive a terminal the user explicitly closed. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false @@ -2247,16 +2304,25 @@ export function registerPtyHandlers( // Why: shutdown() is async but the PtyController interface is sync. Defer // cleanup until shutdown resolves so transient SSH/daemon failures don't // hide a still-running remote process or local daemon session. - void provider - .shutdown(ptyId, { immediate: false }) - .then(() => { + // + // Same synthetic-exit contract as the renderer pty:kill handler: when the + // provider emitted its own exit during shutdown, the exit listener already + // delivered runtime + renderer exits — synthesizing again would double-fire. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } }) .catch((err) => { if (isPtyAlreadyGoneError(err)) { finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return } console.warn( @@ -2283,12 +2349,15 @@ export function registerPtyHandlers( // await, but the relay lease must still be tombstoned. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false } + let providerExitObserved = false try { - await provider.shutdown(ptyId, { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { immediate: true, keepHistory: opts?.keepHistory ?? false }) @@ -2313,7 +2382,11 @@ export function registerPtyHandlers( return false } finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } return true }, getForegroundProcess: async (ptyId) => { @@ -2461,11 +2534,13 @@ export function registerPtyHandlers( } } ) => { + const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) + spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2488,6 +2563,7 @@ export function registerPtyHandlers( ) const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null + spawnTiming.mark('auth') if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -2713,6 +2789,7 @@ export function registerPtyHandlers( throw err } } + spawnTiming.mark('host_env') const spawnEnv = preAllocatedHandle ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } : env @@ -2807,7 +2884,9 @@ export function registerPtyHandlers( startupTerminalColorQueryReplyColors ) } + spawnTiming.mark('options') result = await provider.spawn(spawnOptions) + spawnTiming.mark('provider_spawn') } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) @@ -2868,6 +2947,10 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + spawnTiming.log(result.id, { + daemon: isDaemonHostSpawn, + reattach: result.isReattach ?? false + }) ptyOwnership.set(result.id, args.connectionId ?? null) if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { @@ -3396,10 +3479,14 @@ export function registerPtyHandlers( // before ownership is rebuilt. Tombstone instead of falling back local. finishPtyShutdown(args.id, connectionId, store) runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) return } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false try { - await (provider ?? getProviderForPty(args.id)).shutdown(args.id, { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { immediate: true, keepHistory: args.keepHistory ?? false }) @@ -3412,11 +3499,14 @@ export function registerPtyHandlers( } /* session already dead — cleanup below handles the rest */ } - // Why: onExit clears provider state for LocalPtyProvider, but remote SSH - // and daemon shutdown paths do not emit onExit through the local provider's - // listener. Explicit cleanup is idempotent and covers already-dead PTYs. + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) + } }) ipcMain.handle( diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 48b35cd27c0..0de16d03f17 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -76,13 +76,13 @@ describe('spawnShellWithFallback on Windows', () => { 1, PWSH7, attempts[0].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) expect(ptySpawn).toHaveBeenNthCalledWith( 2, WINDOWS_POWERSHELL, attempts[1].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) }) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 0ad6effc9c9..c8eb87c249d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -160,6 +160,14 @@ export type ShellSpawnResult = { * executables with per-shell args, so when the primary fails we retry with the * next safe shell instead of leaving the user with no terminal. */ +// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY +// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can +// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and +// fresh-local spawns silently behave differently from daemon terminals. +function windowsConptyDllOptions(): { useConptyDll: true } | Record { + return process.platform === 'win32' ? { useConptyDll: true } : {} +} + function spawnWindowsFallbackChain( params: ShellSpawnParams, primaryError: string @@ -174,7 +182,8 @@ function spawnWindowsFallbackChain( cols, rows, cwd: attempt.effectiveCwd, - env + env, + ...windowsConptyDllOptions() }) console.warn( `[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"` @@ -217,7 +226,14 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu if (!primaryError) { try { return { - process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }), + process: ptySpawn(shellPath, shellArgs, { + name: termName, + cols, + rows, + cwd, + env, + ...windowsConptyDllOptions() + }), shellPath } } catch (err) { diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 7756e7904c6..490b09bb065 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -237,11 +237,7 @@ export function disposePane( } catch { /* ignore */ } - try { - pane.webglAddon?.dispose() - } catch { - /* ignore */ - } + disposeWebgl(pane) try { pane.searchAddon.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 61ff5a7d42d..0a30f50f118 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -168,6 +168,11 @@ export class PaneManager { refreshAllPanes(): void { for (const pane of this.panes.values()) { + // Why: suspended panes are invisible and repaint on rendering resume; + // recovery repaints must not scale with hidden-workspace pane count. + if (pane.webglAttachmentDeferred) { + continue + } try { if (pane.terminal.rows > 0) { pane.terminal.refresh(0, pane.terminal.rows - 1) diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 8ad22f07eca..c79598ff0eb 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -7,6 +7,11 @@ import { markComplexScriptOutput, resetWebglTextureAtlas } from './pane-webgl-renderer' +import { + retainSuspendedWebglPane, + shouldRetainSuspendedWebglContexts, + unretainWebglPane +} from './pane-webgl-context-retention' import { reattachWebglIfNeeded } from './pane-webgl-reattach' export function setPaneGpuRenderingState( @@ -43,9 +48,20 @@ export function markPaneComplexScriptOutput( } export function suspendPaneRendering(panes: Iterable): void { + const retainContexts = shouldRetainSuspendedWebglContexts() for (const pane of panes) { + // Why: deferred blocks NEW context creation while hidden; on Windows, + // live contexts are retained (not disposed) so the return switch repaints + // instantly instead of paying ANGLE context re-creation. The LRU cap + // bounds how many hidden panes may keep one. pane.webglAttachmentDeferred = true - disposeWebgl(pane) + if (!retainContexts) { + disposeWebgl(pane) + continue + } + for (const evicted of retainSuspendedWebglPane(pane)) { + disposeWebgl(evicted) + } } } @@ -55,8 +71,23 @@ export function resumePaneRendering(panes: Iterable): void // loss, and bounding retries to resume events cannot loop on live loss. clearTerminalWebglAttachBackoff() for (const pane of panes) { + unretainWebglPane(pane) pane.webglAttachmentDeferred = false pane.webglDisabledAfterContextLoss = false + if (pane.webglAddon) { + // Why: recovery bursts skip suspended panes, so the shared glyph atlas + // may have been cleared/rebuilt while this pane sat hidden with its + // retained context. Repaint from the buffer so stale glyph coordinates + // never reach the screen. + try { + if (pane.terminal.rows > 0) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } + } catch { + /* ignore — pane may be tearing down during resume */ + } + continue + } reattachWebglIfNeeded(pane) } } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts new file mode 100644 index 00000000000..eb4c14bc6d5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + clearRetainedWebglPanes, + RETAINED_WEBGL_PANE_CAP, + retainedWebglPaneCount +} from './pane-webgl-context-retention' +import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control' +import { + attachWebgl, + disposeWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +let nextPaneId = 1 + +function createPane(): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never + return { + id: nextPaneId++, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function createAttachedPane(): ManagedPaneInternal { + const pane = createPane() + attachWebgl(pane) + expect(pane.webglAddon).not.toBeNull() + return pane +} + +function fireContextLoss(pane: ManagedPaneInternal): void { + const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } } + addon._onContextLoss.fire() +} + +function stubCommonGlobals(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +} + +describe('terminal WebGL context retention across hide/show (Windows)', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + clearRetainedWebglPanes() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + stubCommonGlobals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + }) + + afterEach(() => { + clearRetainedWebglPanes() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the live WebGL addon when rendering is suspended', () => { + const pane = createAttachedPane() + const addon = pane.webglAddon + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(pane.webglAddon).toBe(addon) + expect(retainedWebglPaneCount()).toBe(1) + }) + + it('does not create a new context when resuming a retained pane', () => { + const pane = createAttachedPane() + const addon = pane.webglAddon + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + + suspendPaneRendering([pane]) + resumePaneRendering([pane]) + + expect(pane.webglAddon).toBe(addon) + expect(pane.webglAttachmentDeferred).toBe(false) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(retainedWebglPaneCount()).toBe(0) + }) + + it('still blocks new context creation while suspended', () => { + const pane = createPane() + + suspendPaneRendering([pane]) + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(pane.terminal.loadAddon).not.toHaveBeenCalled() + }) + + it('evicts and disposes the least-recently-suspended pane past the cap', () => { + const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 2 }, () => createAttachedPane()) + + suspendPaneRendering(panes) + + expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_CAP) + expect(panes[0].webglAddon).toBeNull() + expect(panes[1].webglAddon).toBeNull() + expect(panes[2].webglAddon).not.toBeNull() + expect(panes.at(-1)?.webglAddon).not.toBeNull() + }) + + it('re-attaches an evicted pane on resume', () => { + const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 1 }, () => createAttachedPane()) + suspendPaneRendering(panes) + expect(panes[0].webglAddon).toBeNull() + + resumePaneRendering([panes[0]]) + + expect(panes[0].webglAddon).not.toBeNull() + expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2) + }) + + it('drops the retention entry when a suspended pane is disposed', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + expect(retainedWebglPaneCount()).toBe(1) + + disposeWebgl(pane) + + expect(retainedWebglPaneCount()).toBe(0) + expect(pane.webglAddon).toBeNull() + }) + + it('repaints a retained pane on resume so a cleared shared atlas cannot leave stale glyphs', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + vi.mocked(pane.terminal.refresh).mockClear() + + resumePaneRendering([pane]) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, pane.terminal.rows - 1) + }) + + it('skips suspended panes during atlas recovery resets', () => { + const pane = createAttachedPane() + const clearTextureAtlas = vi.fn() + ;(pane.webglAddon as unknown as { clearTextureAtlas: () => void }).clearTextureAtlas = + clearTextureAtlas + suspendPaneRendering([pane]) + vi.mocked(pane.terminal.refresh).mockClear() + + resetWebglTextureAtlas(pane) + + expect(clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + + it('drops the retention entry on context loss while hidden and recovers on resume', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + + fireContextLoss(pane) + + expect(pane.webglAddon).toBeNull() + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(retainedWebglPaneCount()).toBe(0) + + resumePaneRendering([pane]) + + expect(pane.webglDisabledAfterContextLoss).toBe(false) + expect(pane.webglAddon).not.toBeNull() + }) +}) + +describe('terminal WebGL context retention is Windows-only', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + clearRetainedWebglPanes() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + stubCommonGlobals('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + }) + + afterEach(() => { + clearRetainedWebglPanes() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the dispose-on-hide behavior off Windows', () => { + const pane = createAttachedPane() + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(pane.webglAddon).toBeNull() + expect(retainedWebglPaneCount()).toBe(0) + }) + + it('re-attaches on resume off Windows as before', () => { + const pane = createAttachedPane() + suspendPaneRendering([pane]) + + resumePaneRendering([pane]) + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts new file mode 100644 index 00000000000..282d57fc5e7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts @@ -0,0 +1,56 @@ +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import type { ManagedPaneInternal } from './pane-manager-types' + +// Why: workspace switches used to dispose every hidden pane's WebGL context +// and recreate it on return — ~5ms on macOS but 100-500ms per pane on Windows +// (ANGLE → D3D11), paid synchronously on the switch path. With the app-level +// context budget raised to 128 (#7064), hidden panes can keep their contexts; +// this LRU cap only bounds hidden-pane GPU memory. Sized for the reported hot +// set (~10-16 worktrees × ~2 terminals) while staying far under the budget. +export const RETAINED_WEBGL_PANE_CAP = 32 + +/** Windows-only: context re-creation is cheap on macOS/Linux GL, so hidden + * panes there keep the long-proven dispose-on-hide behavior instead of + * paying retention's GPU-memory cost for no perceptible switch latency win. */ +export function shouldRetainSuspendedWebglContexts(): boolean { + return getRendererAppPlatform() === 'win32' +} + +// Insertion order doubles as LRU order: re-retaining deletes + re-adds. +const retainedPanes = new Set() + +/** + * Registers a suspended pane's live WebGL context for retention across + * hide/show. Returns panes evicted by the cap — the caller disposes them + * (dependency points that way to avoid a cycle with pane-webgl-renderer). + */ +export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal[] { + if (!pane.webglAddon) { + return [] + } + retainedPanes.delete(pane) + retainedPanes.add(pane) + const evicted: ManagedPaneInternal[] = [] + while (retainedPanes.size > RETAINED_WEBGL_PANE_CAP) { + const oldest = retainedPanes.values().next().value + if (!oldest) { + break + } + retainedPanes.delete(oldest) + evicted.push(oldest) + } + return evicted +} + +export function unretainWebglPane(pane: ManagedPaneInternal): void { + retainedPanes.delete(pane) +} + +export function retainedWebglPaneCount(): number { + return retainedPanes.size +} + +/** Drops all retention entries without disposing anything (test isolation). */ +export function clearRetainedWebglPanes(): void { + retainedPanes.clear() +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 6cff7287564..846b2779abc 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -65,6 +65,30 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('actively releases the xterm WebGL context before disposing the addon', () => { + const loseContext = vi.fn() + const canvas = { width: 120, height: 40 } + const dispose = vi.fn() + const pane = createPane({ + webglAddon: { + dispose, + _renderer: { + _gl: { + getExtension: vi.fn(() => ({ loseContext })) + }, + _canvas: canvas + } + } as never + }) + + disposeWebgl(pane) + + expect(loseContext).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(canvas).toEqual({ width: 0, height: 0 }) + expect(pane.webglAddon).toBeNull() + }) + it('cancels a pending WebGL refresh when the pane is disposed', () => { const cancelAnimationFrame = vi.fn() vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index cca7cc9dd17..1b129c2ae9a 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { unretainWebglPane } from './pane-webgl-context-retention' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -14,6 +15,17 @@ let suggestedRendererType: 'dom' | undefined // until the next recovery boundary (rendering resume or GPU-setting change). let webglAttachFailedSinceRecovery = false +type ReleasableWebglContext = { + getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null +} + +type XtermWebglAddonInternals = { + _renderer?: { + _gl?: ReleasableWebglContext + _canvas?: HTMLCanvasElement + } +} + export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. @@ -61,9 +73,14 @@ export function disposeWebgl( options?: { refreshDimensions?: boolean } ): void { cancelPendingWebglRefresh(pane) + // Why: every dispose path (pane close, context loss, GPU-setting off) must + // drop the retention entry, or the LRU would later evict-dispose a pane + // whose addon was already gone. + unretainWebglPane(pane) if (!pane.webglAddon) { return } + releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() } catch { @@ -85,12 +102,32 @@ export function disposeWebgl( } } +function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { + try { + // Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the + // driver context alive long enough for rapid terminal activation to hit + // Chromium's active WebGL context budget (#6874). + const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer + renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext() + if (renderer?._canvas) { + renderer._canvas.width = 0 + renderer._canvas.height = 0 + } + } catch { + /* ignore - WebGL teardown must not block fallback to the DOM renderer */ + } +} + export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (pane.webglDisabledAfterContextLoss) { + // Why: suspended panes keep retained contexts but are invisible; their + // rebuild is deferred to resumePaneRendering. Clearing + repainting them + // here would make every recovery burst scale with total pane count across + // all workspaces instead of visible panes. + if (pane.webglDisabledAfterContextLoss || pane.webglAttachmentDeferred) { return } try { diff --git a/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json new file mode 100644 index 00000000000..87d8634572f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json @@ -0,0 +1,1041 @@ +{ + "label": "baseline", + "startedAt": "2026-07-02T07:23:46.933Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 2.700000047683716, + "ptyBindMs": 5853.5, + "firstOutputMs": 5875.900000035763, + "paintSettleMs": 5878.100000023842, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 476.9000000357628, + "firstOutputMs": 489.10000002384186, + "paintSettleMs": 493.10000002384186, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 381.60000002384186, + "firstOutputMs": 401, + "paintSettleMs": 404.10000002384186, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.300000011920929, + "ptyBindMs": 391.10000002384186, + "firstOutputMs": 405.80000001192093, + "paintSettleMs": 408.10000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.5, + "ptyBindMs": 454, + "firstOutputMs": 467.80000001192093, + "paintSettleMs": 474, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2, + "ptyBindMs": 527.6999999880791, + "firstOutputMs": 544.5, + "paintSettleMs": 551.3999999761581, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 483.60000002384186, + "firstOutputMs": 498.5, + "paintSettleMs": 501.10000002384186, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 492.89999997615814, + "firstOutputMs": 509.19999998807907, + "paintSettleMs": 517, + "timedOut": false + } + ], + "longtasks": [ + 411, 65, 184, 124, 191, 163, 141, 183, 59, 158, 217, 61, 61, 253, 89, 70, 248, 152, 73, 170, + 184, 160, 178, 176, 151 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 109, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 114.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 138.5, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 73, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 79.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 43.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 125.89999997615814, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 116.5, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 51.80000001192093, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 45.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 50.30000001192093, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 97.89999997615814, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 112.5, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 48.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.5, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 28.30000001192093, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 0.8999999761581421, + "paneVisibleMs": 0.8999999761581421, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 150.70000004768372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 95.60000002384186, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 75.79999995231628, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 40.30000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 30.099999964237213, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 86.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 133, + "timedOut": false + } + ], + "longtasks": [98, 109, 130, 66, 71, 117, 110, 94, 100, 144, 91, 64, 82, 122] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 202, + "ptyBindMs": 204, + "paintSettleMs": 211 + }, + { + "index": 1, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 406, + "ptyBindMs": 550, + "paintSettleMs": 563 + }, + { + "index": 2, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 373, + "ptyBindMs": 476, + "paintSettleMs": 488 + }, + { + "index": 3, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 205, + "ptyBindMs": 251, + "paintSettleMs": 258 + }, + { + "index": 4, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 345, + "ptyBindMs": 369, + "paintSettleMs": 379 + }, + { + "index": 5, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 149, + "ptyBindMs": 177, + "paintSettleMs": 184 + }, + { + "index": 6, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 147, + "ptyBindMs": 162, + "paintSettleMs": 170 + }, + { + "index": 7, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 178, + "ptyBindMs": 202, + "paintSettleMs": 208 + }, + { + "index": 8, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 336, + "ptyBindMs": 366, + "paintSettleMs": 375 + }, + { + "index": 9, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 157, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 10, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 140, + "ptyBindMs": 164, + "paintSettleMs": 173 + }, + { + "index": 11, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 159, + "ptyBindMs": 188, + "paintSettleMs": 199 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 484, + "p95": 5854, + "max": 5854 + }, + "firstOutputMs": { + "count": 8, + "median": 499, + "p95": 5876, + "max": 5876 + }, + "paintSettleMs": { + "count": 8, + "median": 501, + "p95": 5878, + "max": 5878 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 411 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 1, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 80, + "p95": 139, + "max": 151 + } + }, + "longtaskCount": 14, + "longtaskMaxMs": 144 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 202, + "p95": 373, + "max": 406 + }, + "ptyBindMs": { + "count": 12, + "median": 204, + "p95": 476, + "max": 550 + }, + "paintSettleMs": { + "count": 12, + "median": 211, + "p95": 488, + "max": 563 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f93c43c3-57ef-4811-8edc-73d924918ed8", + "activeWorktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "activeTabType": "terminal", + "activeTabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f93c43c3-57ef-4811-8edc-73d924918ed8": 3 + }, + "ptyIdsByTabId": [ + "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6a3ca793-864f-4182-bec5-5b044a884b9b", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "ab3a14da-e087-4677-9fa8-df990c149951", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "afdd9731-982a-4255-8c0e-b4360453f433", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 1, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 50268.60000002384, + "maxDriftMs": 440.10000002384186, + "samples": 335, + "startedAt": 25060.600000023842, + "lastTickAt": 50268.60000002384 + }, + "ptySessions": { + "value": [ + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 71875, + "appLogsTail": [ + { + "source": "stderr", + "line": "https://electronjs.org/docs/tutorial/security.", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "This warning will not show up", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "once the app is packaged.\", source: node:electron/js2c/sandbox_bundle (2)", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=30952 rendererT=23288 durationMs=454", + "at": 1782977071792 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=31069 rendererT=23405 durationMs=533", + "at": 1782977071909 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.013:INFO:CONSOLE:1078] \"%cReact Grab v0.1.34%c", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "https://react-grab.com background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyOTQgMjk0Ij48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJNMTQ1IDQ3YzI1LTIwIDUwLTI3IDY3LTE3IDE2IDkgMjMgMzAgMjAgNjAgMCAyLTEgNS0xIDdsLTIgMTNoLTFsLTEyLTRjLTgtMy0xNy01LTI1LTZsLTE3LTNjLTEwLTEtMjAtMS0yOS0xLTEwIDAtMjAgMC0yOSAxLTYgOC0xMSAxNi0xNiAyNC01IDktOSAxNy0xMyAyNiA0IDkgOCAxOCAxMyAyNnMxMCAxNiAxNiAyNGwxMSAxNGM1IDcgMTEgMTMgMTggMTlsMTAgOHMtMSAwIDAgMGwtMTEgOWMtMTcgMTQtMzUgMjItNDkgMjItNiAwLTEzLTItMTgtNS0xNi05LTIzLTMwLTIwLTU5IDEtMyAxLTUgMS04LTMwLTEyLTQ4LTI5LTQ4LTUwIDAtMTggMTQtMzUgNDEtNDcgMi0xIDUtMiA3LTMgMC0yIDAtNS0xLTctMy0zMCA0LTUxIDIwLTYwIDE4LTEwIDQyLTMgNjggMTdNNzEgMjAxYy0xIDItMSA0LTEgNS0yIDI0IDMgNDEgMTMgNDdoMWMxMSA3IDMwIDEgNTEtMTUtMTAtOS0xOC0xOC0yNi0yOS0xMy0xLTI2LTQtMzgtOG05LTM4Yy0zIDktNSAxNy03IDI2IDggMiAxNyA0IDI1IDYtMy01LTYtMTAtOS0xNi0zLTUtNi0xMC05LTE2bS0xOS01M2MtMiAxLTMgMS01IDItMjEgMTAtMzQgMjMtMzQgMzUgMCAxMyAxNCAyNyAzOSAzNyAzLTEyIDctMjUgMTItMzctNS0xMi05LTI0LTEyLTM3bTM3LTEwYy04IDEtMTcgMy0yNSA2IDIgOCA0IDE2IDcgMjUgMy01IDYtMTEgOS0xNnptLTMtNjFjLTQtMS04IDAtMTIgMi0xMCA3LTE1IDI0LTEzIDQ3IDAgMiAwIDMgMSA1IDEyLTQgMjUtNiAzOC04IDgtMTAgMTYtMjAgMjYtMjktMTUtMTEtMjktMTctNDAtMTdtMTExIDJjLTQtMi04LTMtMTItMi0xMSAwLTI1IDUtNDAgMTcgMTAgOSAxOSAxOSAyNiAyOSAxMyAyIDI2IDQgMzkgOHYtNWMzLTIzLTItNDAtMTMtNDdtLTYxIDIzYy03IDYtMTMgMTMtMTkgMTloMzdjLTYtNi0xMi0xMy0xOC0xOSIvPjxtYXNrIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMjM1IDg1LTEzMyAyNyAyOCAxMzMgMTMzLTI3WiIvPjwvbWFzaz48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJtMTM3IDEzMCA3NiAxMWM4IDEgOSAxMSAzIDE1bC0yOCAxNi00IDMyYy0xIDgtMTAgMTAtMTQgNGwtNDEtNjZjLTMtNiAxLTEzIDgtMTIiIG1hc2s9InVybCgjYSkiLz48L3N2Zz4=\"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px; \", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1078)", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=31229 maxGapMs=223", + "at": 1782977072070 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23666 durationMs=378", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23667 durationMs=262", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31425 rendererT=23762 durationMs=95", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31426 rendererT=23762 durationMs=96", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.274:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1080)", + "at": 1782977072274 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31469 rendererT=23805 durationMs=43", + "at": 1782977072309 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31469 rendererT=23806 durationMs=1", + "at": 1782977072310 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31470 rendererT=23807 durationMs=44", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31470 rendererT=23807 durationMs=0", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31486 rendererT=23823 durationMs=16", + "at": 1782977072327 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31490 rendererT=23827 durationMs=20", + "at": 1782977072331 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31511 rendererT=23848 durationMs=25", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31511 rendererT=23848 durationMs=0", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31512 rendererT=23848 durationMs=22", + "at": 1782977072353 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31515 rendererT=23851 durationMs=3", + "at": 1782977072355 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.892:INFO:CONSOLE:397] \"Tooltip is changing from uncontrolled to controlled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977072892 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32052 rendererT=24110 durationMs=262", + "at": 1782977072893 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32054 rendererT=24149 durationMs=298", + "at": 1782977072894 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32142 rendererT=24479 durationMs=368", + "at": 1782977072983 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32218 rendererT=24554 durationMs=405", + "at": 1782977073059 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=32222 rendererT=24559 durationMs=5", + "at": 1782977073063 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=32225 rendererT=24560 durationMs=1", + "at": 1782977073066 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=32574 rendererT=24606 durationMs=46", + "at": 1782977073415 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=32587 rendererT=24924 durationMs=318", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=32588 rendererT=24924 connectionIds=0", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=32628 rendererT=24965 durationMs=41", + "at": 1782977073472 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=32675 rendererT=25011 durationMs=46", + "at": 1782977073515 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=32677 rendererT=25013 durationMs=2142", + "at": 1782977073517 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=33422 maxGapMs=401", + "at": 1782977074262 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=35433 maxGapMs=309", + "at": 1782977076274 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=37456 maxGapMs=10", + "at": 1782977078296 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ab3a14da-e087-4677-9fa8-df990c149951 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ab3a14da-e087-4677-9fa8-df990c149951:4f90bd45-a941-4d4f-b132-614d700c43c8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=0d6b052e-77c8-463f-afc9-d8f151d2cdeb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=0d6b052e-77c8-463f-afc9-d8f151d2cdeb:69c12565-c05a-493d-8de2-11ff97619d51\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=39469 maxGapMs=57", + "at": 1782977080309 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c9f77dc6-cd6f-4965-809c-c09127a40a03 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c9f77dc6-cd6f-4965-809c-c09127a40a03:f1346d6f-965f-41fb-924b-764c7e3fc305\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=41474 maxGapMs=48", + "at": 1782977082314 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cfb07cef-9bf1-4c78-996b-849186b593a4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cfb07cef-9bf1-4c78-996b-849186b593a4:de8f4a47-b5c9-4bf7-a650-1689aba5a8a8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3a994118-6004-4225-9c79-b7f00e7d72f1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3a994118-6004-4225-9c79-b7f00e7d72f1:89f847c7-b204-4b3a-b4cf-06f5c8c513bd\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=43486 maxGapMs=53", + "at": 1782977084327 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fc5cf91-915a-4244-a4da-1724f3682004 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fc5cf91-915a-4244-a4da-1724f3682004:805fa077-9c84-409e-95c0-d6974394f92a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.635:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977084635 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536:89760fac-065f-4c31-a88a-3d57536749a6\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=be158070-c785-4cea-aca9-a94e71675335 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=be158070-c785-4cea-aca9-a94e71675335:8f686ee6-1816-4b55-8368-0cf249d9ca92\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086318 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086319 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=45527 maxGapMs=50", + "at": 1782977086368 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=257d758c-e00a-45f4-9543-41bb82652d68 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=257d758c-e00a-45f4-9543-41bb82652d68:d3e9824c-9691-4f5d-8bfa-8e7099c095a3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087232 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087233 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3db20953-661a-4e65-8e4c-ca6a6f9d0538 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3db20953-661a-4e65-8e4c-ca6a6f9d0538:621eeeff-c3b9-4558-bb55-fe3470e05025\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088177 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088178 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=47556 maxGapMs=42", + "at": 1782977088397 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f4dca755-e41c-4f03-aa25-ed67526ca90e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f4dca755-e41c-4f03-aa25-ed67526ca90e:edcb7b76-70ec-4c21-b6c1-be29e795657d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.077:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8f40ca49-476c-4959-ae9f-f976293e0b62 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8f40ca49-476c-4959-ae9f-f976293e0b62:f690ab96-e8c7-416a-a8e2-26cc38e11325\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.078:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.492:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d7adfbbb-a7eb-48ed-876b-440835e7174f restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d7adfbbb-a7eb-48ed-876b-440835e7174f:dee0c9f1-8ff4-4c92-894b-178d9a23b3a0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089493 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.493:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089494 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=49559 maxGapMs=57", + "at": 1782977090400 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=51588 maxGapMs=12", + "at": 1782977092429 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=53613 maxGapMs=12", + "at": 1782977094454 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=afdd9731-982a-4255-8c0e-b4360453f433 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=afdd9731-982a-4255-8c0e-b4360453f433:e6721533-a824-4847-98d2-b2c9f073a233\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095950 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095951 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=55626 maxGapMs=26", + "at": 1782977096467 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2a067075-6acf-4aca-a0a6-bc88c1e6abc7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2a067075-6acf-4aca-a0a6-bc88c1e6abc7:6a3ca793-864f-4182-bec5-5b044a884b9b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=57642 maxGapMs=30", + "at": 1782977098483 + } + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json new file mode 100644 index 00000000000..ece15c26cfc --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-a-b", + "startedAt": "2026-07-02T07:46:58.178Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.099999964237213, + "ptyBindMs": 5504, + "firstOutputMs": 5550.799999952316, + "paintSettleMs": 5553.099999964237, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 518.2000000476837, + "firstOutputMs": 535.7000000476837, + "paintSettleMs": 544.7000000476837, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 412.5999999642372, + "firstOutputMs": 432.89999997615814, + "paintSettleMs": 434.7999999523163, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.5, + "ptyBindMs": 388.9000000357628, + "firstOutputMs": 401.10000002384186, + "paintSettleMs": 408.60000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 469, + "firstOutputMs": 482.5999999642372, + "paintSettleMs": 485.30000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.899999976158142, + "ptyBindMs": 499.60000002384186, + "firstOutputMs": 511.60000002384186, + "paintSettleMs": 519, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2, + "ptyBindMs": 441.10000002384186, + "firstOutputMs": 455.60000002384186, + "paintSettleMs": 456.39999997615814, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 312.80000001192093, + "firstOutputMs": 326.69999998807907, + "paintSettleMs": 334.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 404, 60, 184, 142, 208, 169, 148, 194, 87, 159, 211, 62, 67, 258, 95, 55, 226, 151, 53, 149, + 172, 142, 144, 56, 64 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 40.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 129.5999999642372, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 118.39999997615814, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 86.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 34.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 43.59999996423721, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7999999523162842, + "paneVisibleMs": 1.7999999523162842, + "paintSettleMs": 128.89999997615814, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 94.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.5, + "paneVisibleMs": 1.5, + "paintSettleMs": 29.100000023841858, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.199999988079071, + "paneVisibleMs": 1.199999988079071, + "paintSettleMs": 35, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 167.30000001192093, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 2.900000035762787, + "paneVisibleMs": 2.900000035762787, + "paintSettleMs": 141.9000000357628, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 57.69999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 42.90000003576279, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 49.30000001192093, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 126.40000003576279, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 134.89999997615814, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 75.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 40.69999998807907, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 40.80000001192093, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 131.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 146.9000000357628, + "timedOut": false + } + ], + "longtasks": [86, 123, 111, 73, 122, 90, 84, 108, 127, 52, 121, 129, 64, 124, 134] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 240, + "ptyBindMs": 247, + "paintSettleMs": 254 + }, + { + "index": 1, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 423, + "ptyBindMs": 573, + "paintSettleMs": 584 + }, + { + "index": 2, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 355, + "ptyBindMs": 441, + "paintSettleMs": 464 + }, + { + "index": 3, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 152, + "ptyBindMs": 177, + "paintSettleMs": 182 + }, + { + "index": 4, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 294, + "ptyBindMs": 330, + "paintSettleMs": 341 + }, + { + "index": 5, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 167, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 6, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 141, + "ptyBindMs": 158, + "paintSettleMs": 164 + }, + { + "index": 7, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 115, + "ptyBindMs": 139, + "paintSettleMs": 145 + }, + { + "index": 8, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 258, + "ptyBindMs": 284, + "paintSettleMs": 294 + }, + { + "index": 10, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 153, + "ptyBindMs": 176, + "paintSettleMs": 185 + }, + { + "index": 11, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 151, + "paintSettleMs": 158 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 469, + "p95": 5504, + "max": 5504 + }, + "firstOutputMs": { + "count": 8, + "median": 483, + "p95": 5551, + "max": 5551 + }, + "paintSettleMs": { + "count": 8, + "median": 485, + "p95": 5553, + "max": 5553 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 404 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 94, + "p95": 147, + "max": 167 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 134 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 167, + "p95": 355, + "max": 423 + }, + "ptyBindMs": { + "count": 12, + "median": 192, + "p95": 441, + "max": 573 + }, + "paintSettleMs": { + "count": 12, + "median": 200, + "p95": 464, + "max": 584 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "62e7f2fd-6fb6-446e-bded-2866c26a968e", + "activeWorktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "activeTabType": "terminal", + "activeTabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "62e7f2fd-6fb6-446e-bded-2866c26a968e": 3 + }, + "ptyIdsByTabId": [ + "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "74365402-0e1f-4d28-b14b-212e48cb4584", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "212a99f7-8de7-4b20-9a0b-c8caba7656e4", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 36550, + "maxDriftMs": 436.4000000357628, + "samples": 329, + "startedAt": 12199.699999988079, + "lastTickAt": 36550 + }, + "ptySessions": { + "value": [ + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50260, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13843 rendererT=11282 durationMs=40", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13844 rendererT=11282 durationMs=0", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13858 rendererT=11297 durationMs=16", + "at": 1782978443163 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13860 rendererT=11299 durationMs=17", + "at": 1782978443165 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13873 rendererT=11312 durationMs=15", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13873 rendererT=11312 durationMs=0", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13875 rendererT=11314 durationMs=15", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13875 rendererT=11314 durationMs=0", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13891 rendererT=11330 durationMs=18", + "at": 1782978443196 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13893 rendererT=11332 durationMs=17", + "at": 1782978443198 + }, + { + "source": "stderr", + "line": "[55788:0702/034723.201:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=43681c89 (1080)", + "at": 1782978443201 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14296 rendererT=11390 durationMs=59", + "at": 1782978443601 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14297 rendererT=11429 durationMs=97", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14297 rendererT=11434 durationMs=5", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14297 rendererT=11436 durationMs=1", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14305 rendererT=11744 durationMs=308", + "at": 1782978443610 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14306 rendererT=11745 durationMs=1", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14306 rendererT=11745 connectionIds=0", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14338 rendererT=11777 durationMs=32", + "at": 1782978443643 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14736 maxGapMs=360", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14736 rendererT=11817 durationMs=41", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14737 rendererT=11818 durationMs=1577", + "at": 1782978444042 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16763 maxGapMs=352", + "at": 1782978446068 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18790 maxGapMs=8", + "at": 1782978448094 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8:36d54ebb-a041-4133-b5da-c7d36f570603\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e:375f8b17-83a7-4b05-9794-8c7e6daaad79\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20813 maxGapMs=95", + "at": 1782978450118 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "at": 1782978450823 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "at": 1782978450875 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2b742216-def2-45d8-a554-afa8b63a7efc restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2b742216-def2-45d8-a554-afa8b63a7efc:6328b254-57e8-479e-bf53-220dd8fbefed\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978451864 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22815 maxGapMs=57", + "at": 1782978452120 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a669eb6f-24a3-4e93-9b9d-230b1734818a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a669eb6f-24a3-4e93-9b9d-230b1734818a:97eecd6f-f5df-41f6-8d16-0052bd0dd085\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978452806 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.549:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a7f4f0da-28f3-4938-b412-0b5c7d389a1a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a7f4f0da-28f3-4938-b412-0b5c7d389a1a:0e97486f-93f7-4de1-a921-d7883bf6742d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.550:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782978453647 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24819 maxGapMs=45", + "at": 1782978454124 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=33f0654c-45be-4686-9f91-934dbd838341 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=33f0654c-45be-4686-9f91-934dbd838341:aa40fd80-39ca-4647-833f-081ff4315faf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454418 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454419 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.488:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978454488 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782978454518 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=acf961b2-58e1-4700-8246-8fbd13aec1e9 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=acf961b2-58e1-4700-8246-8fbd13aec1e9:d19e1bbf-4575-4b3c-b09e-b674244b0126\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978455389 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26824 maxGapMs=40", + "at": 1782978456129 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6c82b2e6-a836-47cc-9450-d81aab89a3ca restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6c82b2e6-a836-47cc-9450-d81aab89a3ca:fa45ccbe-b10e-4260-850c-350edc40a8f5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "at": 1782978456228 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cc574a5e-9ee8-416a-acb3-9042ca35caf1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cc574a5e-9ee8-416a-acb3-9042ca35caf1:5e19c1dc-6360-4032-99f6-cb0345e5d7e5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456986 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.987:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456987 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782978457082 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8d57a052-276e-4882-ade4-dcc5278b822c restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8d57a052-276e-4882-ade4-dcc5278b822c:e39bef7b-f078-40f5-b29e-83468097b694\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978457962 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28852 maxGapMs=58", + "at": 1782978458157 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d0231601-75d6-4b5e-b6f9-edae135f3beb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d0231601-75d6-4b5e-b6f9-edae135f3beb:0ce6efc9-3d20-49df-a9cc-8ee9dec16cc3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978458358 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=37b9b3da-9361-47ea-b14e-eb9bc33fd17a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=37b9b3da-9361-47ea-b14e-eb9bc33fd17a:ea9a8021-01d2-440f-a587-809680c60baf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458803 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458804 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "at": 1782978458921 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5:d46a8d0d-69a8-4f14-8186-6f094f152d36\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "at": 1782978459316 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30878 maxGapMs=43", + "at": 1782978460183 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32902 maxGapMs=12", + "at": 1782978462207 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34905 maxGapMs=14", + "at": 1782978464210 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=212a99f7-8de7-4b20-9a0b-c8caba7656e4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=212a99f7-8de7-4b20-9a0b-c8caba7656e4:dcbaade8-8868-4626-b685-37a662d40aa0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782978466025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36929 maxGapMs=46", + "at": 1782978466234 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e:74365402-0e1f-4d28-b14b-212e48cb4584\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466425 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466426 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782978466535 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38934 maxGapMs=40", + "at": 1782978468239 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json new file mode 100644 index 00000000000..ae913a7ecac --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json @@ -0,0 +1,1061 @@ +{ + "label": "fix-abd", + "startedAt": "2026-07-02T07:51:00.936Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 4.199999988079071, + "ptyBindMs": 6850.600000023842, + "firstOutputMs": 6893.800000011921, + "paintSettleMs": 6897.199999988079, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.299999952316284, + "ptyBindMs": 553.2999999523163, + "firstOutputMs": 576.5999999642372, + "paintSettleMs": 578.0999999642372, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.699999988079071, + "ptyBindMs": 565.6000000238419, + "firstOutputMs": 584.5, + "paintSettleMs": 585.4000000357628, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 2.899999976158142, + "ptyBindMs": 542.8999999761581, + "firstOutputMs": 571.1999999880791, + "paintSettleMs": 573.8000000119209, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 423, + "firstOutputMs": 457.19999998807907, + "paintSettleMs": 464.80000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 478.5999999642372, + "firstOutputMs": 497, + "paintSettleMs": 498.19999998807907, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2.099999964237213, + "ptyBindMs": 531, + "firstOutputMs": 548.3999999761581, + "paintSettleMs": 549.3999999761581, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 3.199999988079071, + "ptyBindMs": 612, + "firstOutputMs": 624.0999999642372, + "paintSettleMs": 629.0999999642372, + "timedOut": false + } + ], + "longtasks": [ + 478, 78, 204, 149, 218, 203, 145, 218, 202, 165, 223, 189, 163, 210, 86, 162, 280, 68, 110, + 323, 66, 76, 345, 130, 114 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5999999642372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 155.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.699999988079071, + "paneVisibleMs": 2.699999988079071, + "paintSettleMs": 99.39999997615814, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 66.30000001192093, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 87.89999997615814, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.299999952316284, + "paneVisibleMs": 2.299999952316284, + "paintSettleMs": 150, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 70.19999998807907, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 60.19999998807907, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 155.0999999642372, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 159.5, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 68.70000004768372, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 66.39999997615814, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 54.90000003576279, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 149.19999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.400000035762787, + "paintSettleMs": 151.9000000357628, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 90.59999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 57.30000001192093, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 59.30000001192093, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 143.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 117.80000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 94, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 56.299999952316284, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 134.10000002384186, + "timedOut": false + } + ], + "longtasks": [ + 145, 146, 89, 54, 80, 151, 138, 62, 53, 143, 151, 60, 59, 53, 142, 141, 83, 50, 129, 112, + 83, 126 + ] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 281, + "ptyBindMs": 283, + "paintSettleMs": 293 + }, + { + "index": 1, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 344, + "ptyBindMs": 524, + "paintSettleMs": 538 + }, + { + "index": 2, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 428, + "ptyBindMs": 545, + "paintSettleMs": 559 + }, + { + "index": 3, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 147, + "ptyBindMs": 167, + "paintSettleMs": 180 + }, + { + "index": 4, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 320, + "ptyBindMs": 348, + "paintSettleMs": 356 + }, + { + "index": 5, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 125, + "ptyBindMs": 146, + "paintSettleMs": 155 + }, + { + "index": 6, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 135, + "ptyBindMs": 168, + "paintSettleMs": 175 + }, + { + "index": 7, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 129, + "ptyBindMs": 155, + "paintSettleMs": 166 + }, + { + "index": 8, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 121, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 298, + "ptyBindMs": 318, + "paintSettleMs": 328 + }, + { + "index": 10, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 113, + "ptyBindMs": 131, + "paintSettleMs": 139 + }, + { + "index": 11, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 117, + "ptyBindMs": 140, + "paintSettleMs": 150 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 3, + "p95": 4, + "max": 4 + }, + "ptyBindMs": { + "count": 8, + "median": 553, + "p95": 6851, + "max": 6851 + }, + "firstOutputMs": { + "count": 8, + "median": 577, + "p95": 6894, + "max": 6894 + }, + "paintSettleMs": { + "count": 8, + "median": 578, + "p95": 6897, + "max": 6897 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 478 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 99, + "p95": 159, + "max": 160 + } + }, + "longtaskCount": 22, + "longtaskMaxMs": 151 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 147, + "p95": 344, + "max": 428 + }, + "ptyBindMs": { + "count": 12, + "median": 168, + "p95": 524, + "max": 545 + }, + "paintSettleMs": { + "count": 12, + "median": 180, + "p95": 538, + "max": 559 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d", + "activeWorktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "activeTabType": "terminal", + "activeTabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f5b2567a-69b4-41a3-ba29-8c4bd357042d": 3 + }, + "ptyIdsByTabId": [ + "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6aaaf90a-6cb7-48f9-b7c9-6869278038a7", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "93e4a957-3325-4987-a180-5dac10bebbc0", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 39444.30000001192, + "maxDriftMs": 504, + "samples": 351, + "startedAt": 11431.800000011921, + "lastTickAt": 39444.30000001192 + }, + "ptySessions": { + "value": [ + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 53646, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13271 rendererT=10691 durationMs=32", + "at": 1782978685796 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13272 rendererT=10691 durationMs=0", + "at": 1782978685797 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13288 rendererT=10708 durationMs=17", + "at": 1782978685813 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13290 rendererT=10709 durationMs=19", + "at": 1782978685815 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13303 rendererT=10723 durationMs=14", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13303 rendererT=10723 durationMs=0", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13305 rendererT=10725 durationMs=15", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13305 rendererT=10725 durationMs=0", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13313 rendererT=10733 durationMs=10", + "at": 1782978685838 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13314 rendererT=10734 durationMs=8", + "at": 1782978685839 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13828 rendererT=10799 durationMs=66", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13829 rendererT=10833 durationMs=99", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=13829 rendererT=10837 durationMs=4", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=13829 rendererT=10838 durationMs=1", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=13840 rendererT=11257 durationMs=418", + "at": 1782978686365 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=13842 rendererT=11260 durationMs=4", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=13842 rendererT=11261 connectionIds=0", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=13880 rendererT=11295 durationMs=34", + "at": 1782978686405 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=13914 rendererT=11334 durationMs=39", + "at": 1782978686439 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=13916 rendererT=11335 durationMs=1709", + "at": 1782978686441 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14429 maxGapMs=446", + "at": 1782978686954 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16441 maxGapMs=409", + "at": 1782978688966 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18453 maxGapMs=7", + "at": 1782978690978 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=93e4a957-3325-4987-a180-5dac10bebbc0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=93e4a957-3325-4987-a180-5dac10bebbc0:d285da80-e34b-422f-a0a4-14a68a6c5cb5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=766de809-5863-49f7-a603-3faa5fdf4693 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=766de809-5863-49f7-a603-3faa5fdf4693:3747de82-0192-46c3-a130-e114e78bb00d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20469 maxGapMs=30", + "at": 1782978692994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22492 maxGapMs=7", + "at": 1782978695018 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "at": 1782978695134 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "at": 1782978695192 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9efaa7fd-442b-4efe-bf23-52d7143a0219 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9efaa7fd-442b-4efe-bf23-52d7143a0219:df4d3047-f992-48ce-b5f1-6d0637523a9e\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978696250 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24506 maxGapMs=36", + "at": 1782978697031 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.119:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=694f725c-29d1-4107-8648-81d285f6ab4b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=694f725c-29d1-4107-8648-81d285f6ab4b:90413477-6d54-4dda-8c37-59e718741517\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.120:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.171:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978697172 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978697234 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=adc6529c-4b75-45a8-8ea2-1bcf637da544 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=adc6529c-4b75-45a8-8ea2-1bcf637da544:91dc16c6-f567-4e05-994c-94ae19376ee7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978698247 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26510 maxGapMs=51", + "at": 1782978699035 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f891d153-855c-43cb-9fdb-fd8682070724 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f891d153-855c-43cb-9fdb-fd8682070724:5d0971c2-c663-4e44-ae1c-825dc948489a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978699211 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.045:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=939ae529-7ad7-4d46-b4c0-4b3997ab6b98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=939ae529-7ad7-4d46-b4c0-4b3997ab6b98:b3224a5c-19ae-42b8-b756-965f04806e49\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.046:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978700161 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5749668e-dbd3-41ff-bbe5-c93c12925763 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5749668e-dbd3-41ff-bbe5-c93c12925763:e3a00c11-d53b-4747-b215-ce9f19bb0da0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28532 maxGapMs=50", + "at": 1782978701057 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "at": 1782978701126 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2546b0b6-5198-4067-b59b-698e914160d0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2546b0b6-5198-4067-b59b-698e914160d0:fecf7693-17a1-4cdc-8d35-1ba415e0760d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978702102 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=87a752ed-2a66-411e-8c00-b7b0b24b5d10 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=87a752ed-2a66-411e-8c00-b7b0b24b5d10:9258e63f-4dee-455c-8f8f-543569e4ce24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703024 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30557 maxGapMs=47", + "at": 1782978703082 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978703141 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=03cc8628-e72d-478c-90f7-3ed264097afb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=03cc8628-e72d-478c-90f7-3ed264097afb:b30207cc-752f-4836-8ea5-6f095e97f32d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978703630 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.065:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=993f77d3-4e18-4b17-b386-adead86c80c8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=993f77d3-4e18-4b17-b386-adead86c80c8:ee549cc0-14c4-4211-922b-918f0810da85\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.066:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "at": 1782978704188 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2e38aeb9-4c66-440e-adbf-de538b4ddcbb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2e38aeb9-4c66-440e-adbf-de538b4ddcbb:26a39262-f10c-4e3a-a246-8646617d0b05\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "at": 1782978704985 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32571 maxGapMs=116", + "at": 1782978705096 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34594 maxGapMs=18", + "at": 1782978707119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36594 maxGapMs=13", + "at": 1782978709119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38594 maxGapMs=11", + "at": 1782978711119 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7:9002711a-d8da-4d46-a938-d1ab106322eb\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "at": 1782978712070 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083:6aaaf90a-6cb7-48f9-b7c9-6869278038a7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978712689 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=40618 maxGapMs=40", + "at": 1782978713143 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json new file mode 100644 index 00000000000..c4e2c07b18f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-abdf", + "startedAt": "2026-07-02T07:59:11.820Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.299999952316284, + "ptyBindMs": 4816.899999976158, + "firstOutputMs": 4856.899999976158, + "paintSettleMs": 4864, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 529, + "firstOutputMs": 554.5, + "paintSettleMs": 556.8000000119209, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.400000035762787, + "ptyBindMs": 423.10000002384186, + "firstOutputMs": 464.80000001192093, + "paintSettleMs": 470, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.800000011920929, + "ptyBindMs": 438.4000000357628, + "firstOutputMs": 453.69999998807907, + "paintSettleMs": 455.80000001192093, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 497.10000002384186, + "firstOutputMs": 508.80000001192093, + "paintSettleMs": 515.8999999761581, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 471.80000001192093, + "firstOutputMs": 488.10000002384186, + "paintSettleMs": 491.30000001192093, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.199999988079071, + "ptyBindMs": 512.6999999880791, + "firstOutputMs": 524.2999999523163, + "paintSettleMs": 531.6999999880791, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.5, + "ptyBindMs": 468.10000002384186, + "firstOutputMs": 483.19999998807907, + "paintSettleMs": 484.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 52, 397, 67, 187, 133, 214, 192, 152, 196, 90, 154, 255, 61, 71, 296, 76, 68, 258, 92, 68, + 244, 161, 63, 176, 178, 137 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 76.20000004768372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 113.19999998807907, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 130.9000000357628, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 70.09999996423721, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 54.19999998807907, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 52.69999998807907, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 119.30000001192093, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 104.29999995231628, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.5, + "paintSettleMs": 78.39999997615814, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 49.59999996423721, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 47.10000002384186, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 3.100000023841858, + "paneVisibleMs": 3.100000023841858, + "paintSettleMs": 120.30000001192093, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 108.19999998807907, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 51.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 45.30000001192093, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 44.69999998807907, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 58.69999998807907, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 136.5999999642372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 136.0999999642372, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 53.80000001192093, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 51.5, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 122.40000003576279, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 118.5, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 62.69999998807907, + "timedOut": false + } + ], + "longtasks": [66, 102, 123, 64, 115, 96, 74, 113, 102, 52, 125, 130, 117, 110, 53] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 217, + "ptyBindMs": 219, + "paintSettleMs": 233 + }, + { + "index": 1, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 480, + "ptyBindMs": 575, + "paintSettleMs": 601 + }, + { + "index": 2, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 382, + "ptyBindMs": 557, + "paintSettleMs": 573 + }, + { + "index": 3, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 268, + "ptyBindMs": 295, + "paintSettleMs": 299 + }, + { + "index": 4, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 154, + "ptyBindMs": 178, + "paintSettleMs": 184 + }, + { + "index": 5, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 145, + "ptyBindMs": 166, + "paintSettleMs": 178 + }, + { + "index": 6, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 159, + "ptyBindMs": 184, + "paintSettleMs": 192 + }, + { + "index": 7, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 328, + "ptyBindMs": 349, + "paintSettleMs": 354 + }, + { + "index": 8, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 139, + "ptyBindMs": 158, + "paintSettleMs": 167 + }, + { + "index": 9, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 150, + "ptyBindMs": 177, + "paintSettleMs": 183 + }, + { + "index": 10, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 150, + "ptyBindMs": 169, + "paintSettleMs": 179 + }, + { + "index": 11, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 134, + "ptyBindMs": 166, + "paintSettleMs": 182 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 497, + "p95": 4817, + "max": 4817 + }, + "firstOutputMs": { + "count": 8, + "median": 509, + "p95": 4857, + "max": 4857 + }, + "paintSettleMs": { + "count": 8, + "median": 516, + "p95": 4864, + "max": 4864 + } + }, + "longtaskCount": 26, + "longtaskMaxMs": 397 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 76, + "p95": 136, + "max": 137 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 130 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 159, + "p95": 382, + "max": 480 + }, + "ptyBindMs": { + "count": 12, + "median": 184, + "p95": 557, + "max": 575 + }, + "paintSettleMs": { + "count": 12, + "median": 192, + "p95": 573, + "max": 601 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b", + "activeWorktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "activeTabType": "terminal", + "activeTabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b": 3 + }, + "ptyIdsByTabId": [ + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "78b7478c-39a9-4111-aed8-1e2ddf568c24", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "c46e04e4-af5f-4674-9a38-72253480baef", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "769013c6-b5b8-4be9-b188-4444a6094567", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 35644.5, + "maxDriftMs": 413.89999997615814, + "samples": 309, + "startedAt": 11829.5, + "lastTickAt": 35644.5 + }, + "ptySessions": { + "value": [ + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50234, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13833 rendererT=11239 durationMs=53", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13833 rendererT=11240 durationMs=1", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13835 rendererT=11241 durationMs=55", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13835 rendererT=11242 durationMs=0", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13838 rendererT=11246 durationMs=6", + "at": 1782979177599 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13841 rendererT=11249 durationMs=8", + "at": 1782979177602 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13842 rendererT=11250 durationMs=4", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13842 rendererT=11250 durationMs=0", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13844 rendererT=11251 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13844 rendererT=11251 durationMs=0", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13844 rendererT=11252 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13846 rendererT=11254 durationMs=3", + "at": 1782979177607 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11346 durationMs=94", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11426 durationMs=172", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14263 rendererT=11430 durationMs=4", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14263 rendererT=11431 durationMs=1", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14277 rendererT=11685 durationMs=255", + "at": 1782979178038 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14282 rendererT=11691 durationMs=5", + "at": 1782979178043 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14283 rendererT=11691 connectionIds=0", + "at": 1782979178044 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14324 rendererT=11732 durationMs=41", + "at": 1782979178085 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14366 rendererT=11774 durationMs=42", + "at": 1782979178127 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14367 rendererT=11775 durationMs=1992", + "at": 1782979178128 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14580 maxGapMs=453", + "at": 1782979178342 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16597 maxGapMs=336", + "at": 1782979180358 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18612 maxGapMs=15", + "at": 1782979182373 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c46e04e4-af5f-4674-9a38-72253480baef restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c46e04e4-af5f-4674-9a38-72253480baef:8089e7c5-e739-4785-b3b4-6844f99c0a86\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e62346f1-a808-4397-8be7-422efa0aaebb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e62346f1-a808-4397-8be7-422efa0aaebb:e9b9d9ce-241c-458b-be93-01a657de7fd7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "at": 1782979184130 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "at": 1782979184185 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20628 maxGapMs=48", + "at": 1782979184389 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=894d9793-e2ae-40bf-864e-eef357f271c1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=894d9793-e2ae-40bf-864e-eef357f271c1:1b683a77-2230-4238-96eb-5118ea595604\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979185197 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fd459b0-a0a8-416d-b698-521da990ace3 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fd459b0-a0a8-416d-b698-521da990ace3:74040bad-c0d6-42c6-af48-21150176be1c\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "at": 1782979186159 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22635 maxGapMs=34", + "at": 1782979186396 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6559b19f-a2e8-4691-8057-2efd9ba67d0e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6559b19f-a2e8-4691-8057-2efd9ba67d0e:8b41e717-29fe-49fb-a710-c96e41a1d112\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782979187082 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.872:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=04620d6d-4629-40b3-8150-c7eec447d262 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=04620d6d-4629-40b3-8150-c7eec447d262:5992fb07-187a-43c3-9434-d424f1312155\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187872 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.873:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187873 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782979187978 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24659 maxGapMs=46", + "at": 1782979188420 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.434:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782979188434 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.761:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=bc4db6bc-b408-472a-9c15-77a428f98d99 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=bc4db6bc-b408-472a-9c15-77a428f98d99:11ff82bf-57d3-4e85-9293-77c0935f7d08\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.762:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "at": 1782979188862 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6:d186da0c-c1c4-45ca-90f1-e1f3b8fa4505\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782979189734 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26688 maxGapMs=39", + "at": 1782979190449 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6883a556-63dd-4c0a-87fa-9682db6f4cf0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6883a556-63dd-4c0a-87fa-9682db6f4cf0:e0e844db-da79-459b-b791-52ea58bab4da\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190509 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782979190601 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e:c2c02ac2-e421-4c29-a8b6-4c97c11bf1dc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782979191511 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=66083463-228e-4fb0-90c5-231dc75e8da6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=66083463-228e-4fb0-90c5-231dc75e8da6:e4fc0e83-57a8-44f0-9732-936e2914292b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979191926 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.310:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=b99bb441-7027-40a9-beb2-a558ece6f979 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=b99bb441-7027-40a9-beb2-a558ece6f979:f5f1c1e2-72ac-4152-958b-b0b80e1213cc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.311:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979192422 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28705 maxGapMs=52", + "at": 1782979192466 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=490084ae-1bdb-4359-bb86-097e26902f98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=490084ae-1bdb-4359-bb86-097e26902f98:c3647cbf-2f6e-4b4b-89c1-8bea8b949d66\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "at": 1782979192875 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30719 maxGapMs=43", + "at": 1782979194480 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32744 maxGapMs=12", + "at": 1782979196505 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34767 maxGapMs=14", + "at": 1782979198528 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=769013c6-b5b8-4be9-b188-4444a6094567 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=769013c6-b5b8-4be9-b188-4444a6094567:2a72038c-911c-4095-9fe8-0cd8c7f4978b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782979199463 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.852:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=15055907-3232-4ca1-8814-e5484ba78a5b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=15055907-3232-4ca1-8814-e5484ba78a5b:78b7478c-39a9-4111-aed8-1e2ddf568c24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199852 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.853:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199853 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979199957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36789 maxGapMs=49", + "at": 1782979200550 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/terminal-perf-bench.mjs b/tools/benchmarks/terminal-perf-bench.mjs new file mode 100644 index 00000000000..f5c8646b696 --- /dev/null +++ b/tools/benchmarks/terminal-perf-bench.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node +// Terminal interaction latency benchmark: tab creation, tab switching, and +// workspace switching against the real Electron dev app over CDP. Produces +// median/p95 phase timings so Windows terminal slowness can be attributed to +// store work, PTY spawn, shell startup, or renderer paint — not guessed at. + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +import { + pollUntil, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs +} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const scenarioTimeoutMs = 300_000 +const defaultIterations = 8 +const defaultSwitches = 24 +const defaultCycles = 12 + +function parseArgs() { + const args = { + label: 'run', + iterations: defaultIterations, + switches: defaultSwitches, + cycles: defaultCycles, + shell: null, + scenarios: ['tab-create', 'tab-switch', 'workspace-switch'], + reportPath: null, + keep: false + } + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--label') { + args.label = value?.trim() || 'run' + continue + } + if (name === '--iterations') { + args.iterations = parsePositiveInt(name, value) + continue + } + if (name === '--switches') { + args.switches = parsePositiveInt(name, value) + continue + } + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--shell') { + args.shell = value?.trim() || null + continue + } + if (name === '--scenarios') { + const scenarios = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const known = new Set(['tab-create', 'tab-switch', 'workspace-switch']) + if (scenarios.length === 0 || scenarios.some((scenario) => !known.has(scenario))) { + throw new Error( + `Unsupported --scenarios=${value}. Use tab-create,tab-switch,workspace-switch.` + ) + } + args.scenarios = scenarios + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + return args +} + +function printHelp() { + console.log(`Usage: + node tools/benchmarks/terminal-perf-bench.mjs [options] + +Options: + --label=NAME Label recorded in the report filename/JSON. Default: run. + --iterations=N Terminal tab create/close iterations. Default: ${defaultIterations}. + --switches=N Tab switch alternations. Default: ${defaultSwitches}. + --cycles=N Workspace switch cycles. Default: ${defaultCycles}. + --shell=PATH Shell override for created tabs (e.g. cmd.exe). Default: app default. + --scenarios=a,b Subset of tab-create,tab-switch,workspace-switch. + --report=PATH Report JSON path. Default: tools/benchmarks/results/terminal-perf-
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {fableLeft !== null && ( -
-
-
-
- - {fableLeft} - {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx new file mode 100644 index 00000000000..0160978beb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +function claudeLimits(): ProviderRateLimits { + return { + provider: 'claude', + session: { + usedPercent: 32, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 16, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('InlineUsageBars', () => { + it('renders Claude Fable usage in inactive account preview rows', async () => { + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +}) From 0fd2d67c699a6a221196110e184c4da3ec81864d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:23:18 -0700 Subject: [PATCH 079/233] Allow manual sorting of Project Groups (#7191) --- .../src/components/sidebar/WorktreeList.tsx | 104 +++++- .../sidebar/project-group-header-dom.test.ts | 25 ++ .../project-group-header-drag-commit.test.ts | 111 +++++++ .../project-group-header-drag-commit.ts | 50 +++ .../project-group-header-drag-contract.ts | 77 +++++ .../project-group-header-drag-start.ts | 64 ++++ .../sidebar/project-group-header-drag.ts | 304 ++++++++++++++++++ .../sidebar/project-group-header-drop.test.ts | 169 ++++++++++ .../sidebar/project-group-header-drop.ts | 210 ++++++++++++ .../sidebar/worktree-list-groups.test.ts | 61 ++++ 10 files changed, 1172 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/project-group-header-dom.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-contract.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-start.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2296df9064b..2e405dcfb2d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -135,6 +135,8 @@ import { } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { useProjectGroupHeaderDrag } from './project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, @@ -1377,7 +1379,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -1399,6 +1403,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [projectGroups] ) + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) useEffect( () => @@ -1567,6 +1575,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [rows] ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) const repoHeaderIndexByRepoId = useMemo(() => { const map = new Map() for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { @@ -1585,12 +1601,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } return map }, [sidebarRepoHeaderIdsByBucket]) + const projectGroupHeaderIndexByGroupId = useMemo(() => { + const map = new Map() + for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { + groupIds.forEach((groupId, index) => { + map.set(groupId, index) + }) + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) + const projectGroupHeaderBucketByGroupId = useMemo(() => { + const map = new Map() + for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { + for (const groupId of groupIds) { + map.set(groupId, bucketKey) + } + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) const commitProjectGroupOrder = useCallback( (repoId: string, projectGroupId: string | null, order: number) => { void moveProjectToGroup(repoId, projectGroupId, order) }, [moveProjectToGroup] ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + const suppressUntil = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + void updateProjectGroup(groupId, { tabOrder }) + }, + [updateProjectGroup] + ) // Drag is only meaningful when repo headers are using manual order. The // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ @@ -1602,6 +1649,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCommitProjectGroupOrder: commitProjectGroupOrder, getScrollContainer: () => scrollRef.current }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ worktreeId: string rowKey: string @@ -3909,6 +3962,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp style={{ top: `${repoDrag.state.dropIndicatorY}px` }} /> ) : null} + {canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.dropIndicatorY !== null ? ( +
+ ) : null} {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? (
1 ) + const isDraggableProjectGroupHeader = Boolean( + canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader const headerWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) @@ -4100,6 +4185,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-repo-header-id={projectIdForHeader} data-repo-header-index={repoHeaderIndex} data-repo-header-bucket={repoHeaderBucketKey} + data-project-group-header-id={projectGroupIdForHeader} + data-project-group-header-index={projectGroupHeaderIndex} + data-project-group-header-bucket={projectGroupHeaderBucketKey} data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined} data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} @@ -4108,7 +4196,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp 'cursor-pointer', highlightedRevealRowKey === row.key && 'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50', - isDraggingThis && + (isDraggingThis || isDraggingThisProjectGroup) && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', headerWorkspaceStatus && dragOverStatus === headerWorkspaceStatus && @@ -4157,15 +4245,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {row.icon ? (
repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + projectGroupDrag.onHandlePointerDown( + event, + projectGroupIdForHeader + ) + : undefined } > {row.repo ? ( diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts new file mode 100644 index 00000000000..266c85c4893 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +function readWorktreeListSource(): string { + return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') +} + +describe('Project Group header drag DOM source', () => { + it('renders concrete Project Group header drag attributes separately from repo headers', () => { + const source = readWorktreeListSource() + + expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') + expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') + expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') + expect(source).toContain('data-project-group-header-drag-handle=') + }) + + it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { + const source = readWorktreeListSource() + + expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') + expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts new file mode 100644 index 00000000000..ef873d9bf02 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSession( + groupId: string, + sidebarProjectGroupHeaderIds: readonly string[] +): ProjectGroupHeaderDragSession { + return { + groupId, + bucketKey: 'root', + sidebarProjectGroupHeaderIds, + pointerId: 1, + headerRects: [], + handleEl: document.createElement('div'), + startX: 0, + startY: 0, + latestPointerY: 0, + promoted: true + } +} + +describe('commitProjectGroupHeaderDragDrop', () => { + it('commits a finite tabOrder for the dragged Project Group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [ + group('a', { tabOrder: 0 }), + group('b', { tabOrder: 10 }), + group('c', { tabOrder: 20 }) + ] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('c', -1) + }) + + it('computes order only from the captured sibling bucket', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const root = group('root') + const siblingA = group('sibling-a', { parentGroupId: root.id, tabOrder: 0 }) + const siblingB = group('sibling-b', { parentGroupId: root.id, tabOrder: 10 }) + const otherParentGroup = group('other-parent-group', { tabOrder: -100 }) + const projectGroupById = new Map( + [root, siblingA, siblingB, otherParentGroup].map((entry) => [entry.id, entry]) + ) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('sibling-b', ['sibling-a', 'sibling-b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('sibling-b', -1) + }) + + it('does not commit when the drop keeps the group in the same slot', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('b', ['a', 'b', 'c']), + sidebarDropIndex: 2, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) + + it('does not commit when a stale session no longer contains the dragged group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts new file mode 100644 index 00000000000..3ef85959577 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -0,0 +1,50 @@ +import { + getProjectGroupTabOrderForSidebarDrop, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function commitProjectGroupHeaderDragDrop(args: { + session: ProjectGroupHeaderDragSession + sidebarDropIndex: number + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void +}): void { + const draggedGroup = args.projectGroupById.get(args.session.groupId) + if (!draggedGroup) { + return + } + + const sidebarProjectGroupHeaderIds = args.session.sidebarProjectGroupHeaderIds + const sourceIndex = sidebarProjectGroupHeaderIds.indexOf(args.session.groupId) + if (sourceIndex === -1) { + return + } + if (args.sidebarDropIndex === sourceIndex) { + return + } + + const siblings = sidebarProjectGroupHeaderIds + .filter((groupId) => groupId !== args.session.groupId) + .map((groupId) => args.projectGroupById.get(groupId)) + .filter((group): group is ProjectGroup => group !== undefined) + const siblingDropIndex = mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblings.length + }) + const sourceIndexInSiblings = Math.min(sourceIndex, siblings.length) + if (siblingDropIndex === sourceIndexInSiblings) { + return + } + + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings, + dropIndex: siblingDropIndex + }) + if (!Number.isFinite(tabOrder)) { + return + } + args.onCommitProjectGroupTabOrder(draggedGroup.id, tabOrder) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts new file mode 100644 index 00000000000..7127ce6bd8c --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -0,0 +1,77 @@ +import type { PointerEvent } from 'react' + +import type { + ProjectGroupHeaderDragBucketKey, + ProjectGroupHeaderDragRect +} from './project-group-header-drop' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupDragState = { + draggingGroupId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_PROJECT_GROUP_DRAG_STATE: ProjectGroupDragState = { + draggingGroupId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseProjectGroupHeaderDragArgs = { + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type ProjectGroupHeaderDragController = { + state: ProjectGroupDragState + onHandlePointerDown: (event: PointerEvent, groupId: string) => void +} + +export type ProjectGroupHeaderDragSession = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + sidebarProjectGroupHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectGroupHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX = 4 + +const PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR = '[data-project-group-header-drag-handle]' + +const PROJECT_GROUP_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isProjectGroupHeaderDragHandleTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const dragHandle = target.closest(PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR) + return dragHandle !== null && currentTarget.contains(dragHandle) +} + +export function isProjectGroupHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return ( + currentTarget.contains(target) && target.closest(PROJECT_GROUP_HEADER_ACTION_SELECTOR) !== null + ) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts new file mode 100644 index 00000000000..89eb5bd9dd0 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' + +import { + getProjectGroupHeaderDragBucketKey, + measureProjectGroupHeaderDragRects, + type ProjectGroupHeaderDragBucketKey +} from './project-group-header-drop' +import { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget, + type ProjectGroupHeaderDragSession +} from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function createProjectGroupHeaderDragSession(args: { + event: PointerEvent + groupId: string + projectGroupById: ReadonlyMap + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + getScrollContainer: () => HTMLElement | null +}): ProjectGroupHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (!isProjectGroupHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) { + return null + } + if (isProjectGroupHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const group = args.projectGroupById.get(args.groupId) + if (!group) { + return null + } + const bucketKey = getProjectGroupHeaderDragBucketKey(group, args.projectGroupById) + const sidebarProjectGroupHeaderIds = + args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a lone group in a parent bucket cannot move without reparenting. + if (sidebarProjectGroupHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + // Why: defer pointer capture until the threshold so ordinary group header + // clicks still toggle collapse through the row handler. + return { + groupId: args.groupId, + bucketKey, + sidebarProjectGroupHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectGroupHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts new file mode 100644 index 00000000000..6896dff069f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { + computeProjectGroupHeaderDropPreview, + measureProjectGroupHeaderDragRects +} from './project-group-header-drop' +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import { + INITIAL_PROJECT_GROUP_DRAG_STATE, + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX, + type ProjectGroupDragState, + type ProjectGroupHeaderDragController, + type ProjectGroupHeaderDragSession, + type UseProjectGroupHeaderDragArgs +} from './project-group-header-drag-contract' +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + +// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized +// and may unmount while scrolling; cached row-model indices keep drops stable. + +export function useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById, + onCommitProjectGroupTabOrder, + getScrollContainer +}: UseProjectGroupHeaderDragArgs): ProjectGroupHeaderDragController { + const [state, setState] = useState(INITIAL_PROJECT_GROUP_DRAG_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + const sidebarProjectGroupHeaderIdsByBucketRef = useRef(sidebarProjectGroupHeaderIdsByBucket) + sidebarProjectGroupHeaderIdsByBucketRef.current = sidebarProjectGroupHeaderIdsByBucket + const projectGroupByIdRef = useRef(projectGroupById) + projectGroupByIdRef.current = projectGroupById + const onCommitProjectGroupTabOrderRef = useRef(onCommitProjectGroupTabOrder) + onCommitProjectGroupTabOrderRef.current = onCommitProjectGroupTabOrder + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) + + const dragSessionRef = useRef(null) + const clickSwallowTimeoutRef = useRef | null>(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectGroupHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeProjectGroupHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + }) + }, + [] + ) + + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null + } + autoscrollLastFrameTimeRef.current = null + }, []) + + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = setTimeout(() => { + window.removeEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = null + }, 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectGroupHeaderDragDrop({ + session, + sidebarDropIndex, + projectGroupById: projectGroupByIdRef.current, + onCommitProjectGroupTabOrder: onCommitProjectGroupTabOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + const drop = computeDrop(session.latestPointerY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + session.latestPointerY = event.clientY + if (!session.promoted) { + const dx = event.clientX - session.startX + const dy = event.clientY - session.startY + if ( + dx * dx + dy * dy < + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX * PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX + ) { + return + } + session.promoted = true + // Why: virtualized headers may detach during drag; global listeners + // still keep the operation alive if pointer capture is unavailable. + if (session.handleEl.isConnected) { + try { + session.handleEl.setPointerCapture(session.pointerId) + } catch { + // Ignore capture failure; global listeners will handle the drag. + } + } + refreshHeaderRects() + setState({ draggingGroupId: session.groupId, dropIndex: null, dropIndicatorY: null }) + } + refreshHeaderRects() + const drop = computeDrop(event.clientY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + ensureAutoscroll() + } + const onPointerUp = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + cancelAutoscroll() + if (clickSwallowTimeoutRef.current !== null) { + clearTimeout(clickSwallowTimeoutRef.current) + clickSwallowTimeoutRef.current = null + } + } + }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + + useEffect(() => { + if (state.draggingGroupId === null) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [state.draggingGroupId]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, groupId: string) => { + const session = createProjectGroupHeaderDragSession({ + event, + groupId, + projectGroupById: projectGroupByIdRef.current, + sidebarProjectGroupHeaderIdsByBucket: sidebarProjectGroupHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} + +export { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget +} from './project-group-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts new file mode 100644 index 00000000000..5abf7648677 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + computeProjectGroupHeaderDropPreview, + getProjectGroupHeaderDragBucketKey, + getProjectGroupTabOrderForSidebarDrop, + getSidebarOrderedProjectGroupHeaderIdsByBucket, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup, Repo } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectGroupHeaderDragBucketKey', () => { + it('uses root for top-level groups', () => { + expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root') + }) + + it('scopes child groups to their parent bucket', () => { + const root = group('root') + const child = group('child', { parentGroupId: root.id }) + const groupsById = new Map([ + [root.id, root], + [child.id, child] + ]) + + expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root') + }) + + it('falls back to root when persisted parent metadata is missing', () => { + const orphan = group('orphan', { parentGroupId: 'missing' }) + + expect(getProjectGroupHeaderDragBucketKey(orphan, new Map([[orphan.id, orphan]]))).toBe('root') + }) +}) + +describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => { + it('groups Project Group headers by effective parent bucket', () => { + const rootA = group('root-a') + const rootB = group('root-b') + const childA = group('child-a', { parentGroupId: rootA.id }) + const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo + const groupsById = new Map([ + [rootA.id, rootA], + [rootB.id, rootB], + [childA.id, childA] + ]) + const rows = [ + { + type: 'header', + key: 'project-group:root-a', + label: 'A', + count: 0, + tone: '', + projectGroup: rootA + }, + { type: 'header', key: 'repo:repo-a', label: 'repo', count: 0, tone: '', repo }, + { + type: 'header', + key: 'project-group:child-a', + label: 'A child', + count: 0, + tone: '', + projectGroup: childA + }, + { + type: 'header', + key: 'project-group:root-b', + label: 'B', + count: 0, + tone: '', + projectGroup: rootB + } + ] as Row[] + + expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual( + new Map([ + ['root', ['root-a', 'root-b']], + ['parent:root-a', ['child-a']] + ]) + ) + }) +}) + +describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) +}) + +describe('computeProjectGroupHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { groupId: 'b', bucketKey: 'root', headerIndex: 1, top: 100, bottom: 128 }, + { groupId: 'c', bucketKey: 'root', headerIndex: 2, top: 200, bottom: 228 }, + { groupId: 'd', bucketKey: 'root', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) +}) + +describe('getProjectGroupTabOrderForSidebarDrop', () => { + it('uses a midpoint between sibling tab orders when there is room', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 0 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + ).toBe(5) + }) + + it('assigns an order before the first sibling', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 20 })], + dropIndex: 0 + }) + ).toBe(9) + }) + + it('keeps a deterministic finite anchor when sibling orders collide', () => { + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + + expect(tabOrder).toBe(11) + expect(Number.isFinite(tabOrder)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts new file mode 100644 index 00000000000..95546266779 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -0,0 +1,210 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupHeaderDragBucketKey = string + +export type ProjectGroupHeaderDragRect = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + // Index among sibling Project Group headers in the row model, not mounted DOM. + headerIndex: number + top: number + bottom: number +} + +export type ProjectGroupHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 +const ROOT_PROJECT_GROUP_HEADER_BUCKET = 'root' + +type SidebarProjectGroupHeader = ProjectGroup | { id: null } | undefined + +function isConcreteProjectGroup( + projectGroup: SidebarProjectGroupHeader +): projectGroup is ProjectGroup { + return typeof projectGroup?.id === 'string' +} + +export function getProjectGroupHeaderDragBucketKey( + group: Pick, + projectGroupById?: ReadonlyMap +): ProjectGroupHeaderDragBucketKey { + const parentGroupId = group.parentGroupId ?? null + if (!parentGroupId) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + if (projectGroupById && !projectGroupById.has(parentGroupId)) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + return `parent:${parentGroupId}` +} + +export function getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows: readonly Row[], + projectGroupById?: ReadonlyMap +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || row.repo || !isConcreteProjectGroup(row.projectGroup)) { + continue + } + const bucketKey = getProjectGroupHeaderDragBucketKey(row.projectGroup, projectGroupById) + const list = buckets.get(bucketKey) ?? [] + list.push(row.projectGroup.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function getProjectGroupTabOrderForSidebarDrop(args: { + siblings: readonly ProjectGroup[] + dropIndex: number +}): number { + const ordered = args.siblings.slice() + if (ordered.length === 0) { + return 0 + } + const getOrder = (group: ProjectGroup | undefined): number | undefined => + group && Number.isFinite(group.tabOrder) ? group.tabOrder : undefined + const before = getOrder(ordered[args.dropIndex - 1]) + const after = getOrder(ordered[args.dropIndex]) + if (before === undefined && after === undefined) { + return 0 + } + if (before === undefined) { + return after !== undefined ? after - 1 : 0 + } + if (after === undefined) { + return before + 1 + } + if (after > before) { + const midpoint = before + (after - before) / 2 + return Number.isFinite(midpoint) ? midpoint : before / 2 + after / 2 + } + // Why: duplicate legacy ranks leave no slot between siblings; a finite anchor + // lets the next drag establish a stable persisted order. + return before + 1 +} + +export function mapSidebarProjectGroupDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but tabOrder is + // computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +export function measureProjectGroupHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectGroupHeaderDragBucketKey +): ProjectGroupHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectGroupHeaderDragRect[] = [] + container.querySelectorAll('[data-project-group-header-id]').forEach((element) => { + const groupId = element.getAttribute('data-project-group-header-id') + const elementBucketKey = element.getAttribute('data-project-group-header-bucket') + const rawHeaderIndex = element.getAttribute('data-project-group-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!groupId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + groupId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function computeProjectGroupHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectGroupHeaderDragRect[] + sidebarProjectGroupHeaderIds: readonly string[] +}): ProjectGroupHeaderDropPreview | null { + const { rects, sidebarProjectGroupHeaderIds } = args + if (rects.length === 0 || sidebarProjectGroupHeaderIds.length === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = rects[0]! + const last = rects.at(-1)! + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: first.groupId, + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: last.groupId, + groupIndex: last.headerIndex, + top: last.top, + bottom: last.bottom + }, + sourceGroupSize: sidebarProjectGroupHeaderIds.length + }) + if (boundaryDrop.kind === 'outside') { + return null + } + + let dropIndex = last.headerIndex + 1 + let indicatorY = last.bottom + INDICATOR_GAP_PX + if (boundaryDrop.kind === 'drop') { + dropIndex = boundaryDrop.dropIndex + indicatorY = boundaryDrop.indicatorY + } else { + for (const rect of rects) { + const mid = (rect.top + rect.bottom) / 2 + if (localY < mid) { + dropIndex = rect.headerIndex + indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) + break + } + } + } + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 805aaa4c6de..f0ef24b9ef6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -2275,6 +2275,67 @@ describe('project groups', () => { ]) }) + it('orders Project Group siblings by tabOrder within each parent bucket', () => { + const rootA: ProjectGroup = { + id: 'group-root-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 20, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const rootB: ProjectGroup = { + ...rootA, + id: 'group-root-b', + name: 'Infrastructure', + tabOrder: 10 + } + const childLate: ProjectGroup = { + ...rootA, + id: 'group-child-late', + name: 'late', + parentGroupId: rootB.id, + tabOrder: 30 + } + const childEarly: ProjectGroup = { + ...rootA, + id: 'group-child-early', + name: 'early', + parentGroupId: rootB.id, + tabOrder: 5 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [rootA, rootB, childLate, childEarly] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-root-b', + 'project-group:group-child-early', + 'project-group:group-child-late', + 'project-group:group-root-a' + ]) + expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( + [0, 1, 1, 0] + ) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', From d65c938529bd9a893b7fda725743cb72067729e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:42:49 -0700 Subject: [PATCH 080/233] Keep runtime mirror sized with desktop terminal resizes (#7192) Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 56 ++++++++++ src/main/runtime/orca-runtime.test.ts | 143 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 27 +++-- 3 files changed, 218 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e3475692814..26c1bda9157 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3829,6 +3829,62 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + + it('fans out accepted desktop resizes to the runtime after provider resize', async () => { + const resize = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(resize).toHaveBeenCalledWith(id, 120, 30) + expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30) + expect(resize.mock.invocationCallOrder[0]).toBeLessThan( + runtime.onExternalPtyResize.mock.invocationCallOrder[0]! + ) + }) + + it('does not fan out rejected desktop resizes to the runtime', async () => { + setupProviderWithAppliedSize({ + applied: { cols: 80, rows: 24 }, + resize: () => { + throw new Error('resize rejected') + } + }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b4b2dae941d..372cf304061 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -52,6 +52,7 @@ import { recentTerminalOutputIncludesPath, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -719,6 +720,64 @@ function syncSinglePty( }) } +function makeDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function makeStatusFrame(index: number, first: boolean): string { + const pad = '·'.repeat(60) + const rows = [ + `✻ 执行任务中… (esc to interrupt) [${index}] ${pad}`, + ` ⎿ 正在分析代码库结构与依赖关系,请稍候… ${pad}`, + ` ⎿ tokens: ${1000 + index * 137} · elapsed: ${index}s ${pad}` + ] + return `${first ? '' : '\x1b[2A'}\r${rows.map((row) => `\x1b[K${row}`).join('\r\n')}\r` +} + +async function writeHeadless(emulator: HeadlessEmulator, data: string): Promise { + await emulator.write(data) +} + +function visibleNonEmptyLines(emulator: HeadlessEmulator): string[] { + return emulator.getVisibleLines().filter((line) => line.length > 0) +} + +async function parseHeadlessSnapshotLines( + snapshot: { data: string; cols: number; rows: number }, + display: { cols: number; rows: number } +): Promise { + const restored = new HeadlessEmulator({ cols: display.cols, rows: display.rows }) + try { + restored.resize(snapshot.cols, snapshot.rows) + await writeHeadless(restored, `\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + restored.resize(display.cols, display.rows) + return visibleNonEmptyLines(restored) + } finally { + restored.dispose() + } +} + +async function referenceStatusFrameLines( + spawn: { cols: number; rows: number }, + resized: { cols: number; rows: number } +): Promise { + const truth = new HeadlessEmulator({ cols: spawn.cols, rows: spawn.rows }) + try { + await writeHeadless(truth, 'user@host % claude\r\n') + truth.resize(resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + await writeHeadless(truth, makeStatusFrame(index, index === 0)) + } + return visibleNonEmptyLines(truth) + } finally { + truth.dispose() + } +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -5411,6 +5470,90 @@ describe('OrcaRuntimeService', () => { }) }) + it('resizes the headless mirror after an accepted desktop PTY resize', async () => { + const spawn = { cols: 80, rows: 24 } + const resized = { cols: 120, rows: 30 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + currentSize = resized + runtime.onExternalPtyResize('pty-1', resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + runtime.onPtyData('pty-1', makeStatusFrame(index, index === 0), 200 + index) + } + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 5000 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows, source: 'headless' }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual( + await referenceStatusFrameLines(spawn, resized) + ) + }) + + it('orders headless mirror resizes behind queued PTY writes', async () => { + const spawn = { cols: 80, rows: 10 } + const resized = { cols: 120, rows: 10 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize, + resize: () => { + currentSize = resized + return true + } + }) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt\r\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1') + + type HeadlessStateForTest = { + emulator: HeadlessEmulator + writeChain: Promise + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const queuedWriteStarted = makeDeferred() + const releaseQueuedWrite = makeDeferred() + headless!.emulator.write = async (data: string): Promise => { + queuedWriteStarted.resolve() + await releaseQueuedWrite.promise + await originalWrite(data) + } + + try { + runtime.onPtyData('pty-1', '\x1b[90GOLD', 200) + await queuedWriteStarted.promise + await runtime.updateDesktopViewport('pty-1', resized) + runtime.onPtyData('pty-1', '\r\nNEXT', 300) + releaseQueuedWrite.resolve() + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual([ + 'prompt', + ' O', + 'LD', + 'NEXT' + ]) + } finally { + headless!.emulator.write = originalWrite + releaseQueuedWrite.resolve() + } + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81150a3a36b..afd10bee579 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5760,7 +5760,21 @@ export class OrcaRuntimeService { } private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { - this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: terminal reflow is a parser operation. It must sit in the same + // per-PTY stream as output bytes or restore snapshots can bake in wraps + // from the wrong terminal width. + state.writeChain = state.writeChain + .then(() => { + state.emulator.resize(cols, rows) + }) + .catch(() => { + // Best-effort mirror tracking; live PTY streaming must continue even + // if xterm rejects a raced resize during teardown. + }) } private createHeadlessEmulator( @@ -7052,7 +7066,6 @@ export class OrcaRuntimeService { if (!resized) { return false } - this.resizeHeadlessTerminal(ptyId, cols, rows) this.onExternalPtyResize(ptyId, cols, rows) return true } @@ -7880,12 +7893,9 @@ export class OrcaRuntimeService { } } - // Why: called from the pty:resize IPC handler whenever the desktop renderer - // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode - // restore). Stores the renderer-reported size so handleMobileSubscribe can use - // the actual pane geometry instead of a stale PTY size for previousCols. - // This is a passive geometry report — it does NOT call applyLayout; the - // PTY is already at the reported size. + // Why: called after a desktop renderer path has successfully resized the + // PTY (local IPC or remote desktop viewport). The runtime mirror must take + // the same accepted geometry so hidden-output restore parses at PTY width. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` // before calling here, but defend against callers that don't. @@ -7905,6 +7915,7 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } From bf6a39987cfcf64ea71e4c4fa2cb178f7b958b75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:50:01 +0000 Subject: [PATCH 081/233] release: v1.4.120-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41ec21ecb50..5e37d338f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.4", + "version": "1.4.120-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From fa94065a81efbd0f0ea24133078b7ffec23c97d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:06:32 -0700 Subject: [PATCH 082/233] Recover exported terminal handles from live PTYs (#7162) * Recover exported terminal handles from live PTYs * Guard terminal-handle adoption: first-wins, no collisions Discovery adoption of ORCA_TERMINAL_HANDLE is now skipped when the pty already has a handle bound this session (re-keying would strand waiters registered under the old handle) or when the reported handle is already bound to a different pty (provider-reported values are not trusted to be collision-free). Also adds the relay why-comment and PtyProcessSummary type alias from review feedback. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.ts | 12 +- src/main/daemon/daemon-pty-router.ts | 9 +- .../daemon/degraded-daemon-pty-provider.ts | 9 +- src/main/daemon/session.ts | 3 + src/main/daemon/terminal-host.ts | 2 + src/main/daemon/types.ts | 1 + src/main/providers/local-pty-provider.ts | 12 +- src/main/providers/ssh-pty-provider.ts | 6 +- src/main/providers/types.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 106 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 45 +++++++- src/relay/pty-handler.ts | 34 +++++- 12 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a649abebcc9..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -19,7 +19,12 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' @@ -517,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -525,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a7516ec4073..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 372cf304061..ee8d6edd608 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10497,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index afd10bee579..38414da2155 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -661,7 +661,7 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -1037,7 +1037,7 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -5041,6 +5041,46 @@ export class OrcaRuntimeService { } } + private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { + return + } + // Why: after an app/runtime restart, the live PTY child still has its + // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. + this.registerPreAllocatedHandleForPty(ptyId, trimmed) + } + + // Why: adoption is best-effort restart recovery and must be first-wins. + // Re-keying a pty that already has a handle this session would strand + // waiters registered under the old handle, and provider-reported values + // are not trusted to be collision-free — a handle bound to a different + // pty must never be stolen by a later report. + private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { + if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { + return true + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (issued && issued !== handle) { + return true + } + } + const existingRecord = this.handles.get(handle) + if (existingRecord && existingRecord.ptyId !== ptyId) { + return true + } + for (const [otherPtyId, otherHandle] of this.handleByPtyId) { + if (otherHandle === handle && otherPtyId !== ptyId) { + return true + } + } + return false + } + onPtySpawned(ptyId: string): void { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { @@ -17789,6 +17829,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived From f0278c116e9d3abe66dce26364b4e1122f613b76 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:30 -0300 Subject: [PATCH 083/233] fix(mobile): preserve terminal input mode on reentry (#7129) Fixes #6972.\n\nPreserves mobile terminal buffered/live input mode across Android terminal re-entry and session refreshes. Includes follow-up hardening for pre-hydration preference edits and failed storage reads. --- .../app/h/[hostId]/session/[worktreeId].tsx | 80 +------ ...erminal-live-input-mode-preference.test.ts | 138 ++++++++++++ ...use-terminal-live-input-mode-preference.ts | 212 ++++++++++++++++++ mobile/src/storage/preferences.test.ts | 59 +++++ mobile/src/storage/preferences.ts | 47 ++++ .../src/terminal/terminal-live-input.test.ts | 31 +++ mobile/src/terminal/terminal-live-input.ts | 42 ++++ 7 files changed, 540 insertions(+), 69 deletions(-) create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.test.ts create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 3feb30faa3d..a934334b8f2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, - defaultTerminalLiveInputHandles, isTerminalLiveInputWithinByteLimit, - pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' @@ -165,6 +163,7 @@ import { } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' +import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -896,11 +895,14 @@ export default function SessionScreen() { const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1104,7 +1106,6 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs - liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1170,52 +1171,6 @@ export default function SessionScreen() { [clearToastHideTimer] ) - // Why: direct input is now the mobile default, but only once per discovered - // handle so a user's buffered-mode toggle survives tab/list refreshes. - const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { - const result = defaultTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - handles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { - const result = pruneTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - liveHandles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const clearTerminalLiveInputDefault = useCallback( - (handle: string) => { - const liveHandles = new Set([ - ...liveInputTerminalHandlesRef.current, - ...defaultedLiveInputTerminalHandlesRef.current - ]) - liveHandles.delete(handle) - pruneTerminalHandlesFromLiveInput(liveHandles) - }, - [pruneTerminalHandlesFromLiveInput] - ) - const dictation = useMobileDictation({ client, enabled: canSend, @@ -2685,9 +2640,6 @@ export default function SessionScreen() { setSessionTabs([]) setActiveSessionTabId(null) clearPendingLiveInputCommit() - liveInputTerminalHandlesRef.current = new Set() - defaultedLiveInputTerminalHandlesRef.current = new Set() - setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) clearDelayedActionTimers() @@ -3362,17 +3314,7 @@ export default function SessionScreen() { if (!activeHandle) { return } - const nextEnabled = !liveInputTerminalHandles.has(activeHandle) - setLiveInputTerminalHandles((prev) => { - const next = new Set(prev) - if (nextEnabled) { - next.add(activeHandle) - } else { - next.delete(activeHandle) - } - liveInputTerminalHandlesRef.current = next - return next - }) + const nextEnabled = toggleTerminalLiveInput(activeHandle) clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) @@ -3380,7 +3322,7 @@ export default function SessionScreen() { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) + }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, + type DisabledTerminalLiveInputHandlesPreference +} from '../storage/preferences' +import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference' + +vi.mock('../storage/preferences', () => ({ + readDisabledTerminalLiveInputHandlesPreference: vi.fn(), + saveDisabledTerminalLiveInputHandles: vi.fn() +})) + +type TerminalLiveInputModePreferenceHarness = { + readonly current: ReturnType + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + if (!resolve) { + throw new Error('deferred resolver was not initialized') + } + return { promise, resolve } +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { + let current: ReturnType | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + current = useTerminalLiveInputModePreference({ + hostId: 'host-1', + worktreeId: 'worktree-1' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!current || !renderer) { + throw new Error('terminal live input mode preference hook did not render') + } + + return { + get current() { + if (!current) { + throw new Error('terminal live input mode preference hook is not mounted') + } + return current + }, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input mode preference hook', () => { + beforeEach(() => { + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue() + }) + + it('merges pre-hydration edits with loaded disabled handles', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2']) + }) + act(() => { + expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true) + }) + + await act(async () => { + load.resolve({ handles: new Set(['pty-2']), loaded: true }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith( + 'host-1', + 'worktree-1', + new Set(['pty-2']) + ) + harness.unmount() + }) + + it('does not persist fallback-empty storage reads during clean hydration', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1']) + }) + + await act(async () => { + load.resolve({ handles: new Set(), loaded: false }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled() + harness.unmount() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles +} from '../storage/preferences' +import { + applyDisabledTerminalLiveInputHandles, + defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + pruneTerminalLiveInputHandles +} from '../terminal/terminal-live-input' + +type UseTerminalLiveInputModePreferenceOptions = { + readonly hostId: string + readonly worktreeId: string +} + +export function useTerminalLiveInputModePreference({ + hostId, + worktreeId +}: UseTerminalLiveInputModePreferenceOptions) { + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(new Set()) + + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + // Why: terminal discovery (tab snapshots, list poll, create) can arrive + // before the async persisted-disabled load on worktree re-entry. + if (!disabledLiveInputHydratedRef.current) { + for (const handle of handles) { + pendingLiveInputDefaultHandlesRef.current.add(handle) + } + return + } + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + handles, + disabledLiveInputTerminalHandlesRef.current + ) + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + defaultableHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const persistDisabledLiveInputHandles = useCallback(() => { + void saveDisabledTerminalLiveInputHandles( + hostId, + worktreeId, + disabledLiveInputTerminalHandlesRef.current + ).catch(() => {}) + }, [hostId, worktreeId]) + + const pruneTerminalHandlesFromLiveInput = useCallback( + (liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + let prunedDisabledHandles = false + for (const handle of disabledLiveInputTerminalHandlesRef.current) { + if (liveHandles.has(handle)) { + continue + } + disabledLiveInputTerminalHandlesRef.current.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + prunedDisabledHandles = true + } + if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, + [persistDisabledLiveInputHandles] + ) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) { + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + } + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput] + ) + + const toggleTerminalLiveInput = useCallback( + (handle: string): boolean => { + const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle) + if (nextEnabled) { + disabledLiveInputTerminalHandlesRef.current.delete(handle) + } else { + disabledLiveInputTerminalHandlesRef.current.add(handle) + } + // Why: pre-hydration edits must patch the loaded set per handle; replacing + // the loaded set would erase other persisted opt-outs for this worktree. + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled) + } + // Why: only persist after hydration; an earlier write would use the + // reset-empty ref and overwrite other handles' opt-outs for the worktree. + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(handle) + } else { + next.delete(handle) + } + liveInputTerminalHandlesRef.current = next + return next + }) + return nextEnabled + }, + [persistDisabledLiveInputHandles] + ) + + useEffect(() => { + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputHydratedRef.current = false + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + pendingLiveInputDefaultHandlesRef.current = new Set() + setLiveInputTerminalHandles(new Set()) + + let disposed = false + // Why: load the persisted opt-outs first so defaulting logic (which can + // fire immediately from subscriptions) respects prior user choices. + void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => { + if (disposed) { + return + } + const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current + const hydratedDisabledHandles = new Set(preference.handles) + for (const [handle, disabled] of pendingEdits) { + if (disabled) { + hydratedDisabledHandles.add(handle) + } else { + hydratedDisabledHandles.delete(handle) + } + } + disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles + disabledLiveInputHydratedRef.current = true + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + if (preference.loaded && pendingEdits.size > 0) { + persistDisabledLiveInputHandles() + } + const result = applyDisabledTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + hydratedDisabledHandles + ) + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current] + pendingLiveInputDefaultHandlesRef.current.clear() + defaultTerminalHandlesToLiveInput(pendingDefaultHandles) + }) + return () => { + disposed = true + } + }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId]) + + return { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,9 +8,12 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => { }) }) +describe('terminal live input disabled handles preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to no disabled handles when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: true }) + }) + + it('loads only string terminal handles from storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2'])) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set(['pty-1', 'pty-2']) + ) + }) + + it('falls back to no disabled handles for invalid or unreadable storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: false }) + }) + + it('persists disabled handles per host and worktree', async () => { + await saveDisabledTerminalLiveInputHandles( + 'host/one', + 'folder:C:\\repo', + new Set(['pty-2', 'pty-1']) + ) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['pty-2', 'pty-1']) + ) + }) +}) + describe('host sidebar width preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' + +export type DisabledTerminalLiveInputHandlesPreference = { + readonly handles: Set + readonly loaded: boolean +} + +function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string { + return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( + worktreeId + )}` +} + +export async function readDisabledTerminalLiveInputHandlesPreference( + hostId: string, + worktreeId: string +): Promise { + try { + const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId)) + if (!raw) { + return { handles: new Set(), loaded: true } + } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } + } catch { + return { handles: new Set(), loaded: false } + } +} + +export async function loadDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string +): Promise> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + await AsyncStorage.setItem( + terminalLiveInputDisabledKey(hostId, worktreeId), + JSON.stringify([...handles]) + ) +} + const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Bounds for the draggable host worktree-list sidebar on tablet/foldable diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..95b4960f928 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + applyDisabledTerminalLiveInputHandles, clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, @@ -101,6 +103,35 @@ describe('terminal live input', () => { expect(result.defaultedHandles).toBe(defaulted) }) + it('does not default persisted buffered-mode handles back to live input on reentry', () => { + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + ['pty-1', 'pty-2'], + new Set(['pty-1']) + ) + + const result = defaultTerminalLiveInputHandles( + new Set(), + new Set(['pty-1']), + defaultableHandles + ) + + expect(defaultableHandles).toEqual(['pty-2']) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('reconciles persisted buffered-mode handles with currently enabled live input', () => { + const result = applyDisabledTerminalLiveInputHandles( + new Set(['pty-1', 'pty-2']), + new Set(['pty-2']), + new Set(['pty-1']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1']) + }) + it('prunes terminal handles that disappear from session snapshots', () => { const result = pruneTerminalLiveInputHandles( new Set(['pty-1', 'pty-stale']), diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..96f279a4855 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (!disabledHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of disabledHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function pruneTerminalLiveInputHandles( enabledHandles: ReadonlySet, defaultedHandles: ReadonlySet, From 09fc57e11387e982db1b8a549b399f4db580351d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 02:24:24 -0400 Subject: [PATCH 084/233] fix(browser): keep CDP reload from closing Orca browser tabs Fix the per-tab CDP proxy reload path so raw Page.reload keeps the Orca browser tab alive. Lifecycle priming is bounded and root reloads use the same safer webContents reload behavior as Orca's own browser reload path. --- src/main/browser/cdp-ws-proxy.test.ts | 184 +++++++++++++++++++++++++- src/main/browser/cdp-ws-proxy.ts | 117 ++++++++++++++-- 2 files changed, 287 insertions(+), 14 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 9f345a2e6ee..11fbcd772cf 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -21,7 +21,9 @@ function createMockWebContents() { detach: vi.fn(() => { debuggerAttached = false }), - sendCommand: vi.fn(async () => ({})), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _sessionId?: string) => ({}) + ), on: vi.fn((event: string, handler: DebuggerListener) => { const arr = listeners.get(event) ?? [] arr.push(handler) @@ -41,6 +43,8 @@ function createMockWebContents() { debugger: debuggerObj, isDestroyed: () => destroyed, focus: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), getTitle: vi.fn(() => 'Example'), getURL: vi.fn(() => 'https://example.com') }, @@ -89,6 +93,22 @@ describe('CdpWsProxy', () => { }) } + type SendCommandCall = [string, Record?, string?] + + function getSendCommandCalls(): SendCommandCall[] { + const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [ + string, + Record?, + string? + ][] + return calls + } + + function getSendCommandMethods(): string[] { + const calls = getSendCommandCalls() + return calls.map((call) => call[0]) + } + it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(proxy.getPort()).toBeGreaterThan(0) @@ -311,6 +331,168 @@ describe('CdpWsProxy', () => { client.close() }) + it('primes lifecycle events for Page.navigate', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 11, + method: 'Page.navigate', + params: { url: 'https://example.com/next' } + }) + + expect(response.id).toBe(11) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled', + 'Page.navigate' + ]) + client.close() + }) + + it('primes lifecycle events for Page.reload and preserves response id', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 12, + method: 'Page.reload' + }) + + expect(response.id).toBe(12) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled' + ]) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods()).not.toContain('Page.reload') + client.close() + }) + + it('preserves explicit Page.navigate session during lifecycle priming', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 14, + method: 'Page.navigate', + params: { url: 'https://example.com/frame' }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123'] + ]) + client.close() + }) + + it('forwards explicit Page.reload session after lifecycle priming', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 15, + method: 'Page.reload', + params: { ignoreCache: true }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.reload', { ignoreCache: true }, 'iframe-session-123'] + ]) + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(mock.webContents.reload).not.toHaveBeenCalled() + client.close() + }) + + it('rejects root Page.reload params that direct webContents reload cannot honor', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 16, + method: 'Page.reload', + params: { loaderId: 'stale-loader' } + }) + + expect(response).toEqual({ + id: 16, + error: { + code: -32000, + message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads' + } + }) + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(getSendCommandMethods()).not.toContain('Network.enable') + client.close() + }) + + it('still reloads when lifecycle priming stalls', async () => { + const client = await connect() + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + const responsePromise = sendAndReceive(client, { + id: 17, + method: 'Page.reload' + }) + + await expect(responsePromise).resolves.toEqual({ id: 17, result: {} }) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + client.close() + }) + + it('does not reload after the requesting client disconnects during priming', async () => { + const client = await connect() + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + client.send(JSON.stringify({ id: 18, method: 'Page.reload' })) + client.close() + + await new Promise((resolve) => setTimeout(resolve, 1_100)) + + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + }) + + it('forwards Runtime.evaluate without lifecycle priming', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 13, + method: 'Runtime.evaluate', + params: { expression: 'document.readyState' } + }) + + expect(response.id).toBe(13) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Runtime.evaluate' + ]) + client.close() + }) + // ── Page.frameNavigated interception ── // ── Cleanup ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 2531822b982..ec8de5be176 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 + export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null @@ -298,13 +300,39 @@ export class CdpWsProxy { } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. + // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031). if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) { - void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {}) + void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + return + } + // Why: CDP Page.reload can destroy Electron webview targets during process swaps. + // Use the same direct webContents reload path as Orca's own browser.reload. + if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) { + void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId) } + private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { + return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + } + + private isActiveClient(client: WebSocket): boolean { + return this.client === client && client.readyState === WebSocket.OPEN + } + + private sendDebuggerCommand( + method: string, + params: Record, + sessionId?: string + ): Promise { + const command = sessionId + ? this.webContents.debugger.sendCommand(method, params, sessionId) + : this.webContents.debugger.sendCommand(method, params) + return Promise.resolve(command) + } + private forwardCommand( client: WebSocket, clientId: number, @@ -316,10 +344,9 @@ export class CdpWsProxy { this.sendError(clientId, 'Browser tab is no longer available', client) return } - const sessionId = - msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const sessionId = this.resolveDebuggerSessionId(msgSessionId) try { - Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId)) + this.sendDebuggerCommand(method, params, sessionId) .then((result) => { this.sendResult(clientId, result, client) }) @@ -331,21 +358,85 @@ export class CdpWsProxy { } } - private async navigateWithLifecycleEnsured( + private async navigateWithLifecycle( client: WebSocket, clientId: number, - params: Record + params: Record, + msgSessionId?: string ): Promise { + await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId)) + if (!this.isActiveClient(client)) { + return + } + this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) + } + + private async reloadWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + const sessionId = this.resolveDebuggerSessionId(msgSessionId) + const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) + if (unsupportedParam) { + this.sendError( + clientId, + `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, + client + ) + return + } + await this.primePageLifecycle(sessionId) + if (!this.isActiveClient(client)) { + return + } + if (sessionId) { + this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) + return + } + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } try { - const dbg = this.webContents.debugger + if (params.ignoreCache === true) { + this.webContents.reloadIgnoringCache() + } else { + this.webContents.reload() + } + this.sendResult(clientId, {}, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private getUnsupportedRootReloadParam(params: Record): string | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { // Why: without Network.enable, agent-browser never sees network idle → goto times out. - await dbg.sendCommand('Network.enable', {}) - await dbg.sendCommand('Page.enable', {}) - await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true }) - } catch { - /* best-effort */ + await this.sendDebuggerCommand('Network.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId) + })().catch(() => {}) + + try { + await Promise.race([ + priming, + new Promise((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } } - this.forwardCommand(client, clientId, 'Page.navigate', params) } private handleScreenshot( From 2848c5daf47a3b76181179e42817f94582cded70 Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 3 Jul 2026 14:37:12 +0800 Subject: [PATCH 085/233] Fix rename inputs committing on CJK IME Enter confirmation (#6980) Fixes #6980. --- .../settings/WorkspaceDirectorySetting.tsx | 10 ++-- ...eeTitleInlineRename.begin-editing.test.tsx | 50 ++++++++++++++++ .../sidebar/WorktreeTitleInlineRename.tsx | 6 ++ .../components/tab-bar/EditorFileTab.test.tsx | 57 ++++++++++++++++++- .../src/components/tab-bar/EditorFileTab.tsx | 6 ++ .../SortableTab.rename-shortcut.test.tsx | 49 +++++++++++++++- .../src/components/tab-bar/SortableTab.tsx | 6 ++ .../TerminalPaneHeaderOverlay.test.tsx | 53 +++++++++++++++-- .../TerminalPaneHeaderOverlay.tsx | 6 ++ .../ime-composition-keyboard-event.test.ts | 26 +++++++++ .../src/lib/ime-composition-keyboard-event.ts | 13 +++++ 11 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.test.ts create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.ts diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx index 7b017362b1d..8958dd58543 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx @@ -68,6 +68,7 @@ async function renderTitleRename(props: { beginEditing: boolean disabled?: boolean onBeginEditingConsumed: () => void + onRename?: (displayName: string) => Promise | void }): Promise { reactHookRuntime.index = 0 const module = await import('./WorktreeTitleInlineRename') @@ -120,6 +121,27 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('WorktreeTitleInlineRename beginEditing', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -152,4 +174,32 @@ describe('WorktreeTitleInlineRename beginEditing', () => { expect(onBeginEditingConsumed).toHaveBeenCalledTimes(1) expect(findElementsByType(rerender, 'input')).toHaveLength(0) }) + + it('ignores IME composition Enter before committing the edited title', async () => { + const onBeginEditingConsumed = vi.fn() + const onRename = vi.fn() + + await renderTitleRename({ beginEditing: true, onBeginEditingConsumed, onRename }) + let rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 workspace' } + }) + rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onRename).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + await Promise.resolve() + + expect(onRename).toHaveBeenCalledWith('日本語 workspace') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 914c3eb2f9a..d7a98fe2981 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string } @@ -211,6 +212,11 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + // Why: an Enter that only confirms a CJK IME candidate must not commit the + // rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() void commitRename() diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 0d48f3dfe41..59a1c18bcee 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -19,6 +19,8 @@ const appStoreMocks = vi.hoisted(() => ({ })) })) +const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -147,7 +149,7 @@ vi.mock('@/components/editor/editor-labels', () => ({ })) vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: vi.fn() + renameFileOnDisk: renameFileOnDiskMock })) vi.mock('@/lib/file-type-icons', () => ({ @@ -326,6 +328,27 @@ function findSpanByText(node: unknown, label: string): ReactElementLike { return span } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('EditorFileTab rename menu', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -371,6 +394,38 @@ describe('EditorFileTab rename menu', () => { expect(select).not.toHaveBeenCalled() }) + it('ignores IME composition Enter before renaming the editor file tab', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '日本語.md' + } as unknown as HTMLInputElement) + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(renameFileOnDiskMock).toHaveBeenCalledWith({ + oldPath: '/repo/untitled-5.md', + newName: '日本語.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 694d03b0b59..3fb1a28ca5d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' @@ -281,6 +282,11 @@ export default function EditorFileTab({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 10a81ab059c..bb4e757d1c8 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -208,7 +208,11 @@ function makeTerminalTab() { } } -async function renderSortableTab(): Promise { +async function renderSortableTab({ + onSetCustomTitle = vi.fn() +}: { + onSetCustomTitle?: (tabId: string, title: string | null) => void +} = {}): Promise { reactHookRuntime.index = 0 const module = await import('./SortableTab') return module.default({ @@ -224,7 +228,7 @@ async function renderSortableTab(): Promise { onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), - onSetCustomTitle: vi.fn(), + onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), onToggleExpand: vi.fn(), @@ -282,6 +286,25 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -312,4 +335,26 @@ describe('SortableTab rename shortcut signal', () => { expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + + it('ignores IME composition Enter before committing the custom tab title', async () => { + const onSetCustomTitle = vi.fn() + + await renderSortableTab({ onSetCustomTitle }) + let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 terminal' } + }) + rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onSetCustomTitle).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..3bef8529a75 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -5,6 +5,7 @@ import { ShellIcon } from './shell-icons' import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -333,6 +334,11 @@ export default function SortableTab({ onChange={(event) => setRenameValue(event.target.value)} onBlur={commitRename} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() commitRename() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 78e80b3231e..80a386b3c8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -45,17 +45,24 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, onClosePane = vi.fn(), - onRemoveTitle = vi.fn() + onRemoveTitle = vi.fn(), + onRenameSubmit = vi.fn(), + renameValue = '', + renamingPaneId = null }: { paneTitles: Record paneCount?: number showAlwaysOnHeaders?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType + onRenameSubmit?: ReturnType + renameValue?: string + renamingPaneId?: number | null }): { container: HTMLDivElement onClosePane: ReturnType onRemoveTitle: ReturnType + onRenameSubmit: ReturnType } { const panes = [makePane(1), makePane(2)] const container = document.createElement('div') @@ -76,8 +83,8 @@ function renderOverlay({ 1: { left: 0, top: 0, width: 200 }, 2: { left: 220, top: 0, width: 200 } }} - renamingPaneId={null} - renameValue="" + renamingPaneId={renamingPaneId} + renameValue={renameValue} renameInputRef={createRef()} titleUsesLightSurface={false} paneTitleBackground="transparent" @@ -93,14 +100,31 @@ function renderOverlay({ onRemoveTitle={onRemoveTitle as (paneId: number) => void} onClosePane={onClosePane as (paneId: number) => void} onRenameValueChange={vi.fn()} - onRenameSubmit={vi.fn()} + onRenameSubmit={onRenameSubmit as () => void} onRenameCancel={vi.fn()} onRenameBlur={vi.fn()} /> ) }) mounted.push({ container, root }) - return { container, onClosePane, onRemoveTitle } + return { container, onClosePane, onRemoveTitle, onRenameSubmit } +} + +function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + input.dispatchEvent(event) + }) } afterEach(() => { @@ -142,4 +166,23 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).toHaveBeenCalledWith(1) expect(onRemoveTitle).not.toHaveBeenCalled() }) + + it('ignores IME composition Enter before submitting a pane title rename', () => { + const { container, onRenameSubmit } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' }, + renamingPaneId: 1, + renameValue: '日本語 pane' + }) + const input = container.querySelector('.pane-title-input') + + expect(input).not.toBeNull() + + pressInputKey(input as HTMLInputElement, 'Enter', { isComposing: true }) + + expect(onRenameSubmit).not.toHaveBeenCalled() + + pressInputKey(input as HTMLInputElement, 'Enter') + + expect(onRenameSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 3ad25d41bdd..fc1602f866e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' @@ -183,6 +184,11 @@ export default function TerminalPaneHeaderOverlay({ value={renameValue} onChange={(event) => onRenameValueChange(event.target.value)} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must + // not commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { onRenameSubmit() } else if (event.key === 'Escape') { diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts new file mode 100644 index 00000000000..daa02c5411e --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + +function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { + return { + nativeEvent: { + isComposing: nativeEvent.isComposing ?? false, + keyCode: nativeEvent.keyCode ?? 13 + } + } as unknown as ReactKeyboardEvent +} + +describe('isImeCompositionKeyDown', () => { + it('is true while the IME is composing', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('is true for the keyCode 229 fallback when isComposing is not set', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('is false for a plain Enter outside of composition', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts new file mode 100644 index 00000000000..c5faed78d16 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -0,0 +1,13 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that + * only confirms a conversion candidate. Rename/title inputs that commit on + * `Enter` must ignore that keydown, otherwise they submit mid-composition with a + * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is + * a defensive fallback for IMEs that don't set `isComposing` on keydown. + */ +export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { + const nativeEvent = event.nativeEvent + return nativeEvent.isComposing || nativeEvent.keyCode === 229 +} From 10e8f8689975fe7fa6faef3012bf276cc3e0a382 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:43:35 -0700 Subject: [PATCH 086/233] Coalesce PTY input write bursts (#7205) Co-authored-by: Orca --- .../pty-input-write-queue.test.ts | 157 ++++++++++++++ .../terminal-pane/pty-input-write-queue.ts | 155 ++++++++++++++ .../components/terminal-pane/pty-transport.ts | 101 +-------- .../pane-terminal-mouse-wheel.test.ts | 150 +++++++++++++- .../pane-manager/pane-terminal-mouse-wheel.ts | 4 +- .../pane-terminal-tui-wheel-reports.ts | 54 ++++- .../fixtures/visible-tui-scroll-fixture.cjs | 43 +++- tests/e2e/terminal-tui-wheel-drain.spec.ts | 195 ++++++++++++++++++ 8 files changed, 756 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.ts create mode 100644 tests/e2e/terminal-tui-wheel-drain.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts new file mode 100644 index 00000000000..ddf856f05ae --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, + createPtyInputWriteQueue +} from './pty-input-write-queue' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +const WHEEL_UP_REPORT = '\x1b[<64;60;20M' + +type WriteRecord = { id: string; data: string } + +function createRecordingQueue(options: { writable?: () => boolean } = {}): { + writes: WriteRecord[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => options.writable?.() ?? true, + write: (id, data) => writes.push({ id, data }) + }) + return { writes, queue } +} + +describe('pty input write queue', () => { + it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { + const { writes, queue } = createRecordingQueue() + + // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports + // enqueued while the drain cannot run between events. + for (let i = 0; i < 240; i += 1) { + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + } + await queue.waitForDrain() + + // First report flushes immediately (keystroke latency); everything queued + // behind it must drain as a single coalesced write, not 239 timer turns. + expect(writes.length).toBe(2) + expect(writes[0]?.data).toBe(WHEEL_UP_REPORT) + expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239)) + expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1']) + }) + + it('preserves byte order and content across coalesced writes', async () => { + const { writes, queue } = createRecordingQueue() + + const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] + for (const input of inputs) { + queue.enqueue('pty-1', input) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) + }) + + it('does not coalesce across different PTY ids', async () => { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }) + }) + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-2', 'c') + queue.enqueue('pty-1', 'd') + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'b' }, + { id: 'pty-2', data: 'c' }, + { id: 'pty-1', data: 'd' } + ]) + }) + + it('keeps coalesced payloads under the input chunk byte cap', async () => { + const { writes, queue } = createRecordingQueue() + + const piece = 'x'.repeat(1000) + for (let i = 0; i < 12; i += 1) { + queue.enqueue('pty-1', piece) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12)) + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS) + } + expect(writes.length).toBeGreaterThan(1) + }) + + it('still chunks oversized items and keeps trailing input ordered behind them', async () => { + const { writes, queue } = createRecordingQueue() + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + queue.enqueue('pty-1', 'before') + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'after') + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) + expect(writes.at(-1)?.data).toBe('after') + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES) + } + }) + + it('rejects input over the terminal input byte limit without writing', async () => { + const { writes, queue } = createRecordingQueue() + + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('drops queued input for PTYs that are no longer writable', async () => { + let writable = true + const { writes, queue } = createRecordingQueue({ writable: () => writable }) + + queue.enqueue('pty-1', 'a') + writable = false + queue.enqueue('pty-1', 'b') + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) + }) + + it('clear() drops pending input that has not been written yet', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'tail') + // First chunk is written synchronously, then the drain parks on the yield. + expect(writes.length).toBe(1) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes.length).toBe(1) + expect(writes.map((write) => write.data).join('')).not.toContain('tail') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts new file mode 100644 index 00000000000..b20e764df15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -0,0 +1,155 @@ +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' + +// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the +// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on +// the hot input path. +export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 + +type PendingPtyInputWrite = { + id: string + text: string + tooLarge: boolean | Promise + chunks?: Iterator + nextChunk?: string +} + +export type PtyInputWriteQueue = { + enqueue: (id: string, data: string) => boolean + waitForDrain: () => Promise + clear: () => void +} + +export type PtyInputWriteQueueDeps = { + isWritable: (id: string) => boolean + write: (id: string, data: string) => void + yieldBetweenWrites?: () => Promise +} + +function defaultYieldBetweenWrites(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function isCoalescibleText(text: string): boolean { + return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS +} + +export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { + const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + let pending: PendingPtyInputWrite[] = [] + let drainPromise: Promise | null = null + + async function drain(): Promise { + while (pending.length > 0) { + const next = pending[0] + if (!next) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (next.tooLarge) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + } + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleText(next.text)) { + let payload = next.text + pending.shift() + while (pending.length > 0) { + const peek = pending[0] + if ( + !peek || + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleText(peek.text) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + pending.shift() + } + deps.write(next.id, payload) + if (pending.length > 0) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + pending.shift() + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + pending.shift() + } else { + next.nextChunk = following.value + } + if (pending.length > 0) { + await yieldBetweenWrites() + } + } + } + + function scheduleDrain(): void { + if (drainPromise) { + return + } + drainPromise = drain().finally(() => { + drainPromise = null + if (pending.length > 0) { + scheduleDrain() + } + }) + } + + return { + enqueue(id: string, data: string): boolean { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + pending.push({ id, text: data, tooLarge }) + scheduleDrain() + return true + } catch { + return false + } + }, + + async waitForDrain(): Promise { + while (drainPromise) { + await drainPromise + } + }, + + clear(): void { + pending = [] + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..f15f6ea990b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' @@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 -type PendingPtyInputWrite = { - id: string - text: string - tooLarge: boolean | Promise - chunks?: Iterator - nextChunk?: string -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - let pendingInputWrites: PendingPtyInputWrite[] = [] - let inputWriteDrainPromise: Promise | null = null + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (id) => connected && ptyId === id, + write: (id, data) => window.api.pty.write(id, data) + }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function drainPendingInputWrites(): Promise { - while (pendingInputWrites.length > 0) { - const next = pendingInputWrites[0] - if (!next) { - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pendingInputWrites.shift() - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pendingInputWrites.shift() - continue - } - window.api.pty.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pendingInputWrites.shift() - } else { - next.nextChunk = following.value - } - if (pendingInputWrites.length > 0) { - await yieldToInputWriteDrain() - } - } - } - - function schedulePendingInputWriteDrain(): void { - if (inputWriteDrainPromise) { - return - } - inputWriteDrainPromise = drainPendingInputWrites().finally(() => { - inputWriteDrainPromise = null - if (pendingInputWrites.length > 0) { - schedulePendingInputWriteDrain() - } - }) - } - - function clearPendingInputWrites(): void { - pendingInputWrites = [] - } - - function enqueuePtyInputWrite(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pendingInputWrites.push({ id, text: data, tooLarge }) - schedulePendingInputWriteDrain() - return true - } catch { - return false - } - } - - async function waitForPendingInputWrites(): Promise { - while (inputWriteDrainPromise) { - await inputWriteDrainPromise - } - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra disconnect() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { const id = ptyId window.api.pty.kill(id) @@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra detach() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { // Why: detach() is used for in-session remounts such as moving a tab // between split groups. Stop delivering data/title events into the @@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return enqueuePtyInputWrite(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data) }, ...(connectionId @@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return false } const id = ptyId - await waitForPendingInputWrites() + await inputWriteQueue.waitForDrain() if (!connected || ptyId !== id) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 3a15574255f..05190661d5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -243,6 +243,66 @@ describe('terminal mouse wheel multiplier', () => { expect(reports).toEqual([0, 0, 0, 1]) }) + it('does not burst-boost rapid trackpad-like pixel deltas', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('tracks a decaying trackpad-like momentum tail with linear row distance', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1]) + }) + + it('drops pending trackpad distance on each direction change', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0]) + }) + + it('emits the full linear distance for a fast trackpad-like flick event', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([12, 12, 12]) + }) + it('resets pending fractional distance when the user changes direction', () => { const state = createTerminalTuiMouseWheelDistanceState() @@ -269,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) }) - it('leaves trackpad-like pixel scrolling one-to-one', () => { + it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => { expect( shouldMultiplyTerminalMouseWheel( wheelEvent({ @@ -278,7 +338,7 @@ describe('terminal mouse wheel multiplier', () => { }), terminalElement() ) - ).toBe(false) + ).toBe(true) }) it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { @@ -358,6 +418,92 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const events = [ + [4, 0], + [4, 16], + [4, 32], + [4, 48], + [-4, 64], + [-4, 80], + [-4, 96], + [-4, 112] + ].map(([deltaY, timeStamp]) => { + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY + }) as WheelEvent + Object.defineProperty(event, 'timeStamp', { + configurable: true, + value: timeStamp + }) + return event + }) + + for (const event of events) { + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + } + + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1]) + }) + + it('replays a fast trackpad-like flick as one full synthetic report batch', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 16 * 12 + }) as WheelEvent + + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1)) + }) + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { vi.stubGlobal('WheelEvent', TestWheelEvent) const handlers: ((event: WheelEvent) => boolean)[] = [] diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index ab192e393f7..f35c31efba7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,7 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { createTerminalTuiMouseWheelDistanceState, - isDiscreteTerminalTuiWheelEvent, normalizeTerminalTuiMouseWheelMultiplier, resolveTerminalTuiMouseWheelReportCount, resolveTerminalWheelDirection @@ -110,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel( isReplayedWheelEvent(event) || !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || event.deltaY === 0 || - event.shiftKey || - !isDiscreteTerminalTuiWheelEvent(event) + event.shiftKey ) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts index 1b92edb390f..8736dc6504e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -67,6 +67,11 @@ function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | n return null } +function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean { + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { return true @@ -76,8 +81,21 @@ export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInpu return true } - const legacyDelta = legacyVerticalWheelDelta(event) - return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN + return hasDiscreteLegacyWheelDelta(event) +} + +function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event) + ) } function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { @@ -130,6 +148,13 @@ function resolveBurstWheelDistanceRows( state: TerminalTuiMouseWheelDistanceState, distanceRows: number ): number { + if (!canBurstBoostWheelEvent(event)) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + const currentInputAt = wheelInputTime(event) if (currentInputAt === null) { state.fastStreak = 0 @@ -165,6 +190,26 @@ function resolveBurstWheelDistanceRows( return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) } +function resolveTrackpadPixelWheelReportCount( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number | null { + if (!isTrackpadLikePixelWheelEvent(event)) { + return null + } + + // Why: trackpad pixel streams map 1:1 to physical distance: one report per + // terminal row scrolled, fractional remainder carried. No per-event cap and + // no momentum-tail suppression: the input write queue batches whatever a + // busy frame accumulates into a single PTY write, so the TUI applies it at + // once instead of replaying it, and inertial scrolling stays real-time. + const totalRows = state.pendingRows + distanceRows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} + export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER @@ -193,6 +238,11 @@ export function resolveTerminalTuiMouseWheelReportCount( state.pendingDirection = direction const distanceRows = resolveWheelDistanceRows(event, metrics) + const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows) + if (trackpadReportCount !== null) { + return trackpadReportCount + } + const rows = Math.min( TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 207aac49cfc..137bc48dee1 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -1,6 +1,20 @@ +const fs = require('node:fs') + const ESC = '\x1b' const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') +// Optional flags: +// --log append " " per consumed stdin +// chunk so tests can measure when reports reached the PTY. +// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells +// per render, ~430KB/s at 120 reports/s — matches output +// volume measured from a real Claude Code session scrolling +// at 120 reports/s) instead of minimal rows. +const args = process.argv.slice(2) +const logIndex = args.indexOf('--log') +const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const HEAVY_FRAMES = args.includes('--heavy') + let offset = 0 let pending = '' @@ -12,12 +26,28 @@ function visibleRows() { return Math.max(3, process.stdout.rows || 24) } +function visibleCols() { + return Math.max(20, process.stdout.columns || 80) +} + +function heavyRowFiller(row, cols) { + // ~19 bytes per 8 visible columns of styled filler. + const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········` + const units = Math.max(0, Math.floor((cols - 24) / 8)) + return `${unit.repeat(units)}${ESC}[0m` +} + function render() { - write(`${ESC}[H`) - write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`) - for (let row = 1; row < visibleRows(); row += 1) { - write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`) + const rows = visibleRows() + const cols = visibleCols() + let frame = `${ESC}[?2026h${ESC}[H` + frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K` + for (let row = 1; row < rows; row += 1) { + const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}` + frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K` } + frame += `${ESC}[?2026l` + write(frame) } function cleanup() { @@ -42,14 +72,19 @@ process.stdin.on('data', (chunk) => { pending += chunk let match let lastIndex = 0 + let reportsInChunk = 0 MOUSE_REPORT_PATTERN.lastIndex = 0 while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + reportsInChunk += 1 lastIndex = MOUSE_REPORT_PATTERN.lastIndex } if (lastIndex > 0) { + if (LOG_PATH) { + fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`) + } pending = pending.slice(lastIndex) render() return diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts new file mode 100644 index 00000000000..c228159852c --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +// Reports must stop reaching the PTY shortly after the gesture ends. Before +// the input-write-queue coalescing fix, each SGR report drained in its own +// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the +// real input pipeline (which outprioritizes timers) plus TUI-scale redraw +// output starved that drain, and the backlog replayed for seconds after the +// fingers left the trackpad. +const MAX_ARRIVAL_LAG_MS = 900 +const WHEEL_EVENTS = 240 + +type WheelStreamResult = { + dispatchedEvents: number + inputEndWallClockMs: number +} + +async function startHeavyTuiFixture(page: Page, logPath: string): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal( + page, + ptyId, + `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}` + ) + + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.element?.classList.contains('enable-mouse-events') ?? false + }), + { timeout: 15_000, message: 'fixture did not enable mouse reporting' } + ) + .toBe(true) +} + +async function terminalWheelTarget( + page: Page +): Promise<{ x: number; y: number; cellHeight: number }> { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40), + cellHeight: + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + } + }) +} + +/** + * Drives real wheel input (CDP mouse events through the compositor input + * pipeline — the same priority class as physical trackpad input) rather than + * synthetic DOM dispatchEvent, so input can genuinely compete with the + * renderer's timer-based PTY input drain the way a physical gesture does. + */ +async function dispatchTrackpadWheelStream( + page: Page, + options: { alternate: boolean; events: number; deltaY: number } +): Promise { + const target = await terminalWheelTarget(page) + await page.mouse.move(target.x, target.y) + for (let i = 0; i < options.events; i += 1) { + const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1 + // No artificial sleep: CDP round-trips pace this near real trackpad rates + // while keeping the renderer's input queue continuously occupied. + await page.mouse.wheel(0, direction * options.deltaY) + } + const inputEndWallClockMs = await page.evaluate(() => Date.now()) + return { dispatchedEvents: options.events, inputEndWallClockMs } +} + +type ReportArrival = { atMs: number; reports: number } + +function readReportArrivalLog(logPath: string): ReportArrival[] { + if (!fs.existsSync(logPath)) { + return [] + } + return fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [atMs, reports] = line.split(' ') + return { atMs: Number(atMs), reports: Number(reports) } + }) +} + +function summarizeArrivals( + arrivals: ReportArrival[], + input: WheelStreamResult +): { arrivalLagMs: number; chunks: number; totalReports: number } { + const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0) + const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0 + return { + arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs, + chunks: arrivals.length, + totalReports + } +} + +test.describe('terminal TUI wheel report drain', () => { + test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) + + test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) +}) From 8e1ce20289c6a5d835fb0cd25f1fd5c34d067938 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:44:20 +0000 Subject: [PATCH 087/233] release: v1.4.120-rc.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e37d338f40..ce09f6ab7b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.5", + "version": "1.4.120-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0ab4d8925c52d259eeb205f7bea87509f5031883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:06:43 +0000 Subject: [PATCH 088/233] release: v1.4.120 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce09f6ab7b8..60184c71afe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.6", + "version": "1.4.120", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f1bcb7739286a5006124d68c85f0fbe5c8de91d0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:16:19 -0700 Subject: [PATCH 089/233] fix: parse Claude Fable current-week usage Claude Code 2.1.199 renders the Fable plan bucket as Current week (Fable), while Orca only parsed a standalone Fable heading. Accept weekly-style Fable headings as the distinct Fable weekly meter while keeping broader Fable-weekly copy as a section boundary to avoid false positives. Validation: - pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - pnpm run typecheck - pnpm exec oxlint src/main/rate-limits/claude-pty.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - git diff --check HEAD~1..HEAD - live local Claude fetcher returned fableWeekly: 62 --- src/main/rate-limits/claude-pty.test.ts | 43 +++++++++++++++++++++++++ src/main/rate-limits/claude-pty.ts | 12 +++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i const FABLE_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null From 0ff2c090029ad0f86429dd89ac34a1bb6e45afad Mon Sep 17 00:00:00 2001 From: Dvitash Date: Fri, 3 Jul 2026 03:26:37 -0400 Subject: [PATCH 090/233] Fix typed OMP remote title flicker (#6954) --- src/main/runtime/orca-runtime.test.ts | 297 +++++++++++ src/main/runtime/orca-runtime.ts | 119 ++++- .../terminal-pane/pty-connection.test.ts | 467 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 236 ++++++++- src/shared/agent-detection.test.ts | 9 + src/shared/agent-title-owner.ts | 8 + 6 files changed, 1108 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ee8d6edd608..8b3a65720c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12262,6 +12262,303 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(async () => 'omp') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('coalesces same-status title frames behind one post-title foreground probe', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('starts a post-title foreground probe when an older pending probe finds no owner', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + ;( + runtime as unknown as { + refreshPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).refreshPtyForegroundAgentFromController('pty-typed-omp') + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' }) + const getForegroundProcess = vi.fn(async () => 'pi') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'Pi ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'pi', + terminalHandle: terminal.handle, + terminalTitle: 'Pi ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38414da2155..bbfa9731182 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -20,6 +20,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' @@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = { waitBlockedAt: number | null } +type PtyForegroundAgentRefresh = { + promise: Promise + startedAfterTitleObservation: number + requestedAfterTitleObservation: number +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1925,7 +1932,8 @@ export class OrcaRuntimeService { private resolvedWorktreeGeneration = 0 private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null - private ptyForegroundAgentRefreshes = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. @@ -3253,7 +3261,7 @@ export class OrcaRuntimeService { /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch ownership. + * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, @@ -3269,9 +3277,10 @@ export class OrcaRuntimeService { } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - pty.launchAgent + ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -5194,11 +5203,25 @@ export class OrcaRuntimeService { if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } + const shouldDelayMobileSnapshot = + shouldTouchPtyBackedSessionTabs && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) + let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== pty.lastAgentStatus) { - this.refreshPtyForegroundAgent(ptyId) + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + shouldTouchPtyBackedSessionTabs = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } } @@ -8718,6 +8741,15 @@ export class OrcaRuntimeService { } } + private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( + pty: RuntimePtyWorktreeRecord, + title: string + ): boolean { + return ( + !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) + ) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. @@ -8726,19 +8758,75 @@ export class OrcaRuntimeService { void this.refreshPtyForegroundAgentFromController(ptyId) } + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ - private refreshPtyForegroundAgentFromController(ptyId: string): Promise { + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { - return pendingRefresh + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise } - const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => { - this.ptyForegroundAgentRefreshes.delete(ptyId) + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } }) - this.ptyForegroundAgentRefreshes.set(ptyId, refresh) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } @@ -8746,34 +8834,35 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { - return + return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { - return + return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { - return + return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { - return + return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { - return + return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) + return true } private getFreshExplicitAgentStatusForHandle(handle: string): { 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 53da6899409..4b3b796d5a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -230,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { if (/Codex( working)?/.test(title)) { return /working/.test(title) ? 'working' : 'idle' } + if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) { + return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle' + } return null }) } @@ -726,15 +729,18 @@ describe('connectPanePty', () => { markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), recordTerminalInput: vi.fn(), - setAgentStatus: vi.fn((paneKey: string, payload: Record) => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...payload, - paneKey, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] + setAgentStatus: vi.fn( + (paneKey: string, payload: Record, terminalTitle?: string | null) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + ...(terminalTitle ? { terminalTitle } : {}), + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } } - }), + ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), markTerminalTabUnread: vi.fn(), @@ -924,6 +930,451 @@ describe('connectPanePty', () => { ) }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:web-env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + it('queues visible bulk output off the synchronous xterm write path', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f614aea121b..582d695c322 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -89,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' -import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentType +} from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { createAgentInterruptInference, @@ -156,6 +160,10 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' @@ -165,7 +173,6 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -176,6 +183,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 @@ -1238,12 +1246,219 @@ export function connectPanePty( } /** * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, and store state configuration. + * pane startup, typed command ownership, and store state configuration. * * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; the stored status agentType is only the last-resort - * fallback because it can itself be a Pi-compatible frame. + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. */ + let commandInferredPaneAgent: TuiAgent | null = null + let pendingShellCommandLine = '' + let pendingShellCommandCursor = 0 + let commandInferredPaneAgentGeneration = 0 + let shellCommandInferenceSuspendedUntilCommandEnd = false + const resetPendingShellCommandLine = (): void => { + pendingShellCommandLine = '' + pendingShellCommandCursor = 0 + } + const rememberCommandInferredPaneAgent = (): void => { + const commandLine = pendingShellCommandLine.trim() + resetPendingShellCommandLine() + const nextAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + commandInferredPaneAgent = nextAgent + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgent = (): void => { + commandInferredPaneAgent = null + resetPendingShellCommandLine() + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = commandInferredPaneAgentGeneration + resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (commandInferredPaneAgentGeneration === generation) { + clearCommandInferredPaneAgent() + } + }, 0) + }) + } + const appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length + if (available <= 0) { + shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + inserted + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + const deletePendingShellCommandWord = (): void => { + const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) + const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + pendingShellCommandLine = nextBeforeCursor + afterCursor + pendingShellCommandCursor = nextBeforeCursor.length + } + const cancelSuspendedShellCommandInference = (): void => { + if (!shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + const deletePendingShellCommandCharacter = (): void => { + if (pendingShellCommandCursor === 0) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor -= 1 + } + const deletePendingShellCommandCharacterAtCursor = (): void => { + if (pendingShellCommandCursor >= pendingShellCommandLine.length) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + pendingShellCommandLine.slice(pendingShellCommandCursor + 1) + } + const movePendingShellCommandCursor = (delta: number): void => { + pendingShellCommandCursor = Math.min( + pendingShellCommandLine.length, + Math.max(0, pendingShellCommandCursor + delta) + ) + } + const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + if (final === 'D') { + movePendingShellCommandCursor(-1) + } else if (final === 'C') { + movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + pendingShellCommandCursor = pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + resetPendingShellCommandLine() + } + return cursor + 1 + } + const getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const hasFreshPaneAgentSurface = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const now = Date.now() + const entryIsFresh = + entry && + typeof entry.updatedAt === 'number' && + now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + if (entryIsFresh && entry.state !== 'done') { + return true + } + const liveTitle = getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } + const observeAcceptedShellCommandInput = (data: string): void => { + if (commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (hasFreshPaneAgentSurface()) { + resetPendingShellCommandLine() + return + } + if (shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + resetPendingShellCommandLine() + shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + shellCommandInferenceSuspendedUntilCommandEnd = false + rememberCommandInferredPaneAgent() + if (commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + resetPendingShellCommandLine() + continue + } + if (char < ' ') { + resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + appendPendingShellCommandInput(char) + if (shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } const getAuthoritativePaneAgent = (): AgentType | undefined => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -1253,6 +1468,7 @@ export function connectPanePty( tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ?? + commandInferredPaneAgent ?? state.agentStatusByPaneKey[cacheKey]?.agentType ) } @@ -1522,6 +1738,7 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + clearCommandInferredPaneAgentAfterPtySideEffects() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(deps.worktreeId) @@ -2192,6 +2409,7 @@ export function connectPanePty( } } const onAgentExited = (): void => { + clearCommandInferredPaneAgent() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -2461,6 +2679,11 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + cancelSuspendedShellCommandInference() + } clearPendingTerminalInputIntent() markTerminalInputSent() const writePromise = transport @@ -2468,6 +2691,7 @@ export function connectPanePty( .then((accepted) => { if (accepted) { recordAcceptedTerminalInputForHibernation() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) observeTitleOnlyInterrupt() @@ -2482,6 +2706,7 @@ export function connectPanePty( if (intent) { if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() @@ -2489,6 +2714,7 @@ export function connectPanePty( } if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index b4f8495548a..a1bee91aef3 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -8,6 +8,7 @@ import { MAX_OSC_TITLE_CHARS } from './agent-detection' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner @@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => { expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi') }) + it('identifies titles whose compatible identity can be re-owned', () => { + expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false) + expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false) + }) + it('normalizes Pi-compatible status identity and terminal title to the owner', () => { const status = normalizeCompatibleAgentStatusEntryForOwner( { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index a72a46b1cb8..4e1dfda55af 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null { return null } +/** + * Why: unknown-launch remote sessions need to wait for foreground ownership + * before publishing title frames whose identity can be re-owned. + */ +export function hasCompatibleAgentTitleIdentity(title: string): boolean { + return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup) +} + /** * Detects the agent status (working, permission, idle) from a terminal title, * accounting for legacy Pi titles. From 471881c14f07665ca17a596b11cd46d910904b7b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:06:13 -0700 Subject: [PATCH 091/233] Update WeChat QR code --- docs/assets/wechat-qr.png | Bin 62128 -> 443867 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea643ab6b2ac1233ffab84890d7d3a1..bf270d857e800cc3f19c4375f2e89140e30efee7 100644 GIT binary patch literal 443867 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

mVSHw72pz-@?~w3_Cb0iSjjPlQZCiY*#AyV! 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 z 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%0Dbc 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 z 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
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 3fb1a28ca5d..9de55f9c95e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' export default function EditorFileTab({ file, @@ -201,6 +202,12 @@ export default function EditorFileTab({ }, [menuOpen]) const dragListeners = isRenaming ? undefined : listeners + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate, + disabled: isRenaming + }) const tabRoot = (
{ - if (isRenaming || e.button !== 0) { - return - } - onActivate() - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onDoubleClick={() => { if (file.isPreview && onMakePermanent) { diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3bef8529a75..fa90c59e553 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -25,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' type SortableTabProps = { tab: TerminalTab @@ -210,6 +211,16 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const handleActivate = useCallback(() => { + onActivate(tab.id) + }, [onActivate, tab.id]) + // Why: defer activation to pointer-up so pressing a tab to drag it (reorder / + // move into another pane / split) does not switch the active tab or steal + // terminal focus mid-gesture. See tab-strip-pointer-activation. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate: handleActivate, + disabled: isEditing + }) const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = ( @@ -243,11 +254,10 @@ export default function SortableTab({ handleRenameOpen() }} onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..3bd467e49b0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent { + return { button, clientX, clientY } as unknown as React.PointerEvent +} + +function firePointer(type: string, clientX: number, clientY: number): void { + act(() => { + window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true })) + }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useTabStripPointerActivation', () => { + it('activates on a release that never crossed the drag threshold (a click)', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + // Why: the dnd-kit gesture must start on pointerdown even though activation + // is deferred. + expect(dragListener).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + + // Release within the threshold -> click -> activate. + firePointer('pointerup', 12, 11) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('suppresses activation when the pointer travels past the drag threshold', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10) + firePointer('pointerup', 300, 40) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('stays a drag even if the pointer returns near the start before release', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + // Cross the threshold, then come back over the origin and release. + firePointer('pointermove', 200, 200) + firePointer('pointerup', 11, 11) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('does not activate when the press is cancelled', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointercancel', 10, 10) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('ignores non-left buttons and disabled presses', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useTabStripPointerActivation({ onActivate, disabled }), + { initialProps: { disabled: false } } + ) + + // Right-click: ignored. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + + // Disabled: ignored. + rerender({ disabled: true }) + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + }) + + it('activates a click that lands after a prior drag gesture (regression #6395)', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + // First gesture: a drag (reorder). No activation. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 300, 10) + firePointer('pointerup', 300, 10) + expect(onActivate).not.toHaveBeenCalled() + + // Second gesture: a plain click. Must activate. + act(() => result.current.onPointerDown(pointerDownEvent(400, 10))) + firePointer('pointerup', 400, 10) + expect(onActivate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e1a32e1e0d8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' + +/** + * Defer tab activation to pointer-up and suppress it when the press turns into a + * drag. PR #5927 shipped this so dragging a tab (to reorder, move into another + * pane, or split) never switched the active tab or stole terminal focus + * mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix + * click-to-switch-after-reorder, which regressed the drag feature. + * + * We gate on measured pointer DISPLACEMENT, not the drag-active context ref the + * old hook used — that ref clears asynchronously relative to the drop's + * pointerup, which is what made #6395's click-after-reorder misfire. Displacement + * mirrors dnd-kit's own activation threshold exactly: once the pointer travels + * past it the gesture is a drag (activation suppressed); a release within it is a + * click (activate). Because each press measures its own gesture, a click after a + * reorder always activates. + */ +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + const cleanupRef = useRef<(() => void) | null>(null) + + // Why: a press still holding when the tab unmounts (tab closed mid-drag, group + // collapse) would otherwise leak its window listeners and later fire activation + // on a dead closure. + useEffect(() => () => cleanupRef.current?.(), []) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + // Why: start the dnd-kit gesture immediately on pointerdown; only the + // activation decision is deferred to release. + dragListener?.(event) + + cleanupRef.current?.() + const startX = event.clientX + const startY = event.clientY + let draggedPastThreshold = false + + const cleanup = (): void => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + cleanupRef.current = null + } + const onPointerMove = (moveEvent: PointerEvent): void => { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >= + TAB_DRAG_ACTIVATION_DISTANCE_PX + ) { + draggedPastThreshold = true + } + } + const onPointerUp = (): void => { + const wasDrag = draggedPastThreshold + cleanup() + // Why: only a release that never crossed the drag threshold is a click. + // Activating after a real drag would yank the just-dropped tab's pane + // back to the source selection. + if (!wasDrag) { + onActivateRef.current() + } + } + const onPointerCancel = (): void => { + cleanup() + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + cleanupRef.current = cleanup + }, + [disabled] + ) + + return { onPointerDown } +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 9f85c325a08..57dbf5946fc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -225,7 +225,9 @@ export default function TabGroupPanel({ user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b1f90ac2f60..dd5d2116b8c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,11 @@ import { resolveOpaqueTerminalBackground, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { + ManagedPane, + PaneExternalDropTarget, + PaneManager +} from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' @@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import { + detachTerminalPaneToTab, + isTerminalTabStripDropTarget, + resolveTerminalTabStripDropTarget +} from './terminal-pane-tab-detach' import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' @@ -1298,6 +1307,54 @@ export default function TerminalPane({ setPendingCloseConfirmation(null) }, []) + const resolveExternalPaneDropTarget = useCallback( + ({ + sourcePaneId, + clientX, + clientY + }: { + sourcePaneId: number + clientX: number + clientY: number + }): PaneExternalDropTarget | null => { + const manager = managerRef.current + const panes = manager?.getPanes() ?? [] + if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) { + return null + } + return resolveTerminalTabStripDropTarget({ + clientX, + clientY, + groupsByWorktree: useAppStore.getState().groupsByWorktree, + worktreeId + }) + }, + [worktreeId] + ) + + const handleExternalPaneDrop = useCallback( + (sourcePaneId: number, target: PaneExternalDropTarget): boolean => { + if (!isTerminalTabStripDropTarget(target)) { + return false + } + const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + return ( + detachTerminalPaneToTab({ + fallbackPtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }) !== null + ) + }, + [persistLayoutSnapshot, tabId, worktreeId] + ) + useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1354,7 +1411,9 @@ export default function TerminalPane({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop: handleExternalPaneDrop }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts new file mode 100644 index 00000000000..782113e1641 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'remote:env-1@@terminal-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_2]: 'buffer-2', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_2]: 'scrollback-2', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_2]: 'remote shell', + [LEAF_3]: 'three' + } + } +} + +describe('detachTerminalLayoutLeaf', () => { + it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2) + + expect(detached?.ptyId).toBe('remote:env-1@@terminal-1') + expect(detached?.detachedLayout).toEqual({ + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'buffer-2' }, + scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(detached?.sourceLayout).toEqual({ + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_3 } + }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_3]: 'three' + } + }) + }) + + it('clears source expanded selection when the expanded leaf is detached', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3) + + expect(detached?.sourceLayout.expandedLeafId).toBeNull() + expect(detached?.sourceLayout.root).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }) + }) + + it('returns null for missing or only leaf layouts', () => { + expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull() + expect( + detachTerminalLayoutLeaf( + { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1' } + }, + LEAF_1 + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts new file mode 100644 index 00000000000..d43702057f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -0,0 +1,119 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { + collectLeafIdsInOrder, + normalizeTerminalLayoutSnapshot, + resolveTerminalLayoutActiveLeafId +} from './terminal-layout-leaf-ids' + +export type DetachedTerminalLayoutLeaf = { + sourceLayout: TerminalLayoutSnapshot + detachedLayout: TerminalLayoutSnapshot + ptyId: string | null +} + +function removeLeafFromTree( + node: TerminalPaneLayoutNode, + leafId: string +): { node: TerminalPaneLayoutNode | null; removed: boolean } { + if (node.type === 'leaf') { + return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false } + } + + const first = removeLeafFromTree(node.first, leafId) + const second = removeLeafFromTree(node.second, leafId) + if (!first.removed && !second.removed) { + return { node, removed: false } + } + if (!first.node) { + return { node: second.node, removed: true } + } + if (!second.node) { + return { node: first.node, removed: true } + } + return { + node: { + ...node, + first: first.node, + second: second.node + }, + removed: true + } +} + +function omitLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + return source + } + const next = { ...source } + delete next[leafId] + return Object.keys(next).length > 0 ? next : undefined +} + +function singleLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + const value = source?.[leafId] + return value ? { [leafId]: value } : undefined +} + +export function detachTerminalLayoutLeaf( + snapshot: TerminalLayoutSnapshot | null | undefined, + leafId: string +): DetachedTerminalLayoutLeaf | null { + const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot + if (!layout.root) { + return null + } + + const originalLeafIds = collectLeafIdsInOrder(layout.root) + if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { + return null + } + + const removal = removeLeafFromTree(layout.root, leafId) + if (!removal.removed || !removal.node) { + return null + } + + const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId) + const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId) + const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId) + const sourceLayout: TerminalLayoutSnapshot = { + root: removal.node, + activeLeafId: resolveTerminalLayoutActiveLeafId({ + root: removal.node, + activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId, + ptyIdsByLeafId + }), + expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + } + + const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId) + const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId) + const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId) + return { + sourceLayout, + detachedLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), + ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), + ...(detachedScrollbackRefsByLeafId + ? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId } + : {}), + ...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {}) + }, + ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts new file mode 100644 index 00000000000..a92e03cce8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + detachTerminalPaneToTab, + resolveTerminalTabStripDropTarget, + type TerminalPaneTabDetachStore +} from './terminal-pane-tab-detach' + +const WORKTREE_ID = 'repo-1::/worktree' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_GROUP_ID = 'group-target' +const EXISTING_TAB_1 = 'tab-existing-1' +const EXISTING_TAB_2 = 'tab-existing-2' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +function createTerminalTab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } +} + +function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] +): TerminalPaneTabDetachStore { + const store = { + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} + +describe('resolveTerminalTabStripDropTarget', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('finds a same-worktree tab strip under overlay elements', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [] + } + const overlay = { closest: () => null } + const child = { closest: () => strip } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [overlay, child]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toEqual({ + id: TARGET_GROUP_ID, + groupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + rect: stripRect + }) + }) + + it('resolves the insertion slot from the hovered tab side', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 }) + const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 }) + const firstTab = { + dataset: { tabId: EXISTING_TAB_1 }, + getBoundingClientRect: () => firstTabRect + } + const secondTab = { + dataset: { tabId: EXISTING_TAB_2 }, + getBoundingClientRect: () => secondTabRect + } + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [firstTab, secondTab] + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 60, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + activeTabId: EXISTING_TAB_1, + tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2], + worktreeId: WORKTREE_ID + } as AppState['groupsByWorktree'][string][number] + ] + }, + worktreeId: WORKTREE_ID + }) + ).toMatchObject({ + groupId: TARGET_GROUP_ID, + insertionIndex: 1, + overlayKind: 'insertion', + rect: rect({ left: 80, top: 0, width: 2, height: 32 }) + }) + }) + + it('ignores strips from another worktree', () => { + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' }, + getBoundingClientRect: () => + ({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toBeNull() + }) +}) + +describe('detachTerminalPaneToTab', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + const persistLayoutSnapshot = vi.fn() + + const result = detachTerminalPaneToTab({ + manager, + getStore: () => store, + persistLayoutSnapshot, + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('remote:env-1@@terminal-1') + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + activate: true, + initialPtyId: 'remote:env-1@@terminal-1', + recordInteraction: true + }) + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }) + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'remote:env-1@@terminal-1', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached') + expect(store.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(persistLayoutSnapshot).toHaveBeenCalled() + }) + + it('syncs PTY ownership when the primary source pane is detached', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('pty-left') + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'pty-left', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + }) + + it('moves the detached tab into the requested group slot', () => { + const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + targetIndex: 1, + worktreeId: WORKTREE_ID + }) + + expect(store.reorderUnifiedTabs).toHaveBeenCalledWith( + TARGET_GROUP_ID, + [EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2], + { recordInteraction: false } + ) + }) + + it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + fallbackPtyId: 'remote:env-2@@terminal-9', + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts new file mode 100644 index 00000000000..26f3d5996e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -0,0 +1,306 @@ +import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store' +import type { TerminalTab } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' + +export type TerminalPaneTabDetachStore = Pick< + AppState, + | 'createTab' + | 'groupsByWorktree' + | 'reorderUnifiedTabs' + | 'setActiveTab' + | 'setActiveTabType' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' + | 'terminalLayoutsByTabId' +> + +type TerminalPaneTabDetachManager = { + getPanes: () => readonly { id: number }[] + getLeafId: (paneId: number) => string | null + detachPaneForExternalMove: (paneId: number) => boolean +} + +export type TerminalTabStripDropTarget = PaneExternalDropTarget & { + groupId: string + insertionIndex?: number + worktreeId: string +} + +export type DetachedTerminalPaneTab = { + tab: TerminalTab + leafId: string + ptyId: string | null +} + +function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean { + return ( + clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + ) +} + +function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function clampIndex(index: number, max: number): number { + return Math.min(Math.max(index, 0), max) +} + +function getTabElements(strip: HTMLElement): HTMLElement[] { + return Array.from(strip.querySelectorAll('[data-tab-id]')).filter( + (element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0 + ) +} + +function getInsertionMarkerRect( + tabRects: DOMRect[], + insertionIndex: number, + stripRect: DOMRect +): DOMRect { + const markerWidth = 2 + const clampedIndex = clampIndex(insertionIndex, tabRects.length) + const rawLeft = + clampedIndex < tabRects.length + ? (tabRects[clampedIndex]?.left ?? stripRect.left) + : (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth + const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth) + return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height }) +} + +function resolveTabStripInsertion(args: { + clientX: number + clientY: number + groupTabOrderLength: number + strip: HTMLElement + stripRect: DOMRect +}): { index: number; rect: DOMRect } | null { + const tabs = getTabElements(args.strip) + if (tabs.length === 0) { + return null + } + const tabRects = tabs.map((tab) => tab.getBoundingClientRect()) + + for (let index = 0; index < tabRects.length; index += 1) { + const tabRect = tabRects[index] + if (!tabRect) { + continue + } + if (args.clientX < tabRect.left) { + const insertionIndex = clampIndex(index, args.groupTabOrderLength) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + if (pointWithinRect(args.clientX, args.clientY, tabRect)) { + const insertionIndex = clampIndex( + index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1), + args.groupTabOrderLength + ) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + } + + const insertionIndex = args.groupTabOrderLength + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } +} + +function getElementsFromPoint(clientX: number, clientY: number): Element[] { + if (typeof document === 'undefined') { + return [] + } + const elements = document.elementsFromPoint?.(clientX, clientY) + if (elements && elements.length > 0) { + return elements + } + const element = document.elementFromPoint?.(clientX, clientY) + return element ? [element] : [] +} + +export function resolveTerminalTabStripDropTarget(args: { + clientX: number + clientY: number + groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree'] + worktreeId: string +}): TerminalTabStripDropTarget | null { + const groups = args.groupsByWorktree[args.worktreeId] ?? [] + const groupById = new Map(groups.map((group) => [group.id, group])) + const validGroupIds = new Set(groups.map((group) => group.id)) + if (validGroupIds.size === 0) { + return null + } + + for (const element of getElementsFromPoint(args.clientX, args.clientY)) { + const strip = element.closest(TAB_GROUP_STRIP_SELECTOR) + const groupId = strip?.dataset.tabGroupStripId + const worktreeId = strip?.dataset.worktreeId + if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) { + continue + } + const rect = strip.getBoundingClientRect() + if (!pointWithinRect(args.clientX, args.clientY, rect)) { + continue + } + const group = groupById.get(groupId) + const insertion = group + ? resolveTabStripInsertion({ + clientX: args.clientX, + clientY: args.clientY, + groupTabOrderLength: group.tabOrder?.length ?? 0, + strip, + stripRect: rect + }) + : null + return insertion + ? { + id: groupId, + groupId, + insertionIndex: insertion.index, + overlayKind: 'insertion', + rect: insertion.rect, + worktreeId + } + : { id: groupId, groupId, worktreeId, rect } + } + + return null +} + +function withDetachedPtyFallback(args: { + leafId: string + ptyId: string | null + detachedLayout: NonNullable>['detachedLayout'] +}): NonNullable>['detachedLayout'] { + if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { + return args.detachedLayout + } + return { + ...args.detachedLayout, + ptyIdsByLeafId: { + ...args.detachedLayout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +export function isTerminalTabStripDropTarget( + target: PaneExternalDropTarget +): target is TerminalTabStripDropTarget { + const candidate = target as Partial + return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string' +} + +function moveCreatedTabToIndex(args: { + groupId: string + store: TerminalPaneTabDetachStore + tabId: string + targetIndex: number | undefined + worktreeId: string +}): void { + if (args.targetIndex === undefined) { + return + } + const group = args.store.groupsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.groupId + ) + if (!group) { + return + } + const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId) + const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length) + const nextOrder = [...orderWithoutCreatedTab] + nextOrder.splice(insertionIndex, 0, args.tabId) + args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) +} + +export function detachTerminalPaneToTab(args: { + fallbackPtyId?: string | null + getStore: () => TerminalPaneTabDetachStore + manager: TerminalPaneTabDetachManager | null + persistLayoutSnapshot: () => void + sourcePaneId: number + sourceTabId: string + targetGroupId: string + targetIndex?: number + worktreeId: string +}): DetachedTerminalPaneTab | null { + const initialStore = args.getStore() + const targetGroupExists = + initialStore.groupsByWorktree[args.worktreeId]?.some( + (group) => group.id === args.targetGroupId + ) ?? false + if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { + return null + } + + const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) + if (!sourceLeafId) { + return null + } + + args.persistLayoutSnapshot() + const store = args.getStore() + const detached = detachTerminalLayoutLeaf( + store.terminalLayoutsByTabId[args.sourceTabId], + sourceLeafId + ) + if (!detached) { + return null + } + + const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null + const detachedLayout = withDetachedPtyFallback({ + leafId: sourceLeafId, + ptyId, + detachedLayout: detached.detachedLayout + }) + + // Why: remove the renderer pane only after the layout/PTY handoff has been + // computed; the close callback detaches listeners but must not kill the PTY. + if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { + return null + } + + const latestStore = args.getStore() + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + activate: true, + initialPtyId: ptyId ?? undefined, + recordInteraction: true + }) + const afterCreateStore = args.getStore() + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId: args.worktreeId + }) + afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + afterCreateStore.setTabLayout(tab.id, detachedLayout) + afterCreateStore.syncPaneDetachPtyOwnership({ + detachedPtyId: ptyId, + sourceLayout: detached.sourceLayout, + sourceTabId: args.sourceTabId, + targetTabId: tab.id + }) + afterCreateStore.setActiveTab(tab.id) + afterCreateStore.setActiveTabType('terminal') + + return { tab, leafId: sourceLeafId, ptyId } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1674e150fcd..3597f9dd28e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,7 +2,11 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + PaneManager, + type PaneExternalDropHandler, + type PaneExternalDropResolver +} from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { normalizeTerminalFastScrollSensitivity, @@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = { // Why: same pane count does not imply same geometry; drag-reorder can move // panes without resizing them, so overlay rects need a layout-change tick. setPaneLayoutRevision: React.Dispatch> + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function suppressIntentionalPaneCloseExit( @@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -1094,6 +1102,7 @@ export function useTerminalPaneLifecycle({ queueResizeAll(true) }, onPaneClosed: (paneId, closedPane) => { + const isDetachedToTab = closedPane?.reason === 'detach' const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -1169,31 +1178,37 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. This is pane-keyed state, so it must - // clear even if the PTY transport was already removed. const leafId = closedPane?.leafId - if (leafId) { + if (leafId && !isDetachedToTab) { + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().setCacheTimerStartedAt(paneKey, null) clearTerminalPaneUnread(paneKey) useAppStore.getState().dropAgentStatus(paneKey) } if (transport) { - const ptyId = suppressIntentionalPaneCloseExit( - transport, - useAppStore.getState().suppressPtyExit - ) - if (ptyId) { - // Why: user/CLI pane closes intentionally tear down this PTY after - // PaneManager has already promoted the sibling. Suppress that exit - // so the last-surviving pane is not mistaken for an exited tab. - syncPanePtyLayoutBinding(paneId, null) - clearTabPtyId(tabId, ptyId) + if (isDetachedToTab) { + // Why: pane-to-tab detach hands the PTY to a newly-created tab; + // detach renderer listeners without sending a process teardown. + transport.detach?.() + } else { + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) + if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. + syncPanePtyLayoutBinding(paneId, null) + clearTabPtyId(tabId, ptyId) + } + transport.destroy?.() } - transport.destroy?.() paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -1292,6 +1307,8 @@ export function useTerminalPaneLifecycle({ releaseWebviewDragPassthrough?.() releaseWebviewDragPassthrough = null }, + resolveExternalPaneDropTarget, + onExternalPaneDrop, terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index 45d71f151e0..3be4db71d72 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { handlePaneDrop, @@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown( callbacks.getRoot().classList.remove('is-pane-dragging') callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source') try { - if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) { - handlePaneDrop( - state.dragSourcePaneId, - state.currentDropTarget.paneId, - state.currentDropTarget.zone, - state, - callbacks - ) + if (commitDrop && state.dragSourcePaneId !== null) { + if (state.currentDropTarget) { + handlePaneDrop( + state.dragSourcePaneId, + state.currentDropTarget.paneId, + state.currentDropTarget.zone, + state, + callbacks + ) + } else if (state.currentExternalDropTarget) { + callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget) + } } } finally { // Why: pointer capture can be lost crossing Electron webviews; always @@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown( hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } } @@ -166,8 +171,20 @@ function updateDropTarget( } const targetPane = findDropTargetPane(clientX, clientY, state, callbacks) if (!targetPane) { - overlay.style.display = 'none' + const sourcePaneId = state.dragSourcePaneId + const externalTarget = + sourcePaneId === null + ? null + : (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null) + if (!externalTarget) { + overlay.style.display = 'none' + state.currentDropTarget = null + state.currentExternalDropTarget = null + return + } state.currentDropTarget = null + state.currentExternalDropTarget = externalTarget + positionExternalDropOverlay(overlay, externalTarget) return } @@ -180,9 +197,11 @@ function updateDropTarget( ) { overlay.style.display = 'none' state.currentDropTarget = null + state.currentExternalDropTarget = null return } state.currentDropTarget = { paneId: targetPane.id, zone } + state.currentExternalDropTarget = null positionDropOverlay(overlay, rect, zone) } @@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void { overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = 'area' const scrollX = window.scrollX const scrollY = window.scrollY const halfWidth = rect.width / 2 @@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px` overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px` } + +function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void { + const rect = target.rect + overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area' + overlay.style.left = `${rect.left + window.scrollX}px` + overlay.style.top = `${rect.top + window.scrollY}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index dd81b2063fe..1f5996526cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -229,6 +229,7 @@ describe('attachPaneDrag', () => { expect(appendedElements[0].removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) expect(detachPaneFromTree).not.toHaveBeenCalled() @@ -288,10 +289,83 @@ describe('attachPaneDrag', () => { expect(appendedElements[0]?.removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) }) + it('drops onto an external target when no pane target is under the pointer', () => { + const handle = new FakeElement() + const root = new FakeElement(['pane-manager-root']) + const sourcePane = createPane( + 1, + new FakeElement(['pane'], { + left: 0, + top: 80, + right: 100, + bottom: 180, + width: 100, + height: 100 + }) + ) + const siblingPane = createPane( + 2, + new FakeElement(['pane'], { + left: 120, + top: 80, + right: 220, + bottom: 180, + width: 100, + height: 100 + }) + ) + const panes = new Map([ + [sourcePane.id, sourcePane], + [siblingPane.id, siblingPane] + ]) + const externalTarget = { + id: 'group-1', + overlayKind: 'insertion' as const, + rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect + } + const onExternalPaneDrop = vi.fn(() => true) + const state = createDragReorderState() + + attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, { + getPanes: () => panes, + getRoot: () => root as unknown as HTMLElement, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn(), + resolveExternalDropTarget: ({ clientX, clientY }) => + clientX === 10 && clientY === 10 ? externalTarget : null, + onExternalPaneDrop + }) + + handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 })) + handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 })) + + expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBe(externalTarget) + expect(appendedElements[0].style).toMatchObject({ + left: '0px', + top: '0px', + width: '300px', + height: '32px' + }) + expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion') + + handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 })) + + expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget) + expect(detachPaneFromTree).not.toHaveBeenCalled() + expect(insertPaneNextTo).not.toHaveBeenCalled() + expect(state.currentExternalDropTarget).toBeNull() + }) + it('returns cleanup that removes handle listeners and cancels active drag capture', () => { const handle = new FakeElement() const root = new FakeElement(['pane-manager-root']) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 6b08dc4b48e..7c1803227c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,5 +1,12 @@ -import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import type { PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget, + PaneStyleOptions +} from './pane-manager-types' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -10,6 +17,7 @@ export type DragReorderState = { dragSourcePaneId: number | null dropOverlay: HTMLElement | null currentDropTarget: { paneId: number; zone: DropZone } | null + currentExternalDropTarget: PaneExternalDropTarget | null cleanupActiveDrag: ((commitDrop: boolean) => void) | null } @@ -25,6 +33,8 @@ export type DragReorderCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void + resolveExternalDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function createDragReorderState(): DragReorderState { @@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState { dragSourcePaneId: null, dropOverlay: null, currentDropTarget: null, + currentExternalDropTarget: null, cleanupActiveDrag: null } } @@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void { hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } /** True when dropping source onto target in zone would leave pane order unchanged. */ diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e4e002a5351..1ab542bf787 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -27,8 +27,26 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId + reason?: 'close' | 'detach' } +export type PaneExternalDropTarget = { + id: string + rect: DOMRect + overlayKind?: 'area' | 'insertion' +} + +export type PaneExternalDropResolver = (args: { + sourcePaneId: number + clientX: number + clientY: number +}) => PaneExternalDropTarget | null + +export type PaneExternalDropHandler = ( + sourcePaneId: number, + target: PaneExternalDropTarget +) => boolean + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void @@ -37,6 +55,8 @@ export type PaneManagerOptions = { /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 61ff5a7d42d..77ad5398890 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,7 +5,10 @@ import type { ManagedPane, ManagedPaneInternal, PaneRenderingDiagnostics, - DropZone + DropZone, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget } from './pane-manager-types' import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { @@ -40,11 +43,23 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' -import { closeManagedPane, splitManagedPane } from './pane-split-close' +import { + closeManagedPane, + detachManagedPaneForExternalMove, + splitManagedPane +} from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { splitPaneAroundMountedSubtree } from './pane-subtree-split' -export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } +export type { + PaneManagerOptions, + PaneStyleOptions, + ManagedPane, + DropZone, + PaneExternalDropTarget, + PaneExternalDropResolver, + PaneExternalDropHandler +} export class PaneManager { private root: HTMLElement @@ -158,6 +173,22 @@ export class PaneManager { }) } + detachPaneForExternalMove(paneId: number): boolean { + return detachManagedPaneForExternalMove({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) + } + getPanes(): ManagedPane[] { return Array.from(this.panes.values()).map(toPublicPane) } @@ -404,7 +435,9 @@ export class PaneManager { this.requestPaneReparentFrame(callback) }, onLayoutChanged: this.options.onLayoutChanged, - onDragActiveChange: this.options.onPaneDragActiveChange + onDragActiveChange: this.options.onPaneDragActiveChange, + resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget, + onExternalPaneDrop: this.options.onExternalPaneDrop } } diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 2d7c50a62f0..b7d8356e0a5 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void { safeFit(p) } updateMultiPaneState(args.getDragCallbacks()) - args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'close' + }) args.managerOptions.onLayoutChanged?.() } +export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean { + const pane = args.panes.get(args.paneId) + if (!pane || args.panes.size <= 1) { + return false + } + const closedLeafId = pane.leafId + args.releasePaneIdentity(args.paneId) + removePaneContainer(args, pane) + const nextActivePaneId = activateReplacementPane(args) + applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) + for (const p of args.panes.values()) { + safeFit(p) + } + updateMultiPaneState(args.getDragCallbacks()) + // Why: pane-to-tab detach tears down only this renderer pane; the PTY is + // adopted by the new tab, so TerminalPane must skip process-close cleanup. + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'detach' + }) + args.managerOptions.onLayoutChanged?.() + return true +} + function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 9e2f89bc160..fbecc991416 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => { expect(groups[0].tabOrder).toEqual([terminal.id]) }) + it('moves live PTY ownership when detaching a primary pane to a tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-detached', 'pty-survivor'], + [targetTabId]: ['pty-detached'] + }, + lastKnownRelayPtyIdByTabId: { + [sourceTabId]: 'pty-detached', + [targetTabId]: 'pty-detached' + } + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedPtyId: 'pty-detached', + sourceLayout: { + root: { type: 'leaf', leafId: 'survivor-leaf' }, + activeLeafId: 'survivor-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor']) + expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached']) + expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor') + expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached') + expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe( + 'pty-survivor' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + 'pty-detached' + ) + }) + it('stores trimmed quick command labels on terminal and unified tabs', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 0e6f30ddefc..bbd43ff773b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean { return a.every((value) => bSet.has(value)) } +function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} + export type AutomaticAgentResumeClaim = { worktreeId: string launchAgent: TuiAgent @@ -506,6 +536,12 @@ export type TerminalSlice = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void queueTabStartupCommand: ( tabId: string, startup: { @@ -2455,6 +2491,54 @@ export const createTerminalSlice: StateCreator }) }, + syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + + // Why: pane-to-tab detach moves live PTY ownership without spawning or + // exiting processes, so sync identity maps directly without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + }, + queueTabStartupCommand: (tabId, startup) => { // Why: launchToken is only meaningful for tracked launch-config reuse; // plain startup commands must not mint or carry a synthetic token. From ce680071a24a213608becb05736c1323853c3a90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:15:24 -0700 Subject: [PATCH 094/233] Place terminal URL tooltip at pane corner (#7221) --- .../pane-manager/pane-dom-creation.test.ts | 16 ++++++ .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- .../lib/pane-manager/pane-lifecycle.test.ts | 55 ++++++++++++++----- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 7f256d4304a..3165e86c04e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -57,6 +57,22 @@ afterEach(() => { }) describe('createPaneDOM link tooltips', () => { + it('anchors WebLinks hover text to the unpadded terminal window corner', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const pane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + expect(pane.linkTooltip.style.left).toBe('0px') + expect(pane.linkTooltip.style.bottom).toBe('0px') + }) + it('uses desktop modifier-click text for WebLinks hover hints', () => { const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 2e556f44400..43b628ffbd5 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -66,8 +66,10 @@ export function createPaneDOM( const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' linkTooltip.classList.add('xterm-hover') + // Why: Ghostty-style URL hover belongs to the terminal window corner; do not + // let terminal content padding shift it inward. linkTooltip.style.cssText = - 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + + 'display:none;position:absolute;bottom:0;left:0;z-index:40;' + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index cb41bca09b8..16791a485b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -444,14 +444,32 @@ describe('openTerminal — Unicode 11 ordering', () => { } } - const fakeContainer = { + const fakePaneContainer = { appendChild: vi.fn(), addEventListener: vi.fn() } as unknown as HTMLDivElement + const fakeXtermContainer = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as HTMLDivElement const fakeTooltip = {} as unknown as HTMLDivElement + const fakeTerminalElement = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + classList: { contains: vi.fn(() => false) } + } as unknown as HTMLElement + vi.stubGlobal( + 'MutationObserver', + vi.fn(function MutationObserver() { + return { observe: vi.fn(), disconnect: vi.fn() } + }) + ) const terminal = { - element: null as HTMLElement | null, + element: fakeTerminalElement, textarea: null, cols: 80, rows: 24, @@ -485,8 +503,8 @@ describe('openTerminal — Unicode 11 ordering', () => { leafId, stablePaneId: leafId, terminal, - container: fakeContainer, - xtermContainer: fakeContainer, + container: fakePaneContainer, + xtermContainer: fakeXtermContainer, linkTooltip: fakeTooltip, terminalGpuAcceleration: 'off', gpuRenderingEnabled: false, @@ -507,19 +525,26 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - openTerminal(pane) + try { + openTerminal(pane) - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) + expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() + expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) + } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + } finally { + vi.unstubAllGlobals() } - - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 490b09bb065..c6727c3c1c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -29,6 +29,7 @@ export { createPaneDOM } from './pane-dom-creation' export function openTerminal(pane: ManagedPaneInternal): void { const { terminal, + container, xtermContainer, linkTooltip, terminalTuiScrollSensitivity, @@ -41,8 +42,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) - const linkTooltipContainer = terminal.element ?? xtermContainer - linkTooltipContainer.appendChild(linkTooltip) + // Why: terminal.element sits under the padded xterm container. Pane-level + // placement keeps the hover URL on the true bottom-left window corner. + container.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) From 653f349b5f7bcbf049687e00794c612b8bf3f364 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:27 -0700 Subject: [PATCH 095/233] Fix terminal theme selection persistence (#7227) Fixes #7166. --- src/main/persistence.test.ts | 18 ++++ .../TerminalThemeSections.lifecycle.test.ts | 93 +++++++++++++++++-- .../settings/TerminalThemeSections.tsx | 59 +++++++++++- 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 419016f6f83..2e609b20558 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2828,6 +2828,24 @@ describe('Store', () => { expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) + it('round-trips selected terminal theme names across reload', async () => { + const store = await createStore() + + store.updateSettings({ + terminalThemeDark: 'One Light', + terminalThemeLight: 'GitHub Light' + }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.settings.terminalThemeDark).toBe('One Light') + expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') + + const reopened = await createStore() + expect(reopened.getSettings().terminalThemeDark).toBe('One Light') + expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> From c8e571f40c03a9547721f455d4e70bf543a11f15 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:07:17 -0700 Subject: [PATCH 096/233] fix(sidebar): prevent hovercard text selection drags (#7228) --- .../worktree-sidebar-pointer-drag-dom.test.ts | 43 +++++++++++++++++++ .../worktree-sidebar-pointer-drag-dom.ts | 10 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts new file mode 100644 index 00000000000..1da451ff024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' + +describe('worktree sidebar pointer drag DOM guards', () => { + it('allows plain row targets to start pointer drags', () => { + const row = document.createElement('div') + const card = document.createElement('div') + row.appendChild(card) + + expect(isSidebarPointerDragBlocked(card, row)).toBe(false) + }) + + it('blocks portaled hover card targets outside the row', () => { + const row = document.createElement('div') + const hoverCardContent = document.createElement('div') + document.body.append(row, hoverCardContent) + + expect(isSidebarPointerDragBlocked(hoverCardContent, row)).toBe(true) + + hoverCardContent.remove() + row.remove() + }) + + it('blocks interactive targets inside the row', () => { + const row = document.createElement('div') + const button = document.createElement('button') + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(button, row)).toBe(true) + }) + + it('blocks icon targets inside interactive row controls', () => { + const row = document.createElement('div') + const button = document.createElement('button') + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + button.appendChild(icon) + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(icon, row)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 740a32abf92..15d102ac3b5 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -17,7 +17,15 @@ const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [ ].join(',') export function isSidebarPointerDragBlocked(target: EventTarget | null, row: HTMLElement): boolean { - if (!(target instanceof HTMLElement)) { + if (!(target instanceof Node)) { + return false + } + // Why: Radix hover cards portal outside the row, but React still bubbles their + // pointer events through row handlers; text selection there must not drag rows. + if (!row.contains(target)) { + return true + } + if (!(target instanceof Element)) { return false } const blocker = target.closest(INTERACTIVE_DRAG_BLOCKER_SELECTOR) From a4f9dda71ada934809d4b97eb5f81a9ddfb3de45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:57:58 +0000 Subject: [PATCH 097/233] release: v1.4.121-rc.1 [rc-slot:2026-07-03-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60184c71afe..858ae4e230b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120", + "version": "1.4.121-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 14034678cc7973834000b79e68b9a99a32a8375d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:56:28 +0000 Subject: [PATCH 098/233] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f83dbfcb7ae..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.3m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.3m - 3.3m + 3.4m + 3.4m From 96481b1eab3363a3df1175de7cbd6ab9324ed0f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:03:28 -0700 Subject: [PATCH 099/233] Stop scheduled release cuts (#7258) --- .github/workflows/release-cut.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a7b8fe1814b..b792dd9cf47 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,10 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -182,8 +176,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1fd9547501e8fe5b05466cb0029d092278f77e34 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 15:44:28 -0400 Subject: [PATCH 100/233] fix(agent-completion): stop PermissionRequest hooks from firing false completion notifications (#6316) * test(agent-completion): cover done after waiting pause Co-authored-by: Orca * test(agent-completion): assert attention fires alongside done-after-waiting Wire dispatchAttention into the done-after-waiting regression test so it verifies both halves of the #5698 fix: the mid-turn permission pause surfaces as attention exactly once, and the later genuine 'done' still completes exactly once. Matches sibling tests that assert on both paths. Co-authored-by: Orca --------- Co-authored-by: brennanb2025 Co-authored-by: Orca Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../agent-completion-coordinator.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index ed370b84864..d69d1858c1f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1510,6 +1510,43 @@ describe('agent completion coordinator', () => { expect(dispatchAttention).not.toHaveBeenCalled() }) + it('still dispatches completion on done after an intervening waiting state in the same turn', () => { + const dispatchCompletion = vi.fn() + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchAttention, + isLive: () => true + }) + + const turn = { + prompt: 'fix the bug', + agentType: 'cursor' as const + } + + // Realistic flow: the agent pauses for a permission prompt mid-turn, resumes, + // then genuinely finishes. The intervening attention state must surface as + // attention only and must not suppress the final completion. This fails if + // 'waiting' is treated as a completion state (issue #5698). + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'Shell', + toolInput: 'pnpm test' + }) + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { const inspection = createDeferred() const dispatchCompletion = vi.fn() From 37c8ff4935c9ce70ced5b994aa12a41ba3a1a4da Mon Sep 17 00:00:00 2001 From: PP <128917720+huhuhulululu@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:44:31 -0400 Subject: [PATCH 101/233] fix: block resuming host-stored AI Vault sessions into SSH workspaces (#7130) * fix: block resuming host-stored AI Vault sessions into SSH workspaces AI Vault scans session history from this machine's disk only (host home dirs plus local WSL homes), but since #6685 resume actions accepted any SSH workspace as a target. Resuming a host-stored session there queues cd '' && --resume into a remote shell where neither the cwd nor the session file exists, so it always fails. Gate resume targets on where the session file lives: local targets stay unrestricted, SSH targets now require a WSL-stored session (the SSH-to-local-WSL setup #6685 was built for). Applies to the sidebar resume buttons, direct resume, and pane drag-and-drop, with a toast explaining the host mismatch. Catalog sync also picked up two pre-existing drifts (missing failedUnnestWorkspace fallback, two unreferenced SSH-timeout keys). * i18n: translate host-session SSH-resume block message (es/ja/ko/zh) Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../components/right-sidebar/AiVaultPanel.tsx | 2 + .../right-sidebar/AiVaultSessionRow.tsx | 3 +- .../ai-vault-session-launch-actions.ts | 43 +++++++---- .../ai-vault-session-resume.test.ts | 74 +++++++++++++++++++ .../right-sidebar/ai-vault-session-resume.ts | 12 ++- .../tab-group/AiVaultSessionDropLayer.tsx | 19 ++++- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/ai-vault-resume-target.test.ts | 54 +++++++++++++- .../src/lib/ai-vault-resume-target.ts | 24 ++++++ .../src/lib/ai-vault-session-drag.test.ts | 19 +++++ src/renderer/src/lib/ai-vault-session-drag.ts | 7 +- 15 files changed, 256 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 7905e030639..f67c7d82a3a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeState({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeActions = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index fe2e07bc338..5ea6bd9d307 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -101,12 +101,13 @@ export function VaultSessionRow({ sessionId: session.sessionId, title: session.title, command: resumeStartup.command, + sessionFilePath: session.filePath, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup] + [resumeDisabled, session, resumeStartup] ) return ( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index ac5c07645fa..dc2ed601f83 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,8 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' @@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ + sessionFilePath: session.filePath, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({ } if (targetId.status === 'unsupported') { - toast.error( - targetId.targetStatus === 'runtime' - ? translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' - ) - ) + toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus)) return } @@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return { status: 'unsupported', targetStatus } } return { status: 'ready', worktreeId: targetWorktreeId } } +function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + if (targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', + 'Resume from history is not available in runtime-hosted workspaces.' + ) + } + // Why: 'ssh' only reaches the unsupported branch when the session file lives + // on this machine, so the message explains the host mismatch, not SSH itself. + if (targetStatus === 'ssh') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a local or SSH workspace before resuming a session.' + ) +} + function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index a3cf98dd7f5..342aed8b015 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -106,10 +106,14 @@ function makeWorktreeInfo( } } +const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl' +const WSL_SESSION_FILE = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl' + describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree over the active workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -128,6 +132,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is unavailable', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -143,6 +148,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks missing worktrees even when the repo is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [], @@ -158,6 +164,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-owned session worktrees', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -173,6 +180,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-stamped worktrees even when the repo owner is runtime', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })], @@ -188,6 +196,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-owned targets even when they have no SSH connection', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -208,6 +217,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], @@ -223,6 +233,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree when the active workspace is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -245,6 +256,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is runtime-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -275,6 +287,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to an active SSH folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -288,9 +301,27 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) + it('blocks host-stored sessions when only an SSH workspace is open', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: folderWorkspaceKey('folder-1'), + worktrees: [], + repos: [], + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + it('blocks an active runtime folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -309,6 +340,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('exposes separate session-worktree and active-workspace targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -326,6 +358,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables an SSH active-workspace action when the session worktree is local', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -344,9 +377,32 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) + it('disables the SSH active-workspace action for host-stored sessions', () => { + expect( + resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + it('disables runtime-owned targets without disabling local targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -376,6 +432,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('does not expose the active workspace as a duplicate new-tab target', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('current'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -390,6 +447,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables the active folder workspace action when it is local or SSH-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -405,6 +463,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('disables the active folder workspace action when it is runtime-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -422,6 +481,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => { it('allows direct resume into an active SSH folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) }) @@ -431,9 +491,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) + it('blocks direct resume of a host-stored session into an SSH folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: HOST_SESSION_FILE, + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + status: 'unsupported', + targetStatus: 'ssh' + }) + }) + it('blocks direct resume into a runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 07091909d94..b8a428cd0c0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,7 +1,7 @@ import type { Repo, Worktree } from '../../../../shared/types' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' @@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId, targetState }) @@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: { const targetState = resolveAiVaultResumeTargetState(args) const sessionTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionFilePath: string | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return null } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index c0362c59c68..ee41d5c6e81 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { toast } from 'sonner' -import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus +} from '@/lib/ai-vault-resume-target' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, @@ -185,6 +188,20 @@ export default function AiVaultSessionDropLayer({ ) return true } + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: payload.sessionFilePath ?? null, + targetStatus + }) + ) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + ) + ) + return true + } launchAiVaultSessionInNewTab({ agent: payload.agent, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9113f064cd..5611d8d73ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { "paneColumnLabel": "New split" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ea71f964f4b..ebca92c506e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, suéltala en un espacio de trabajo local." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, abre un espacio de trabajo local." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 177d52f50b2..c02e6f709d8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "ワークツリーは利用できなくなりました。", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ab7493ba20b..ce2b4283fcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 78f552eef4a..719e1e9856b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 2bbd2f0bb32..a2c827cf99b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + canResumeAiVaultSessionOnTarget, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, isSupportedAiVaultResumeRepo, - isUnsupportedAiVaultResumeRepo + isUnsupportedAiVaultResumeRepo, + isWslStoredAiVaultSessionFile } from './ai-vault-resume-target' import { folderWorkspaceKey } from '../../../shared/workspace-scope' @@ -26,6 +28,56 @@ function makeState( } as unknown as ResumeTargetState } +describe('ai vault session storage compatibility', () => { + const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl' + const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl' + const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + const wslLocalhostSessionFile = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + + it('detects WSL-stored session files', () => { + expect(isWslStoredAiVaultSessionFile(wslSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(wslLocalhostSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(hostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(windowsHostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(null)).toBe(false) + expect(isWslStoredAiVaultSessionFile(undefined)).toBe(false) + }) + + it('allows host-stored sessions on local targets only', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: windowsHostSessionFile, + targetStatus: 'ssh' + }) + ).toBe(false) + }) + + it('allows WSL-stored sessions on local and SSH targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' }) + ).toBe(true) + }) + + it('never allows runtime or unknown targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' }) + ).toBe(false) + }) +}) + describe('ai vault resume target ownership', () => { it('classifies local, SSH, runtime, and unknown repo owners', () => { expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 0e2afff8f34..d4fae026978 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -8,6 +8,7 @@ import { import type { Repo } from '../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -36,6 +37,29 @@ export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTarget return status === 'local' || status === 'ssh' } +export function isWslStoredAiVaultSessionFile( + sessionFilePath: string | null | undefined +): boolean { + return Boolean(sessionFilePath && isWslUncPath(sessionFilePath)) +} + +export function canResumeAiVaultSessionOnTarget(args: { + sessionFilePath: string | null | undefined + targetStatus: AiVaultResumeTargetStatus +}): boolean { + if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { + return false + } + // Why: vault sessions are scanned from this machine's disk (host home dirs + // plus local WSL homes). An SSH shell can only reach the WSL-stored ones + // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist + // on a remote filesystem, so queuing a resume there is guaranteed to fail. + if (args.targetStatus === 'ssh') { + return isWslStoredAiVaultSessionFile(args.sessionFilePath) + } + return true +} + export function isUnsupportedAiVaultResumeRepo( repo: AiVaultResumeRepoOwner | null | undefined ): boolean { diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 6e1a93f763f..e51a0c78efd 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -48,6 +48,7 @@ describe('Session History session drag data', () => { sessionId: 'session-1', title: 'Fix terminal split', command: "cd '/repo' && claude --resume session-1", + sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', @@ -63,6 +64,24 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toEqual(payload) }) + it('rejects blank session file paths', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'claude', + sessionId: 'session-1', + title: 'Blank session file path', + command: 'claude --resume session-1', + sessionFilePath: ' ' + }) + ) + + expect(readAiVaultSessionDragData(transfer)).toBeNull() + }) + it('rejects malformed payloads', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 1dfede058ec..2b52359e534 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -12,6 +12,9 @@ export type AiVaultSessionDragPayload = { sessionId: string title: string command: string + // Why: drop targets must know where the session file lives (host vs local + // WSL) to reject SSH panes that cannot reach it. + sessionFilePath?: string // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -63,6 +66,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.sessionId) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && + (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -110,12 +114,13 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, env, launchConfig } = parsed + const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed return { agent, sessionId, title, command, + ...(sessionFilePath ? { sessionFilePath } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } From 492306f223e9ca1ae1e92e3a2ba45237a229927d Mon Sep 17 00:00:00 2001 From: Daniel Naxon Date: Fri, 3 Jul 2026 22:44:34 +0300 Subject: [PATCH 102/233] fix(sidebar): keep independent same-host checkouts of one repo as separate projects (#7237) * fix(sidebar): keep independent same-host checkouts of one repo as separate projects Re-regression of #5374: PR #6320 removed the per-host-surface separation added by #5375, so independent local clones of the same repo (shared origin => same github:owner/repo identity) collapse into one sidebar project again. Restore the surface-key separation in worktree-list-groups.ts, scoped by ProjectHostSetup.setupMethod: 'provisioned' (ephemeral-VM runtime copies) still nest under the project header (preserving #6320), while real user checkouts (legacy-repo / imported-existing-folder / cloned) each get their own header when duplicated on one host surface. Revert the two tests #6320 inverted and add coverage for a provisioned copy nesting alongside a same-host user checkout. * test(cmd-j): update palette grouping tests for same-host checkout split The Cmd+J palette shares getProjectGroupingForRepo (via getProjectHeaderRevealTarget) with the sidebar, so restoring same-host checkout separation also splits independent `cloned` checkouts into per-setup palette entries. Update the multi-setup test to assert the split and add coverage that a `provisioned` runtime copy still nests under one project key. * test(sidebar): cover split + provisioned-nest on one host together Guard the intersection of #5374 and #6320: when a project has two real same-host user checkouts AND a provisioned copy, the real checkouts each get their own per-setup header while the provisioned copy stays under the plain project key (never a path-scoped ::setup: header). Locks the two features' combined behavior so a future change can't silently regress either half. * refactor(sidebar): reuse shared Windows-path helper, single-pass surface count Take-over polish of #7237: - Use shared isWindowsAbsolutePathLike instead of an inline drive-letter regex, deduplicating the pattern and also bucketing UNC \\server\share paths as a Windows surface instead of falling through to default. - Collapse buildProjectGroupingIndex's two passes over projectHostSetups into one, computing each surface key once and flagging ambiguous projects from the aggregated counts. - Tighten why-comments to AGENTS.md's two-line rule. No behavior change to the fix; all 123 sidebar/palette grouping tests pass. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../components/cmd-j/palette-results.test.ts | 26 ++- .../sidebar/worktree-list-groups.test.ts | 189 +++++++++++++++++- .../sidebar/worktree-list-groups.ts | 64 +++++- 3 files changed, 264 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 820fa577de9..73049bcc149 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -357,7 +357,10 @@ describe('Cmd+J project and repo-group search', () => { ]) }) - it('uses project header keys for multi-setup projects on one host', () => { + it('splits independent same-host checkouts of one project into per-setup keys', () => { + // Why: two `cloned` checkouts share the project's remote identity but are + // distinct user clones; the palette follows the sidebar and surfaces each as + // its own jump target rather than collapsing them. See #5374. const results = searchCmdJProjectResults({ query: 'platform', projectGroups: [], @@ -369,6 +372,27 @@ describe('Cmd+J project and repo-group search', () => { ] }) + expect(results.map((result) => result.rowKey)).toEqual([ + 'project:project-1::setup:repo-1', + 'project:project-1::setup:repo-2' + ]) + }) + + it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => { + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout; it nests + // under the single project key. Mirrors the sidebar grouping. See #6320 / #5374. + const results = searchCmdJProjectResults({ + query: 'platform', + projectGroups: [], + repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-runtime')], + projects: [project('project-1', 'Platform')], + projectHostSetups: [ + setup('setup-1', 'project-1', 'local', 'repo-1'), + { ...setup('setup-2', 'project-1', 'local', 'repo-2'), setupMethod: 'provisioned' } + ] + }) + expect(results.map((result) => result.rowKey)).toEqual(['project:project-1']) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f0ef24b9ef6..1ab12104567 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -629,7 +629,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('keeps same-host checkouts of one project under the project header', () => { + it('splits same-host checkouts of one project into separate per-setup groups', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -676,15 +676,22 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(1) - expect(headers[0]).toMatchObject({ - key: 'project:github:stablyai/orca', - label: 'Orca', - count: 2 - }) + expect(headers).toHaveLength(2) + expect(headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-1', + label: 'orca' + }), + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-2', + label: 'orca-2' + }) + ]) + ) }) - it('keeps runtime copies under the project header when one host has duplicate checkouts', () => { + it('splits all project setups when one host has duplicate checkouts', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -738,8 +745,170 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual(['project:github:stablyai/orca']) - expect(headers[0]).toMatchObject({ label: 'Orca', count: 3 }) + expect(headers.map((row) => row.key)).toEqual([ + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b', + 'project:github:stablyai/orca::setup:repo-remote' + ]) + }) + + it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => { + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout into two + // headers; it nests under the project. See #6320 / #5374. + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers).toHaveLength(1) + expect(headers[0]).toMatchObject({ + key: 'project:github:stablyai/orca', + label: 'Orca', + count: 2 + }) + }) + + it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { + // Why: guards the intersection of #5374 (real same-host checkouts split) and + // #6320 (provisioned copies nest). Two legacy checkouts must each get their own + // header while a provisioned copy of the same project stays under the plain + // project header — all on one host surface, simultaneously. + const localRepoB: Repo = { + ...repo, + id: 'repo-local-b', + path: '/tmp/orca-b', + displayName: 'orca-b' + } + const localWorktreeB: Worktree = { + ...worktree, + id: 'wt-local-b', + repoId: localRepoB.id, + path: '/tmp/orca-b-feature', + displayName: 'feature-b' + } + const localSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: localRepoB.id, + repoId: localRepoB.id, + path: localRepoB.path, + displayName: localRepoB.displayName + } + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, localWorktreeB, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [localRepoB.id, localRepoB], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [localWorktreeB.id, localWorktreeB], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers.map((row) => row.key).sort()).toEqual([ + 'project:github:stablyai/orca', + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b' + ]) + // The provisioned copy nests under the plain project key with only its own + // worktree; it never gets a path-scoped `::setup:` header like the real + // checkouts do. (buildRows disambiguates its visible label to the repo name.) + expect( + headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') + ).toBe(false) + expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ + count: 1 + }) }) it('groups Windows host and WSL setups on the same runtime host', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 82a3301d82b..e7fb1749ed4 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -35,6 +35,8 @@ import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slic import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import { parseWslUncPath } from '../../../../shared/wsl-paths' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' export { branchName } @@ -152,10 +154,30 @@ type WorktreeGroupEntry = { type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map + projectIdsRequiringSetupGroups: Set } const projectGroupingIndexCache = new WeakMap() +// Why: `provisioned` setups are ephemeral recipe-created runtime copies that +// nest under the project header; every other method is a real user checkout. See #5374. +function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { + return setup.setupMethod !== 'provisioned' +} + +function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { + const wslPath = parseWslUncPath(setup.path) + if (wslPath) { + // Why: Windows host and WSL on one machine are separate execution surfaces; + // only duplicate checkouts within one surface make project grouping ambiguous. + return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}` + } + if (isWindowsAbsolutePathLike(setup.path)) { + return `${setup.projectId}::${setup.hostId}::windows-host` + } + return `${setup.projectId}::${setup.hostId}::default` +} + function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { if (!model) { return null @@ -170,9 +192,31 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin projectGroupingIndexCache.set(model, null) return null } + // Count real user checkouts per host surface (provisioned copies excluded so + // they keep nesting); more than one on a surface makes that project ambiguous. + const checkoutsByProjectSurface = new Map() + for (const setup of projectHostSetups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const key = getProjectSetupSurfaceKey(setup) + const existing = checkoutsByProjectSurface.get(key) + if (existing) { + existing.count += 1 + } else { + checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 }) + } + } + const projectIdsRequiringSetupGroups = new Set() + for (const { projectId, count } of checkoutsByProjectSurface.values()) { + if (count > 1) { + projectIdsRequiringSetupGroups.add(projectId) + } + } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + projectIdsRequiringSetupGroups } projectGroupingIndexCache.set(model, index) return index @@ -200,9 +244,21 @@ function getProjectGroupingForRepo( repo } } - // Why: recipe-created runtimes and local worktrees can be separate checkouts - // of the same Git project; the sidebar should follow project identity rather - // than path-scoped setup identity so those workspaces stay in one project. + if ( + projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) && + isDistinctUserCheckout(setup) + ) { + // Why: independent user checkouts of one project on the same host surface + // can't be safely merged, so each keeps its own sidebar entry. See #5374. + return { + key: `project:${project.id}::setup:${repoId}`, + label: repo?.displayName ?? setup.displayName, + repo, + projectId: project.id + } + } + // Why: provisioned runtime copies and non-ambiguous checkouts follow project + // identity rather than path-scoped setup identity, so they stay in one project. return { key: `project:${project.id}`, label: project.displayName, From 2153ef94566588d77efcf57fc0df56f2e34cca04 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:52:55 +0800 Subject: [PATCH 103/233] Accept any hostname (and optional :port) in manual network address entry (#7223) * Allow arbitrary hostnames in manual network address entry parseManualNetworkAddress only accepted an IPv4 address or a Tailscale MagicDNS (*.ts.net) hostname, so users behind a dynamic residential IP who rely on a DDNS domain or self-hosted relay had no way to enter it in the desktop UI short of bypassing validation via DevTools/IPC. The main process already resolves any host: resolvePairingEndpoint and parsePairingAddressOverride in src/main/runtime/runtime-rpc.ts accept an arbitrary hostname and an optional host:port. This change brings the renderer-side validation in line with what pairing already supports: any RFC 1123 hostname (a superset that still covers *.ts.net), optionally suffixed with :port (1-65535). IPv4 validation is unchanged, including still rejecting malformed dotted-numeric input instead of silently treating it as an all-digit hostname. Updates the custom-address dialog copy in NetworkInterfacePicker.tsx to describe the wider grammar. * Polish manual-address takeover: fix bare-numeric guard, sync 5 locales, lint - Require a dot in the IPv4-typo guard so a bare numeric label (`123`) validates as a legal RFC 1123 hostname, matching the code's own comment and the main-process resolver; add coverage. - Update en.json + es/ja/ko/zh placeholder/hint to the broadened grammar (translate() reads en.json before the TSX fallback, so the copy change was previously inert; the other locales described the old ts.net-only rule). - Replace indexOf(...)!==-1 with includes() to satisfy oxlint. Co-authored-by: Orca * Keep validator a strict subset of the backend resolver Review surfaced two ways the renderer could accept an address the main process handles differently: - All-numeric hosts (bare `123` and dotted `256.0.0.1`) are now rejected. The WHATWG URL host parser downstream reinterprets a numeric host as IPv4 (`123` -> `0.0.0.123`), so accepting one would validate an address the pairing resolver silently dials as a different host. - Ports with leading zeros are rejected. `^[0-9]+$` let an arbitrarily long zero-padded string past the range check and inflate the returned address beyond the hostname length cap that the old whole-string check enforced. Co-authored-by: Orca * Reject any numeric final label, not just fully-numeric hosts WHATWG URL host parsing treats a host whose last label is numeric (`foo.123`, `foo.0x1`) as an IPv4 signal, so the pairing resolver would fail to parse it and silently dial a fallback host. Widen the ambiguous-IP guard to a single last-label check that subsumes the earlier all-numeric case, keeping the renderer a strict subset of what the backend resolves correctly. Normal hostnames whose last label merely contains digits (`host2.example.com`) are unaffected. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../mobile/NetworkInterfacePicker.tsx | 13 +- 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 +- src/shared/network/manual-address.test.ts | 115 +++++++++++++++++- src/shared/network/manual-address.ts | 71 +++++++++-- 8 files changed, 187 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index a668ccbc555..af45e066a3c 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac // Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection // (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net) -// and copy. Discovered interfaces come from the OS; "Add custom address…" -// opens a dialog for an address the OS didn't surface — the only way to pair -// across networks. +// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 +// hostname — including Tailscale *.ts.net and DDNS domains — optionally +// with :port) and copy. Discovered interfaces come from the OS; "Add custom +// address…" opens a dialog for an address the OS didn't surface — the only +// way to pair across networks. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] @@ -81,11 +82,11 @@ export function NetworkInterfacePicker({ inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net or 192.168.1.50' + 'my-mac.ts.net, home.example.com, or 192.168.1.50' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a Tailscale hostname (ends in .ts.net).' + 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5611d8d73ce..32395a56253 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10216,8 +10216,8 @@ "title": "Custom network address", "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.", "label": "Address", - "placeholder": "my-mac.ts.net or 192.168.1.50", - "hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50", + "hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.", "cancel": "Cancel", "use": "Use address" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ebca92c506e..ea305d3abb7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10216,8 +10216,8 @@ "title": "Dirección de red personalizada", "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.", "label": "Dirección", - "placeholder": "my-mac.ts.net o 192.168.1.50", - "hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50", + "hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.", "cancel": "Cancelar", "use": "Usar dirección" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c02e6f709d8..fc9795ca8c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10216,8 +10216,8 @@ "title": "カスタムネットワークアドレス", "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。", "label": "アドレス", - "placeholder": "my-mac.ts.net または 192.168.1.50", - "hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。", + "placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50", + "hint": "IP アドレスまたはホスト名(Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。", "cancel": "キャンセル", "use": "アドレスを使用" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce2b4283fcc..5ab1eade73a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10216,8 +10216,8 @@ "title": "사용자 지정 네트워크 주소", "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.", "label": "주소", - "placeholder": "my-mac.ts.net 또는 192.168.1.50", - "hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.", + "placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50", + "hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.", "cancel": "취소", "use": "주소 사용" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 719e1e9856b..5f9fff50376 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10216,8 +10216,8 @@ "title": "自定义网络地址", "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。", "label": "地址", - "placeholder": "my-mac.ts.net 或 192.168.1.50", - "hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。", + "placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50", + "hint": "请输入 IP 地址或主机名(Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。", "cancel": "取消", "use": "使用地址" }, diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts index 3d1b48b43b2..fe6c1950f68 100644 --- a/src/shared/network/manual-address.test.ts +++ b/src/shared/network/manual-address.test.ts @@ -19,11 +19,18 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) }) + it('accepts an IPv4 address with a port suffix', () => { + expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({ + ok: true, + address: '192.168.1.24:8080' + }) + }) + it('rejects malformed IPv4', () => { for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { expect(parseManualNetworkAddress(bad)).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) } }) @@ -31,7 +38,7 @@ describe('parseManualNetworkAddress', () => { it('rejects leading zeros in octets', () => { expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) }) @@ -57,11 +64,107 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) }) - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { + it('rejects a MagicDNS-shaped hostname with a malformed label', () => { + // Leading/trailing hyphens are invalid in any RFC 1123 label, + // regardless of the `.ts.net` suffix. + expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false) + }) + }) + + describe('arbitrary hostnames (DDNS / self-hosted domains)', () => { + it('accepts a bare single-label hostname', () => { + expect(parseManualNetworkAddress('my-nas')).toEqual({ + ok: true, + address: 'my-nas' + }) + }) + + it('accepts a multi-label domain, e.g. a DDNS hostname', () => { + expect(parseManualNetworkAddress('home.example.com')).toEqual({ + ok: true, + address: 'home.example.com' + }) + expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true) + }) + + it('accepts a hostname with a port suffix', () => { + expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({ + ok: true, + address: 'home.example.com:8443' + }) + }) + + it('is case-insensitive', () => { + expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true) + }) + + it('rejects labels with leading or trailing hyphens', () => { + for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) { expect(parseManualNetworkAddress(bad).ok).toBe(false) } }) + + it('rejects hostnames with characters outside the RFC 1123 grammar', () => { + for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) { + expect(parseManualNetworkAddress(bad).ok).toBe(false) + } + }) + + it('rejects a host whose last label is numeric as an ambiguous IPv4', () => { + // The WHATWG URL parser downstream treats "ends in a number" as an IPv4 + // signal and would reinterpret or fail to resolve these (`123` -> + // `0.0.0.123`; `foo.123` -> no-op fallback), so accepting one here would + // validate an address the main process dials differently. + const bad = [ + '123', + '123:8080', + '1.2.3', + '1.2.3.4.5', + '256.0.0.1', + '999.999.999.999', + 'foo.123', + 'foo.123:8080', + 'foo.0x1' + ] + for (const value of bad) { + expect(parseManualNetworkAddress(value).ok).toBe(false) + } + }) + + it('still accepts a hostname whose last label merely contains digits', () => { + // Only a fully-numeric (or hex) final label is ambiguous; a label like + // `com` or `nas1` is a normal hostname. + expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true) + expect(parseManualNetworkAddress('my-nas1').ok).toBe(true) + }) + }) + + describe('port suffix', () => { + it('accepts boundary port values', () => { + expect(parseManualNetworkAddress('example.com:1').ok).toBe(true) + expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true) + }) + + it('rejects out-of-range ports', () => { + expect(parseManualNetworkAddress('example.com:0').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false) + }) + + it('rejects a non-numeric or empty port', () => { + expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:').ok).toBe(false) + }) + + it('rejects a leading-zero (non-canonical, unbounded-length) port', () => { + expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false) + expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false) + }) + + it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => { + expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false) + expect(parseManualNetworkAddress('::1').ok).toBe(false) + }) }) describe('length and whitespace', () => { @@ -77,5 +180,9 @@ describe('parseManualNetworkAddress', () => { address: '192.168.1.24' }) }) + + it('rejects whitespace inside the address', () => { + expect(parseManualNetworkAddress('my host.example.com').ok).toBe(false) + }) }) }) diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts index 9f6a8421f78..81a8a28a26b 100644 --- a/src/shared/network/manual-address.ts +++ b/src/shared/network/manual-address.ts @@ -1,36 +1,83 @@ // Why: pure shared helper so the same validation runs in renderer // today and in any future CLI/main-process caller without duplicating -// the IPv4 + Tailscale MagicDNS grammar. +// the IPv4 + hostname + optional-port grammar. const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` -// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. -// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). -const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' -const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` +const IPV4_REGEX = new RegExp(`^${IPV4}$`) + +// RFC 1123 hostname label: letters/digits/hyphens, 1-63 chars, may not +// start or end with a hyphen. This covers plain LAN hostnames, DDNS domains +// (e.g. `home.example.com`), and Tailscale MagicDNS names (`*.ts.net`) as a +// special case of the same grammar — no separate MagicDNS-only pattern needed. +const HOSTNAME_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' +const HOSTNAME = `(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}` +const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i') const HOSTNAME_MAX_LENGTH = 253 -const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' +const MIN_PORT = 1 +const MAX_PORT = 65535 +const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix' export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string } export function parseManualNetworkAddress(input: string): ParseManualAddressResult { const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { + if (trimmed === '') { return { ok: false, error: ERROR_MESSAGE } } if (/\s/.test(trimmed)) { return { ok: false, error: ERROR_MESSAGE } } - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } + const { host, port } = splitHostPort(trimmed) + if (host === '' || host.length > HOSTNAME_MAX_LENGTH) { + return { ok: false, error: ERROR_MESSAGE } + } + if (port !== null && !isValidPort(port)) { + return { ok: false, error: ERROR_MESSAGE } } - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { + if (IPV4_REGEX.test(host)) { + return { ok: true, address: trimmed } + } + // Why: reject a host whose last label is numeric — a bare `123`, a dotted + // `256.0.0.1`, or a hostname with a numeric final label (`foo.123`, `foo.0x1`). + // The WHATWG URL host parser that `resolvePairingEndpoint` feeds this into + // treats "ends in a number" as an IPv4 signal and tries to parse the whole + // host as IPv4. A real IPv4 was already accepted above, so anything reaching + // here would fail that parse and the main process would silently dial a + // different host — so treat these as mistyped IPs, not hostnames. + const lastLabel = host.split('.').at(-1) ?? '' + if (/^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel)) { + return { ok: false, error: ERROR_MESSAGE } + } + if (HOSTNAME_REGEX.test(host)) { return { ok: true, address: trimmed } } return { ok: false, error: ERROR_MESSAGE } } + +// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts +// so the UI only accepts what the main process's pairing endpoint resolution +// can already handle. IPv6 stays out of scope (same as that function), so a +// second colon is left in `host` and fails the grammar checks below instead +// of being misparsed as a port. +function splitHostPort(value: string): { host: string; port: string | null } { + const firstColon = value.indexOf(':') + if (firstColon === -1 || value.includes(':', firstColon + 1)) { + return { host: value, port: null } + } + return { host: value.slice(0, firstColon), port: value.slice(firstColon + 1) } +} + +function isValidPort(port: string): boolean { + // Reject leading zeros: a canonical port has none, and allowing them lets an + // arbitrarily long zero-padded string (`00…08080`) slip past the range check + // and inflate the returned address past the hostname length cap. + if (!/^[1-9][0-9]*$/.test(port)) { + return false + } + const value = Number(port) + return value >= MIN_PORT && value <= MAX_PORT +} From 9aeaf0728f6e6173b969e473c63a785b37dda182 Mon Sep 17 00:00:00 2001 From: yukei_lee Date: Sat, 4 Jul 2026 03:56:38 +0800 Subject: [PATCH 104/233] Fix git worktree list fallback for older Git (#7235) * fix git worktree list fallback for localized errors * Polish worktree -z fallback: clarify comment, add localized + negative tests Co-authored-by: Orca * Extend -z fallback to relay/SSH worktree path; tighten comments; add relay tests Co-authored-by: Orca * Isolate regex-fallback path in relay test Co-authored-by: Orca --------- Co-authored-by: yuqili03 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- src/main/git/worktree.test.ts | 98 +++++++++++++++++++++++++++++ src/main/git/worktree.ts | 10 ++- src/relay/git-handler-utils.test.ts | 30 +++++++++ src/relay/git-handler-utils.ts | 16 ++++- 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 44679c14ed3..69ce1ed46c5 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -237,6 +237,12 @@ bare }) describe('listWorktreeGraph', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockClear() + }) + it('returns the worktree graph without sparse-checkout annotation probes', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `worktree /repo @@ -272,6 +278,98 @@ branch refs/heads/feature/test }) }) + it('falls back when older Git rejects worktree list -z with usage exit code', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-main-2 +HEAD def456 +branch refs/heads/main-2 +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-main-2', + head: 'def456', + branch: 'refs/heads/main-2', + isBare: false, + isMainWorktree: false + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => { + // Git under a non-English locale translates the usage text, so stderr + // matching alone misses it; the 129 exit code must still trigger fallback. + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('does not retry without -z for non-usage Git failures', async () => { + // A fatal error (exit 128) is not an unsupported-flag signal, so the -z + // command must not be silently re-run without it. + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + ) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }] + ]) + }) + it('returns an empty graph for paths Git reports as non-repositories', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository')) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ed830d06d70..1dce3fff2ae 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean { } function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage + // exit 129 signals the rejection in any locale; stderr match is a fallback. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } function isUnsupportedRevParsePathFormatError(error: unknown): boolean { diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 9dca8777834..03d0fe14aed 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,35 @@ import { describe, expect, it } from 'vitest' import { parseStatusOutput } from './git-status-output-parser' +import { isUnsupportedWorktreeListZError } from './git-handler-utils' + +describe('isUnsupportedWorktreeListZError', () => { + it('detects an unknown-switch usage error from stderr when the exit code is absent', () => { + // Isolates the regex fallback: no numeric code, so only the stderr text + // (a runner that dropped the exit code) can classify the rejection. + const error = Object.assign(new Error('worktree list -z'), { + stderr: "error: unknown switch `z'\nusage: git worktree list []\n" + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('detects a localized (non-English) usage error via exit code 129', () => { + // The SSH remote may run under a non-English locale where the stderr text is + // translated; the numeric exit code must still classify the -z rejection. + const error = Object.assign(new Error('worktree list -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => { + const error = Object.assign(new Error('fatal'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(false) + }) +}) describe('parseStatusOutput', () => { it('parses upstream ahead/behind from porcelain v2 branch headers', () => { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 5ddd15fd896..f0a395da27b 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -147,10 +147,20 @@ function getErrorText(error: unknown): string { return String(error) } +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + export function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit + // 129 signals the rejection in any locale; key for SSH remotes on old Git. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } export function parseWorktreeList( From 273fee79fa3b37e1938e5f3a0ec1ffcad0b61184 Mon Sep 17 00:00:00 2001 From: Avichal Dwivedi <150160561+AvichalDwivedi2205@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:36:43 +0530 Subject: [PATCH 105/233] Fix CDP Page.printToPDF support (#7044) * Fix CDP printToPDF proxy * Harden CDP PDF stream handling * Fix PDF margin units * refactor(cdp): extract printToPDF param mapping + stream store into cdp-print-to-pdf Move the CDP Page.printToPDF option translation and the ReturnAsStream handle store out of cdp-ws-proxy into a focused cdp-print-to-pdf module, mirroring the existing cdp-screenshot sibling. Keeps the over-budget proxy from growing further and gives the PDF logic direct unit coverage. No behavior change: all existing proxy tests pass unmodified. Co-authored-by: Orca * fix(cdp): drop print result if client disconnected during printToPDF A ReturnAsStream print that resolved after the requesting client disconnected (or was replaced) registered its buffer in pdfStreams after the disconnect's clear() already ran, orphaning a potentially large PDF until the 5-minute TTL. Re-check isActiveClient after the await, mirroring navigateWithLifecycle/reloadWithLifecycle. Co-authored-by: Orca --------- Co-authored-by: Neil Co-authored-by: Orca --- src/main/browser/cdp-print-to-pdf.test.ts | 172 +++++++++++++++++ src/main/browser/cdp-print-to-pdf.ts | 177 ++++++++++++++++++ src/main/browser/cdp-ws-proxy.test.ts | 213 ++++++++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 79 ++++++++ 4 files changed, 641 insertions(+) create mode 100644 src/main/browser/cdp-print-to-pdf.test.ts create mode 100644 src/main/browser/cdp-print-to-pdf.ts diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts new file mode 100644 index 00000000000..e6f2163b5dd --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' + +const DEFAULT_MARGIN_INCHES = 1 / 2.54 +const TTL_MS = 5 * 60 * 1000 + +describe('buildPrintToPdfOptions', () => { + it('returns empty options for empty params', () => { + expect(buildPrintToPdfOptions({})).toEqual({}) + }) + + it('maps every well-formed field', () => { + expect( + buildPrintToPdfOptions({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + ).toEqual({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + pageSize: { width: 8.5, height: 11 }, + margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 }, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + }) + + it('defaults omitted margin sides to 1cm when any side is set', () => { + expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({ + marginType: 'custom', + top: 0.25, + bottom: DEFAULT_MARGIN_INCHES, + left: DEFAULT_MARGIN_INCHES, + right: DEFAULT_MARGIN_INCHES + }) + }) + + it('drops non-finite and out-of-range numeric options', () => { + const options = buildPrintToPdfOptions({ + scale: 0, + paperWidth: Number.POSITIVE_INFINITY, + paperHeight: 11, + marginTop: Number.NaN + }) + expect(options.scale).toBeUndefined() + expect(options.pageSize).toBeUndefined() + expect(options.margins).toBeUndefined() + }) + + it('requires both paper dimensions for pageSize', () => { + expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined() + }) + + it('ignores wrong-typed options', () => { + expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({}) + }) +}) + +describe('CdpPdfStreamStore', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('claims only its own handles', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('pdf')) + + expect(store.ownsHandle({ handle })).toBe(true) + expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/) + expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false) + expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false) + expect(store.ownsHandle({})).toBe(false) + }) + + it('mints distinct handles per stream', () => { + const store = new CdpPdfStreamStore() + expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b'))) + }) + + it('reads sequential chunks and reports eof', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, size: 2 })).toEqual({ + data: Buffer.from('ab').toString('base64'), + eof: false + }) + expect(store.read({ handle })).toEqual({ + data: Buffer.from('cdef').toString('base64'), + eof: true + }) + }) + + it('honors an explicit read offset', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, offset: 4 })).toEqual({ + data: Buffer.from('ef').toString('base64'), + eof: true + }) + }) + + it('returns an empty eof chunk when reading past the end', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) + }) + + it('returns null for unknown handles', () => { + const store = new CdpPdfStreamStore() + expect(store.read({ handle: 'nope' })).toBeNull() + expect(store.read({})).toBeNull() + }) + + it('drops a stream on close', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.close({ handle }) + expect(store.read({ handle })).toBeNull() + }) + + it('drops all streams on clear', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.clear() + expect(store.read({ handle })).toBeNull() + }) + + it('evicts an abandoned stream after the TTL', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + vi.advanceTimersByTime(TTL_MS + 1) + expect(store.read({ handle })).toBeNull() + }) + + it('refreshes the TTL on each read', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + }) +}) diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts new file mode 100644 index 00000000000..643013377d8 --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto' +import type { PrintToPDFOptions } from 'electron' + +const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54 +const PDF_STREAM_CHUNK_BYTES = 1024 * 1024 +const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-' +const PDF_STREAM_TTL_MS = 5 * 60 * 1000 + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +/** + * Translate CDP `Page.printToPDF` params into Electron `printToPDF` options. + * Only well-formed values are forwarded so a malformed param can never smuggle + * NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm. + */ +export function buildPrintToPdfOptions(params: Record): PrintToPDFOptions { + const options: PrintToPDFOptions = {} + + if (typeof params.landscape === 'boolean') { + options.landscape = params.landscape + } + if (typeof params.displayHeaderFooter === 'boolean') { + options.displayHeaderFooter = params.displayHeaderFooter + } + if (typeof params.printBackground === 'boolean') { + options.printBackground = params.printBackground + } + if (typeof params.preferCSSPageSize === 'boolean') { + options.preferCSSPageSize = params.preferCSSPageSize + } + if (typeof params.generateTaggedPDF === 'boolean') { + options.generateTaggedPDF = params.generateTaggedPDF + } + if (typeof params.generateDocumentOutline === 'boolean') { + options.generateDocumentOutline = params.generateDocumentOutline + } + + const scale = finiteNumber(params.scale) + if (scale !== null && scale > 0) { + options.scale = scale + } + + const paperWidth = finiteNumber(params.paperWidth) + const paperHeight = finiteNumber(params.paperHeight) + if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) { + options.pageSize = { width: paperWidth, height: paperHeight } + } + + const marginTop = finiteNumber(params.marginTop) + const marginBottom = finiteNumber(params.marginBottom) + const marginLeft = finiteNumber(params.marginLeft) + const marginRight = finiteNumber(params.marginRight) + if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) { + // CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm. + options.margins = { + marginType: 'custom', + top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES, + bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES, + left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES, + right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES + } + } + + if (typeof params.pageRanges === 'string') { + options.pageRanges = params.pageRanges + } + if (typeof params.headerTemplate === 'string') { + options.headerTemplate = params.headerTemplate + } + if (typeof params.footerTemplate === 'string') { + options.footerTemplate = params.footerTemplate + } + + return options +} + +type PdfStream = { + data: Buffer + offset: number + cleanupTimer: ReturnType +} + +export type PdfStreamChunk = { + data: string + eof: boolean +} + +/** + * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and + * serves them back through `IO.read` / `IO.close`, the path Playwright's + * `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client + * cannot forge one or collide with a real Chromium IO stream, and each buffer is + * evicted after a TTL so an abandoned stream can never leak memory. + */ +export class CdpPdfStreamStore { + private readonly streams = new Map() + private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-` + private nextId = 0 + + /** True when `params.handle` names one of this store's streams. */ + ownsHandle(params: Record): boolean { + return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix) + } + + create(data: Buffer): string { + const handle = `${this.handlePrefix}${++this.nextId}` + this.streams.set(handle, { + data, + offset: 0, + cleanupTimer: this.scheduleCleanup(handle) + }) + return handle + } + + /** Read the next chunk, or `null` if the handle is unknown/expired. */ + read(params: Record): PdfStreamChunk | null { + const handle = typeof params.handle === 'string' ? params.handle : '' + const stream = this.streams.get(handle) + if (!stream) { + return null + } + this.refreshCleanup(handle, stream) + + const offset = finiteNumber(params.offset) + if (offset !== null) { + stream.offset = Math.max(0, Math.floor(offset)) + } + const requestedSize = finiteNumber(params.size) + const size = + requestedSize !== null && requestedSize > 0 + ? Math.floor(requestedSize) + : PDF_STREAM_CHUNK_BYTES + const start = Math.min(stream.offset, stream.data.length) + const end = Math.min(start + size, stream.data.length) + const chunk = stream.data.subarray(start, end) + stream.offset = end + + return { data: chunk.toString('base64'), eof: end >= stream.data.length } + } + + close(params: Record): void { + const handle = typeof params.handle === 'string' ? params.handle : '' + this.delete(handle) + } + + clear(): void { + for (const stream of this.streams.values()) { + clearTimeout(stream.cleanupTimer) + } + this.streams.clear() + } + + private scheduleCleanup(handle: string): ReturnType { + const cleanupTimer = setTimeout(() => { + this.delete(handle) + }, PDF_STREAM_TTL_MS) + const maybeNodeTimer = cleanupTimer as { unref?: () => void } + maybeNodeTimer.unref?.() + return cleanupTimer + } + + private refreshCleanup(handle: string, stream: PdfStream): void { + clearTimeout(stream.cleanupTimer) + stream.cleanupTimer = this.scheduleCleanup(handle) + } + + private delete(handle: string): void { + const stream = this.streams.get(handle) + if (!stream) { + return + } + clearTimeout(stream.cleanupTimer) + this.streams.delete(handle) + } +} diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 11fbcd772cf..15976e9c729 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -43,6 +43,7 @@ function createMockWebContents() { debugger: debuggerObj, isDestroyed: () => destroyed, focus: vi.fn(), + printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), reload: vi.fn(), reloadIgnoringCache: vi.fn(), getTitle: vi.fn(() => 'Example'), @@ -109,6 +110,15 @@ describe('CdpWsProxy', () => { return calls.map((call) => call[0]) } + function expectPdfStreamHandle(response: Record): string { + const result = response.result as Record + expect(result.data).toBe('') + expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/)) + return result.stream as string + } + + const defaultPdfMarginInches = 1 / 2.54 + it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(proxy.getPort()).toBeGreaterThan(0) @@ -493,6 +503,209 @@ describe('CdpWsProxy', () => { client.close() }) + it('prints PDF data through native webContents printToPDF', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 19, + method: 'Page.printToPDF', + params: { + landscape: true, + printBackground: true, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + preferCSSPageSize: true + } + }) + + expect(response).toEqual({ + id: 19, + result: { data: Buffer.from('%PDF-test').toString('base64') } + }) + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + landscape: true, + printBackground: true, + pageSize: { width: 8.5, height: 11 }, + margins: { + marginType: 'custom', + top: 0.25, + bottom: 0.5, + left: 0.75, + right: 1 + }, + pageRanges: '1-2', + preferCSSPageSize: true + }) + expect(getSendCommandMethods()).not.toContain('Page.printToPDF') + client.close() + }) + + it('keeps default PDF margins for omitted sides', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 20, + method: 'Page.printToPDF', + params: { + marginTop: 0.25 + } + }) + + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + margins: { + marginType: 'custom', + top: 0.25, + bottom: defaultPdfMarginInches, + left: defaultPdfMarginInches, + right: defaultPdfMarginInches + } + }) + client.close() + }) + + it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect() + + const printResponse = await sendAndReceive(client, { + id: 21, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + const firstRead = await sendAndReceive(client, { + id: 22, + method: 'IO.read', + params: { handle, size: 2 } + }) + const secondRead = await sendAndReceive(client, { + id: 23, + method: 'IO.read', + params: { handle } + }) + const closeResponse = await sendAndReceive(client, { + id: 24, + method: 'IO.close', + params: { handle } + }) + const readAfterClose = await sendAndReceive(client, { + id: 25, + method: 'IO.read', + params: { handle } + }) + + expect(printResponse.id).toBe(21) + expect(firstRead).toEqual({ + id: 22, + result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false } + }) + expect(secondRead).toEqual({ + id: 23, + result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true } + }) + expect(closeResponse).toEqual({ id: 24, result: {} }) + expect(readAfterClose).toEqual({ + id: 25, + error: { code: -32000, message: 'Invalid stream handle' } + }) + client.close() + }) + + it('clears streamed PDF data when the client disconnects', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect() + + const printResponse = await sendAndReceive(client, { + id: 26, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + expect(printResponse.id).toBe(26) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + const nextClient = await connect() + const staleRead = await sendAndReceive(nextClient, { + id: 27, + method: 'IO.read', + params: { handle } + }) + + expect(staleRead).toEqual({ + id: 27, + error: { code: -32000, message: 'Invalid stream handle' } + }) + nextClient.close() + }) + + it('does not register a PDF stream when the client disconnects mid-print', async () => { + let resolvePrint: (buf: Buffer) => void = () => {} + mock.webContents.printToPDF.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePrint = resolve + }) + ) + const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams + const createSpy = vi.spyOn(store, 'create') + + const client = await connect() + client.send( + JSON.stringify({ + id: 30, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Print resolves only after the client is gone: no stream must be created. + resolvePrint(Buffer.from('%PDF-late')) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(createSpy).not.toHaveBeenCalled() + createSpy.mockRestore() + }) + + it('forwards non-PDF IO streams to the debugger', async () => { + mock.webContents.debugger.sendCommand + .mockResolvedValueOnce({ data: 'trace-data', eof: false }) + .mockResolvedValueOnce({}) + const client = await connect() + + const readResponse = await sendAndReceive(client, { + id: 28, + method: 'IO.read', + params: { handle: 'trace-stream', size: 64 } + }) + const closeResponse = await sendAndReceive(client, { + id: 29, + method: 'IO.close', + params: { handle: 'trace-stream' } + }) + + expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } }) + expect(closeResponse).toEqual({ id: 29, result: {} }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', { + handle: 'trace-stream', + size: 64 + }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', { + handle: 'trace-stream' + }) + client.close() + }) + // ── Page.frameNavigated interception ── // ── Cleanup ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index ec8de5be176..6c3a401d5b7 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' import type { WebContents } from 'electron' import { captureScreenshot } from './cdp-screenshot' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' @@ -20,6 +21,7 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -51,6 +53,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { + this.pdfStreams.clear() this.client = null } } @@ -101,6 +104,7 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null + this.pdfStreams.clear() client?.close() } @@ -290,6 +294,30 @@ export class CdpWsProxy { this.handleScreenshot(client, clientId, msg.params) return } + // Why: CDP Page.printToPDF is not available for Electron webview guests. + // Electron's native printToPDF path is the reliable equivalent. + if (msg.method === 'Page.printToPDF') { + void this.handlePrintToPdf(client, clientId, msg.params ?? {}) + return + } + if (msg.method === 'IO.read') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamRead(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } + if (msg.method === 'IO.close') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamClose(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } // Why: Input.insertText can still require native focus in Electron webviews. // Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait // polling and read-only JS probes use those methods heavily, and focusing on @@ -439,6 +467,57 @@ export class CdpWsProxy { } } + private async handlePrintToPdf( + client: WebSocket, + clientId: number, + params: Record + ): Promise { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) + // Why: printToPDF can resolve after the client disconnected (or was + // replaced). Bail before registering a stream so its buffer isn't + // orphaned in pdfStreams past the disconnect's clear() until the TTL. + if (!this.isActiveClient(client)) { + return + } + const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) + if (params.transferMode === 'ReturnAsStream') { + const handle = this.pdfStreams.create(buffer) + this.sendResult(clientId, { data: '', stream: handle }, client) + return + } + this.sendResult(clientId, { data: buffer.toString('base64') }, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private handleStreamRead( + client: WebSocket, + clientId: number, + params: Record + ): void { + const chunk = this.pdfStreams.read(params) + if (!chunk) { + this.sendError(clientId, 'Invalid stream handle', client) + return + } + this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client) + } + + private handleStreamClose( + client: WebSocket, + clientId: number, + params: Record + ): void { + this.pdfStreams.close(params) + this.sendResult(clientId, {}, client) + } + private handleScreenshot( client: WebSocket, clientId: number, From 02dfb4b334c5090a89f3e19b438493893d3ce327 Mon Sep 17 00:00:00 2001 From: Parker Rex Date: Fri, 3 Jul 2026 16:12:14 -0400 Subject: [PATCH 106/233] Fix Discord icon and CreateWorktree label spacing (#6505) * Fix landing create worktree label * Update Discord help menu icon --- src/renderer/src/components/Landing.tsx | 4 +- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 67 ++++++++++++++++++- .../sidebar/SidebarSettingsHelpMenu.tsx | 4 +- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 05d609f6d35..9e79671f25a 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element { onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} > - {translate('auto.components.Landing.76a95f7f47', 'Create')} - {createTargetLabel} + {translate('auto.components.Landing.76a95f7f47', 'Create')}{' '} + {createTargetLabel.toLowerCase()}
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx index 8fba33ed07d..6f6ea7809ef 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx @@ -1,6 +1,9 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' const mocks = vi.hoisted(() => ({ @@ -20,6 +23,7 @@ const mocks = vi.hoisted(() => ({ })) let updateStatus = { state: 'idle' } as const +const roots: Root[] = [] vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => @@ -96,9 +100,48 @@ vi.mock('./SidebarFeedbackDialog', () => ({ SidebarFeedbackDialog: () =>
})) +function installWindowApi(): void { + Object.assign(window, { + api: { + app: { + restart: mocks.appRestart + }, + shell: { + openUrl: mocks.shellOpenUrl + }, + updater: { + check: mocks.updaterCheck + } + } + }) +} + +async function renderMenu(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render() + }) + + return container +} + +function findMenuItem(container: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll('[data-testid="menu-item"]') + ).find((element) => element.textContent?.includes(label)) + expect(button).toBeDefined() + return button as HTMLButtonElement +} + describe('SidebarSettingsHelpMenu', () => { beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + installWindowApi() mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false }) updateStatus = { state: 'idle' } mocks.setupProgress = { @@ -109,6 +152,13 @@ describe('SidebarSettingsHelpMenu', () => { } }) + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + }) + it('renders the help button with correct aria-label', () => { const html = renderToStaticMarkup() expect(html).toContain('Help') @@ -177,6 +227,19 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Discord link', () => { const html = renderToStaticMarkup() expect(html).toContain('Discord') + expect(html).toContain('viewBox="0 0 20 20"') + expect(html).toContain('M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335') + }) + + it('opens Discord invite through the shell bridge', async () => { + const container = await renderMenu() + const discordButton = findMenuItem(container, 'Discord') + + await act(async () => { + discordButton.click() + }) + + expect(mocks.shellOpenUrl).toHaveBeenCalledWith('https://discord.gg/fzjDKHxv8Q') }) it('renders X link', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index f5bf3250c6f..d74fcfb11aa 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -46,8 +46,8 @@ function openExternalUrl(url: string): void { function DiscordIcon(): React.JSX.Element { return ( -
+ {noRepositoriesSelected ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f', + 'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.' + )} +

+ ) : null}
+ {noRepositoriesSelected ? ( + + ) : null} + ) : null} + +
+
+ ) : null}
) })} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts index 9715002a997..1153604715e 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts @@ -6,11 +6,16 @@ import type { RepoSourceControlAiOverrides, SourceControlAiSettings } from '../../../../shared/source-control-ai-types' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { createRepoAiDraftState, dropRepoLegacyInstructionForAction, resolveRepoAiDraftState } from './RepositorySourceControlAiSection' +import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows' import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' describe('RepositorySourceControlAiSection draft state', () => { @@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => { }) }) +describe('RepositorySourceControlAiActionRows', () => { + it('renders save controls on dirty repository action recipes', () => { + const actionDirtyById = Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure']) + ) as Record + const source = { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as SourceControlAiSettings + + const markup = renderToStaticMarkup( + React.createElement(RepositorySourceControlAiActionRows, { + repoId: 'repo-1', + repoAi: { + actionOverrides: { + fixCommitFailure: { + agentId: null, + commandInputTemplate: '{basePrompt}\n\nrepo' + } + } + }, + source, + defaultTuiAgent: 'codex', + isSaving: false, + actionDirtyById, + onActionModeChange: () => {}, + onActionAgentChange: () => {}, + onActionTemplateChange: () => {}, + onActionAgentArgsChange: () => {}, + onAppendVariable: () => {}, + onActionDiscard: () => {}, + onActionSave: () => {} + }) + ) + + expect(markup).toContain('Commit failure fixes') + expect(markup).toContain('Unsaved changes') + expect(markup).toContain('Discard') + expect(markup).toContain('Save') + expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure') + }) +}) + describe('dropRepoLegacyInstructionForAction', () => { it('prevents legacy text instructions from remigrating after an action override is cleared', () => { const next = dropRepoLegacyInstructionForAction( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 6f5f771379c..5568912edd2 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -1,16 +1,18 @@ import { useMemo, useState } from 'react' import type React from 'react' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { Repo, TuiAgent } from '../../../../shared/types' import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' import { normalizeRepoSourceControlAiOverrides, - normalizeSourceControlAiSettings, - resolveSourceControlActionRecipe + normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai' import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' -import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { Button } from '../ui/button' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults' import { + buildActionScopedRepoAiSave, createRepoAiDraftState, dropRepoLegacyInstructionForAction, hasOwnActionOverride, normalizeRepoAiDraft, + readCompleteRecipeForDraft, resolveRepoAiDraftState, + serializeActionOverride, serializeRepoAiDraft, + setActionOverride, type RepoAiDraftState } from './repository-source-control-ai-draft' import { ACTION_MODE_INHERIT, DEFAULT_AGENT_VALUE, - completeRepoActionRecipe, readInheritedCommandTemplate } from './repository-source-control-ai-labels' import { getSettingOwnershipSummary } from './setting-ownership' @@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = { type HostedReviewDefaultKey = keyof NonNullable -function readCompleteRecipeForDraft( - current: RepoSourceControlAiOverrides, - settings: GlobalSettings | null, - actionId: SourceControlActionId -): NonNullable< - NonNullable[SourceControlActionId] -> { - const recipe = resolveSourceControlActionRecipe({ - settings, - repo: { sourceControlAi: current }, - actionId - }) - return completeRepoActionRecipe(recipe, actionId) -} - -function setActionOverride( - current: RepoSourceControlAiOverrides, - actionId: SourceControlActionId, - recipe: NonNullable< - NonNullable[SourceControlActionId] - > -): RepoSourceControlAiOverrides { - return dropRepoLegacyInstructionForAction( - { - ...current, - actionOverrides: { - ...current.actionOverrides, - [actionId]: recipe - } - }, - actionId - ) -} - export function RepositorySourceControlAiSection({ repo, updateRepo @@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({ const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi]) const isDirty = resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized + const actionDirtyById = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + serializeActionOverride(repoAi, actionId) !== + serializeActionOverride(persistedRepoAi, actionId) + ]) + ) as Record, + [persistedRepoAi, repoAi] + ) const updateDraftRepoAi = ( update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides @@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({ setSaveError(null) } + const discardActionDraft = (actionId: SourceControlActionId): void => { + updateDraftRepoAi((current) => { + const nextActionOverrides = { ...current.actionOverrides } + if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) { + nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return dropRepoLegacyInstructionForAction( + { ...current, actionOverrides: nextActionOverrides }, + actionId + ) + }) + } + + const saveActionDraft = async (actionId: SourceControlActionId): Promise => { + if (isSaving || !actionDirtyById[actionId]) { + return + } + // Why: a per-action Save persists only that action's override (its Discard is + // action-scoped, so its Save must be too) — never other rows' pending edits. + const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId) + const repoUpdate = toSourceControlAiRepoUpdate(next) + setIsSaving(true) + setSaveError(null) + try { + const result = await updateRepo(repo.id, repoUpdate) + if (!mountedRef.current) { + return + } + if (result === false) { + setSaveError('Failed to save Source Control AI settings.') + } + } catch { + if (mountedRef.current) { + setSaveError('Failed to save Source Control AI settings.') + } + } finally { + if (mountedRef.current) { + setIsSaving(false) + } + } + } + const updateEnablement = (value: boolean | undefined): void => { updateDraftRepoAi((current) => ({ ...current, enabled: value })) } @@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({ onChange={updateCustomCommand} /> void saveActionDraft(actionId)} /> void onTemplateChange: (actionId: SourceControlActionId, value: string) => void onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void @@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({ baseValue, defaultTuiAgent, isSavingTemplate, + repoOverrideNote, onAgentChange, onTemplateChange, onAgentArgsChange, @@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({ />

Z-%z0d!Y%55-k{&pyjb7>(oG?j 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 Z`+t-TKbBqUhHwA?002ovPDHLkV1i0z#=QUl literal 62128 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 Date: Fri, 3 Jul 2026 01:32:22 -0700 Subject: [PATCH 092/233] Restore desktop terminal URL modifier clicks Restore desktop terminal URL and OSC link activation to require Cmd/Ctrl while preserving mobile tap routing. --- .../terminal-pane/terminal-link-activation.ts | 6 ++ .../terminal-link-handlers.test.ts | 80 +++++++++++++++++-- .../terminal-pane/terminal-link-handlers.ts | 12 +-- .../terminal-pane/terminal-link-open-hints.ts | 4 +- .../terminal-osc-link-routing.ts | 26 +++--- .../terminal-url-link-hit-testing.ts | 16 ++-- .../use-terminal-pane-lifecycle.ts | 12 ++- .../pane-manager/pane-dom-creation.test.ts | 44 +++++++++- .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- 9 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts new file mode 100644 index 00000000000..e85da903224 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -0,0 +1,6 @@ +export function isTerminalLinkActivation( + event: Pick | undefined +): boolean { + const isMac = navigator.userAgent.includes('Mac') + return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5cbc01a0903..c75dd3943e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -7,6 +7,7 @@ import { createFilePathLinkProvider, getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, + getTerminalUrlOpenHint, installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, @@ -156,12 +157,28 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('routes http links on ordinary click', () => { + it('ignores http links without the platform modifier on desktop', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: true } const preventDefault = vi.fn() - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + expect( + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + ).toBe(false) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('routes http links with the platform modifier on desktop', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + expect( + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, preventDefault }, deps) + ).toBe(true) expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { @@ -438,18 +455,38 @@ describe('handleOscLink', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') setPlatform('Windows') expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe( 'Ctrl+click to open or Shift+Ctrl+click for default browser' ) + expect(getTerminalUrlOpenHint()).toBe( + 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ) }) - it('opens local file URL links in Orca on ordinary click', async () => { + it('ignores local file URL links without the platform modifier on desktop', async () => { setPlatform('Windows') - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps)).toBe( + false + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + }) + + it('opens local file URL links in Orca with the platform modifier on desktop', async () => { + setPlatform('Windows') + + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps)).toBe( + true + ) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1708,7 +1745,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { + it('ignores regular URLs from a direct ordinary-click fallback on desktop', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1732,6 +1769,39 @@ describe('createFilePathLinkProvider range bounds', () => { stopPropagation } as unknown as MouseEvent) + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) + }) + + it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [ + makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82') + ] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseUp({ + button: 0, + metaKey: true, + ctrlKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 230, + clientY: 25, + preventDefault, + stopPropagation + } as unknown as MouseEvent) + expect(openUrlMock).toHaveBeenCalledWith( 'https://github.com/stablyai/orca-marketing-website/pull/82' ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 0e111027e37..462dbdeeedc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -33,14 +33,15 @@ import { getTerminalOrcaFileOpenHint, getTerminalWorktreePathOpenHint, getTerminalFileOpenHint, - getTerminalUrlOpenHint, - isMacPlatform + getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } +export { isTerminalLinkActivation } from './terminal-link-activation' export type LinkHandlerDeps = { worktreeId: string @@ -315,10 +316,3 @@ export function installFilePathLinkClickFallback( } } } - -export function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = isMacPlatform() - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 246333c7cbd..0d51669a6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 9d588b7857a..2b043d648cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -3,6 +3,7 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester @@ -11,16 +12,16 @@ import { type TerminalLinkEvent = Pick & Partial> -function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { +function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { return false } if ('button' in event && event.button !== undefined && event.button !== 0) { return false } - // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports - // it as button 0; ordinary OSC links should not steal that secondary action. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop xterm links must not open while the user is just placing the + // cursor or selecting text. Mobile URL taps use a separate WebView path. + return isTerminalLinkActivation(event) } export function handleOscLink( @@ -30,9 +31,9 @@ export function handleOscLink( Partial> & { requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } -): void { - if (!isPrimaryOscLinkActivation(event)) { - return +): boolean { + if (!isDesktopOscLinkActivation(event)) { + return false } // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress // default anchor navigation so link-routing settings can choose the target. @@ -67,15 +68,14 @@ export function handleOscLink( ) { // Why: `new URL("C:\\path\\file.ts")` succeeds with protocol `c:`; // Windows OSC links need file-path routing before generic URL parsing. - return + return true } let parsed: URL try { parsed = new URL(rawText) } catch { - openDetectedPathLink() - return + return openDetectedPathLink() } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -84,7 +84,7 @@ export function handleOscLink( forceSystemBrowser: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) - return + return true } if (parsed.protocol === 'file:') { @@ -99,11 +99,13 @@ export function handleOscLink( !deps.runtimeEnvironmentId const resolved = resolveTerminalFileUrlTarget(parsed, { allowUncHost }) if (!resolved) { - return + return false } openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { ...deps, openWithSystemDefault: Boolean(event?.shiftKey) }) + return true } + return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 96ceafcce38..67505d5f622 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,6 +1,7 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { @@ -48,13 +49,14 @@ export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLi return links } -function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { +function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay - // available for context menus even when Chromium reports it as button 0. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so + // plain clicks remain available for cursor placement and selection. Mobile + // tap routing is handled separately under mobile/src/terminal. + return isTerminalLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -220,7 +222,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (!isPrimaryHttpLinkFallbackActivation(event)) { + if (!isDesktopHttpLinkFallbackActivation(event)) { return } @@ -230,8 +232,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves ordinary link clicks when the hover link - // was never established, while defaultPrevented avoids duplicate opens. + // direct mouseup fallback preserves modifier-clicks when the hover link was + // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a5d9172855b..1674e150fcd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1017,7 +1017,7 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, { + const handled = handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, @@ -1031,7 +1031,9 @@ export function useTerminalPaneLifecycle({ // moving the mouse extends a selection until the next click/Esc. // clearSelection() explicitly detaches those listeners (see // SelectionService._removeMouseDownListeners). - pane.terminal.clearSelection() + if (handled) { + pane.terminal.clearSelection() + } }, // Show bottom-left tooltip on hover for OSC 8 hyperlinks (e.g. // GitHub owner/repo#issue references emitted by CLI tools) — same @@ -1344,7 +1346,7 @@ export function useTerminalPaneLifecycle({ return } const activePane = managerRef.current?.getActivePane() - void handleOscLink(url, event, { + const handled = handleOscLink(url, event, { ...linkDeps, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane @@ -1359,7 +1361,9 @@ export function useTerminalPaneLifecycle({ // phantom selection until the next click/Esc. Explicitly clearing the // selection also detaches those listeners (see // SelectionService._removeMouseDownListeners). - managerRef.current?.getActivePane()?.terminal.clearSelection() + if (handled) { + managerRef.current?.getActivePane()?.terminal.clearSelection() + } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index ec1783654d5..7f256d4304a 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' @@ -48,7 +48,49 @@ vi.mock('@xterm/xterm', () => ({ }) })) +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('createPaneDOM link tooltips', () => { + it('uses desktop modifier-click text for WebLinks hover hints', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + + setPlatform('Macintosh') + const macPane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(macPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (⌘+click to open or ⇧⌘+click for system browser)' + ) + + setPlatform('Windows') + const windowsPane = createPaneDOM( + 2, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(windowsPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (Ctrl+click to open or Shift+Ctrl+click for system browser)' + ) + }) + it('lets callers replace WebLinks hover text for display-only labels', async () => { const labeledText = 'http://main.orca.localhost:60016/ (localhost:5180; click to open)' const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 8c1c1e289ff..2e556f44400 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -16,8 +16,8 @@ import { installGuardedLinkProviderRegistration } from './terminal-link-provider function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } function defaultLinkTooltipText(uri: string, openLinkHint: string): string { From a84f737cf79e6dece80c7071e450f6facfe8bd67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:45:15 -0700 Subject: [PATCH 093/233] fix(terminals): restore pane detach tab placement (#7215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tabs): restore deferred tab activation so dragging a terminal tab doesn't switch panes mid-gesture PR #5927 shipped terminal-pane drag (reorder tabs, move a tab into another pane, edge-split into a new column). It deliberately DEFERRED tab activation to pointer-up and suppressed it during a drag, so pressing a tab to drag it never switched the active tab or stole terminal focus mid-gesture. PR #6395 (d32d62a395) deleted tab-strip-pointer-activation.ts and made SortableTab/EditorFileTab/BrowserTab activate eagerly on pointerdown — fixing click-to-switch-after-reorder but regressing the drag: grabbing a tab now flips the active tab + focused pane and yanks xterm keyboard focus before the drag is even recognized (aggravated by PR #6210 raising the dnd-kit threshold 5px->12px). The move itself still lands, but the gesture feels broken. Restore the deferred-activation hook, but gate it on measured pointer DISPLACEMENT instead of the drag-active context ref the old hook used — that ref clears asynchronously relative to the drop's pointerup, which is exactly what made #6395's click-after-reorder misfire. Displacement mirrors dnd-kit's own activation threshold: a release within TAB_DRAG_ACTIVATION_DISTANCE_PX is a click (activate); crossing it is a drag (suppress). Because each press measures its own gesture, a click after a reorder always activates. - Recreate src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts (displacement-gated) + unit tests, incl. an explicit #6395 regression case. - Rewire onPointerDown in SortableTab, EditorFileTab, BrowserTab to use it. - BrowserTab.test.tsx shallow-renders via mocked React hooks; add useRef/ useCallback stubs so the new hook works under that harness. Verified end-to-end in a dev build via CDP: pressing a tab no longer activates/steals focus on pointerdown; a click still activates on release; dragging a tab into another pane still moves it; clicking tabs after a drag still switches (the #6395 guard in tests/e2e/tabs.spec.ts stays green because it uses zero-displacement clicks). 233 unit tests pass; oxlint + renderer typecheck clean. * fix: restore pane detach tab placement --- src/renderer/src/assets/terminal.css | 6 + .../components/tab-bar/BrowserTab.test.tsx | 5 + .../src/components/tab-bar/BrowserTab.tsx | 14 +- .../src/components/tab-bar/EditorFileTab.tsx | 16 +- .../src/components/tab-bar/SortableTab.tsx | 20 +- .../tab-strip-pointer-activation.test.tsx | 111 +++++ .../tab-bar/tab-strip-pointer-activation.ts | 91 +++++ .../components/tab-group/TabGroupPanel.tsx | 2 + .../components/terminal-pane/TerminalPane.tsx | 63 ++- .../terminal-layout-leaf-detach.test.ts | 119 ++++++ .../terminal-layout-leaf-detach.ts | 119 ++++++ .../terminal-pane-tab-detach.test.ts | 383 ++++++++++++++++++ .../terminal-pane/terminal-pane-tab-detach.ts | 306 ++++++++++++++ .../use-terminal-pane-lifecycle.ts | 55 ++- .../src/lib/pane-manager/pane-drag-pointer.ts | 50 ++- .../pane-manager/pane-drag-reorder.test.ts | 74 ++++ .../src/lib/pane-manager/pane-drag-reorder.ts | 16 +- .../lib/pane-manager/pane-manager-types.ts | 20 + .../src/lib/pane-manager/pane-manager.ts | 41 +- .../src/lib/pane-manager/pane-split-close.ts | 31 +- .../src/store/slices/store-cascades.test.ts | 51 +++ src/renderer/src/store/slices/terminals.ts | 84 ++++ 22 files changed, 1624 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 97d3cbb4744..d9763b3e341 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -219,6 +219,12 @@ pointer-events: none; } +.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] { + background: var(--chart-2); + border: 0; + border-radius: 0; +} + .tab-drop-overlay__label { font-size: 11px; color: var(--primary-foreground); diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index a53e80e0117..f4dc863f8e8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -11,6 +11,11 @@ vi.mock('react', async () => { return { ...actual, useEffect: () => {}, + // Why: this shallow harness calls the component as a plain function (no React + // render), so ref/callback hooks must be stubbed like useState/useEffect. The + // favicon tests never fire pointer events, so non-persistent refs are fine. + useRef: (initial: T) => ({ current: initial }), + useCallback: (fn: T) => fn, useState(initial: T | (() => T)) { const stateIndex = reactHookRuntime.index++ if (!(stateIndex in reactHookRuntime.states)) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 83ad2c5f45f..0dfb97a9af6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -169,6 +170,10 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate }) + const tabRoot = (

-
-

- {templateDirty - ? translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', - 'Unsaved changes' - ) - : translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', - 'Saved' - )} -

-
- {templateDirty ? ( - - ) : null} - +

+
+ {templateDirty ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx new file mode 100644 index 00000000000..095cfc4b550 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' +import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderNote( + summary: SourceControlActionRecipeOverrideSummary, + onReviewRepo = vi.fn() +): void { + act(() => { + root.render( + + + + ) + }) +} + +describe('SourceControlActionRepoOverrideNote', () => { + it('renders nothing when no repo overrides the action', () => { + renderNote({ count: 0, overrides: [] }) + expect(container.textContent).toBe('') + }) + + it('shows the singular count when one repo overrides the action', () => { + renderNote({ + count: 1, + overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }] + }) + expect(container.textContent).toContain("Global saves won't change 1 repository") + expect(container.textContent).toContain('Review') + }) + + it('shows the plural count when several repos override the action', () => { + renderNote({ + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }) + expect(container.textContent).toContain("Global saves won't change 2 repositories") + expect(container.textContent).toContain('Review first') + }) + + it('shows a "+N more" note when overrides exceed the visible limit', async () => { + renderNote({ + count: 6, + overrides: Array.from({ length: 6 }, (_, index) => ({ + repoId: `r${index}`, + repoName: `Repo ${index}`, + fields: ['commandTemplate'] + })) + }) + + // The overflow note lives in the tooltip content, only mounted once Radix + // opens the tooltip — drive its pointer-move open path and flush the timer. + const trigger = container.querySelector('[data-slot="tooltip-trigger"]') + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(document.body.textContent).toContain('+1 more') + }) + + it('opens the first repo override from the review action', () => { + const onReviewRepo = vi.fn() + renderNote( + { + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }, + onReviewRepo + ) + + act(() => { + container.querySelector('button')?.click() + }) + + expect(onReviewRepo).toHaveBeenCalledWith('r1') + }) +}) diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx new file mode 100644 index 00000000000..ec389927e42 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -0,0 +1,135 @@ +import type React from 'react' +import { Info } from 'lucide-react' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import type { + SourceControlActionRecipeOverrideField, + SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' +import { translate } from '@/i18n/i18n' + +type SourceControlActionRepoOverrideNoteProps = { + summary: SourceControlActionRecipeOverrideSummary + onReviewRepo: (repoId: string) => void +} + +const MAX_VISIBLE_REPOS = 5 + +function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string { + switch (field) { + case 'agent': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agent', + 'Agent' + ) + case 'agentArgs': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs', + 'CLI arguments' + ) + case 'commandTemplate': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate', + 'Command template' + ) + default: { + // Fail at compile time if the override-field union grows a new variant. + const _exhaustive: never = field + return _exhaustive + } + } +} + +function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string { + if (fields.length === 0) { + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.recipe', + 'Recipe' + ) + } + return fields.map(getRecipeOverrideFieldLabel).join(', ') +} + +export function SourceControlActionRepoOverrideNote({ + summary, + onReviewRepo +}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null { + if (summary.count === 0) { + return null + } + const firstOverride = summary.overrides[0] + if (!firstOverride) { + return null + } + const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS) + const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length) + const noteText = + summary.count === 1 + ? translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.singular', + "Global saves won't change 1 repository with its own recipe." + ) + : translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.plural', + "Global saves won't change {{count}} repositories with their own recipes.", + { count: summary.count } + ) + return ( +
+ + + + + {noteText} + + + +
+

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle', + 'Repository overrides' + )} +

+
    + {visibleOverrides.map((override) => ( +
  • +
    {override.repoName}
    +
    + {getRecipeOverrideFieldSummary(override.fields)} +
    +
  • + ))} +
+ {hiddenOverrideCount > 0 ? ( +

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.more', + '+{{count}} more', + { count: hiddenOverrideCount } + )} +

+ ) : null} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index c54390e2a8d..56906be96d3 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react' import type React from 'react' import { toast } from 'sonner' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import { isFolderRepo } from '../../../../shared/repo-kind' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -13,9 +15,17 @@ import { type SourceControlActionId } from '../../../../shared/source-control-ai-actions' import { Label } from '../ui/label' +import { useAppStore } from '@/store' +import { useRepos } from '@/store/selectors' +import { + summarizeReposOverridingActionRecipe, + type SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' import { SearchableSetting } from './SearchableSetting' +import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets' import { matchesSettingsSearch } from './settings-search' import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state' import { translate } from '@/i18n/i18n' @@ -84,6 +94,22 @@ export function SourceControlAiActionRecipeDefaults({ searchQuery, writeConfig }: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null { + const allRepos = useRepos() + // Recomputes on every keystroke in the sibling recipe textareas otherwise, so + // memoize the filter and the O(actions × repos) override scan on `repos`. + const repos = useMemo(() => allRepos.filter((repo) => !isFolderRepo(repo)), [allRepos]) + const overrideSummaries = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + summarizeReposOverridingActionRecipe({ repos, actionId }) + ]) + ) as Record, + [repos] + ) + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const { actionRecipeDraftState, savingActionTemplateIds, @@ -134,6 +160,18 @@ export function SourceControlAiActionRecipeDefaults({ } } + const openRepoSourceControlAiSettings = ( + repoId: string, + actionId: SourceControlActionId + ): void => { + openSettingsTarget({ + pane: 'repo', + repoId, + sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId) + }) + openSettingsPage() + } + if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) { return null } @@ -158,6 +196,7 @@ export function SourceControlAiActionRecipeDefaults({ {SOURCE_CONTROL_ACTION_IDS.map((actionId) => { const recipe = config.actions?.[actionId] const selectedAgent = recipe?.agentId ?? null + const overrideSummary = overrideSummaries[actionId] return ( openRepoSourceControlAiSettings(repoId, actionId)} + /> + } onAgentChange={(id, value) => void onActionAgentChange(id, value)} onTemplateChange={onActionTemplateChange} onAgentArgsChange={onActionAgentArgsChange} diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts index 2ab4fd8d5d5..32bcd1f4510 100644 --- a/src/renderer/src/components/settings/repository-settings-targets.ts +++ b/src/renderer/src/components/settings/repository-settings-targets.ts @@ -1,3 +1,5 @@ +import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' + export function getRepositoryLocalCommandsSectionId(repoId: string): string { return `repo-${repoId}-local-commands` } @@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string { export function getRepositorySourceControlAiSectionId(repoId: string): string { return `repo-${repoId}-source-control-ai` } + +export function getRepositorySourceControlAiActionRecipeSectionId( + repoId: string, + actionId: SourceControlActionId +): string { + return `repo-${repoId}-source-control-ai-${actionId}` +} diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts new file mode 100644 index 00000000000..1972afde88b --- /dev/null +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft' + +describe('buildActionScopedRepoAiSave', () => { + it('persists only the target action, keeping siblings at their saved values', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'claude', + commandInputTemplate: '{basePrompt}', + agentArgs: '' + }, + fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'codex', + commandInputTemplate: '{basePrompt}\n\nedited', + agentArgs: '' + }, + // A half-finished edit on a different row that must NOT be flushed. + fixCommitFailure: { + agentId: 'gemini', + commandInputTemplate: '{basePrompt}\n\nunsaved', + agentArgs: '' + } + } + } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex') + expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe( + '{basePrompt}\n\nedited' + ) + // Sibling keeps the persisted value, not the draft's pending edit. + expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex') + expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}') + }) + + it('removes the action override when the draft inherits it', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { actionOverrides: {} } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 8eb2875a477..a085821f23f 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -1,8 +1,17 @@ -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../../shared/source-control-ai' import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import type { GlobalSettings } from '../../../../shared/types' +import { completeRepoActionRecipe } from './repository-source-control-ai-labels' import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options' +type RepoActionRecipe = NonNullable< + NonNullable[SourceControlActionId] +> + export type RepoAiDraftState = { repoId: string value: RepoSourceControlAiOverrides @@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction( Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined } } + +export function readCompleteRecipeForDraft( + current: RepoSourceControlAiOverrides, + settings: GlobalSettings | null, + actionId: SourceControlActionId +): RepoActionRecipe { + const recipe = resolveSourceControlActionRecipe({ + settings, + repo: { sourceControlAi: current }, + actionId + }) + return completeRepoActionRecipe(recipe, actionId) +} + +export function setActionOverride( + current: RepoSourceControlAiOverrides, + actionId: SourceControlActionId, + recipe: RepoActionRecipe +): RepoSourceControlAiOverrides { + return dropRepoLegacyInstructionForAction( + { + ...current, + actionOverrides: { + ...current.actionOverrides, + [actionId]: recipe + } + }, + actionId + ) +} + +export function serializeActionOverride( + value: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): string { + return JSON.stringify({ + hasOverride: hasOwnActionOverride(value.actionOverrides, actionId), + recipe: value.actionOverrides?.[actionId] ?? null + }) +} + +/** + * Layer only one action's draft override onto the last-saved repo settings, so a + * per-action save persists that recipe without flushing other rows' edits. + */ +export function buildActionScopedRepoAiSave( + persisted: RepoSourceControlAiOverrides, + draft: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): RepoSourceControlAiOverrides { + const nextActionOverrides = { ...persisted.actionOverrides } + if (hasOwnActionOverride(draft.actionOverrides, actionId)) { + nextActionOverrides[actionId] = draft.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return normalizeRepoAiDraft( + dropRepoLegacyInstructionForAction( + { ...persisted, actionOverrides: nextActionOverrides }, + actionId + ) + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a2d2f906fc..ea81c2c094a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.", "toggleLabel": "Toggle per-workspace environments" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "c7ff8cef11": "Detecting agents...", "b0da3a4d3e": "Launch recipe already saved", "bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.", - "5c75b24735": "Customize what the agent receives before Orca starts it." + "5c75b24735": "Customize what the agent receives before Orca starts it.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Save as global default", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b1a8371f613..97cbdff6f63 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.", "toggleLabel": "Activar o desactivar entornos por espacio de trabajo" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agente", + "agentArgs": "Argumentos de la CLI", + "commandTemplate": "Plantilla del comando", + "more": "+{{count}} más", + "plural": "Los guardados globales no cambiarán {{count}} repositorios con sus propias recetas.", + "recipe": "Receta", + "review": "Revisar", + "reviewFirst": "Revisar primero", + "singular": "Los guardados globales no cambiarán 1 repositorio con su propia receta.", + "tooltipTitle": "Anulaciones del repositorio" } }, "right": { @@ -9207,7 +9219,8 @@ "c7ff8cef11": "Detectando agentes...", "b0da3a4d3e": "La receta de lanzamiento ya está guardada", "bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.", - "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie." + "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie.", + "repoAgentOverrideNote": "Este repositorio anula tu valor predeterminado global ({{global}}) y actualmente ejecuta {{effective}}. Guarda en este repositorio para cambiar lo que se ejecuta aquí." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Guardar como valor predeterminado global", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d9e157b3635..590f6d505cf 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。", "toggleLabel": "ワークスペースごとの環境を切り替える" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "キャンセル", "b0da3a4d3e": "起動レシピはすでに保存されています", "bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。", - "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。" + "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "グローバルデフォルトとして保存", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 14aa226e98d..8ae5f66134f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.", "toggleLabel": "워크스페이스별 환경 전환" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "취소", "b0da3a4d3e": "실행 레시피가 이미 저장되었습니다", "bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.", - "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다." + "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "전역 기본값으로 저장", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 258a5cdd31e..4166c860372 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8710,6 +8710,18 @@ "ephemeralVmsExperimentalSetting": { "description": "显示仓库自带按需环境的设置控件和工作区运行目标。", "toggleLabel": "切换工作区专属环境" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -9207,7 +9219,8 @@ "ea4788705e": "取消", "b0da3a4d3e": "启动预设已保存", "bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的预设。", - "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。" + "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。", + "repoAgentOverrideNote": "此仓库会覆盖你的全局默认值({{global}}),当前运行 {{effective}}。保存到此仓库以更改这里运行的内容。" }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "保存为全局默认值", diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.test.ts b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts new file mode 100644 index 00000000000..63a053f0186 --- /dev/null +++ b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' +import { + resolveSourceControlLaunchAgentScope, + summarizeReposOverridingActionRecipe +} from './source-control-launch-agent-selection' + +function settingsWithGlobalResolveAgent(agentId: TuiAgent): GlobalSettings { + const base = getDefaultSettings('/tmp') + return { + ...base, + defaultTuiAgent: agentId, + sourceControlAi: { + ...base.sourceControlAi!, + enabled: true, + actions: { + ...base.sourceControlAi!.actions, + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +function repoWithResolveAgent(agentId: TuiAgent): Pick { + return { + sourceControlAi: { + actionOverrides: { + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +describe('resolveSourceControlLaunchAgentScope', () => { + it('flags a repo override that pins a different agent than the global default', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'codex', + globalAgentId: 'claude', + overridesGlobalAgent: true + }) + }) + + it('does not flag a repo override that matches the global agent', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('claude'), + actionId: 'resolveConflicts' + }) + expect(scope.overridesGlobalAgent).toBe(false) + }) + + it('does not flag when there is no repo override', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: null, + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'claude', + globalAgentId: 'claude', + overridesGlobalAgent: false + }) + }) + + it('falls back to the default agent when no global recipe agent is set', () => { + const base = getDefaultSettings('/tmp') + const settings: GlobalSettings = { + ...base, + defaultTuiAgent: 'claude', + sourceControlAi: { ...base.sourceControlAi!, enabled: true } + } + const scope = resolveSourceControlLaunchAgentScope({ + settings, + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope.globalAgentId).toBe('claude') + expect(scope.overridesGlobalAgent).toBe(true) + }) +}) + +function repoOverriding( + id: string, + displayName: string, + agentId: TuiAgent +): Pick { + return { id, displayName, ...repoWithResolveAgent(agentId) } +} + +describe('summarizeReposOverridingActionRecipe', () => { + it('lists repos with any action recipe override', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + repoOverriding('repo-1', 'App', 'claude'), + { + id: 'repo-2', + displayName: 'Web', + sourceControlAi: { + actionOverrides: { + resolveConflicts: { + commandInputTemplate: '{basePrompt}\n\nUse project conflict rules.', + agentArgs: '--model gpt-5.4-mini' + } + } + } + }, + { id: 'repo-3', displayName: 'Docs' } + ], + actionId: 'resolveConflicts' + }) + + expect(summary).toEqual({ + count: 2, + overrides: [ + { repoId: 'repo-1', repoName: 'App', fields: ['agent', 'commandTemplate', 'agentArgs'] }, + { repoId: 'repo-2', repoName: 'Web', fields: ['commandTemplate', 'agentArgs'] } + ] + }) + }) + + it('includes legacy repo instructions as command template overrides', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + { + id: 'repo-1', + displayName: 'App', + sourceControlAi: { + instructionsByOperation: { + commitMessage: 'Use project style.' + } + } + } + ], + actionId: 'commitMessage' + }) + + expect(summary).toEqual({ + count: 1, + overrides: [{ repoId: 'repo-1', repoName: 'App', fields: ['commandTemplate'] }] + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.ts b/src/renderer/src/lib/source-control-launch-agent-selection.ts index 38660a74ce5..e8c00812590 100644 --- a/src/renderer/src/lib/source-control-launch-agent-selection.ts +++ b/src/renderer/src/lib/source-control-launch-agent-selection.ts @@ -1,8 +1,15 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { isCustomAgentId } from '../../../shared/commit-message-agent-spec' -import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../shared/source-control-ai' +import type { + SourceControlActionId, + SourceControlActionRecipe +} from '../../../shared/source-control-ai-actions' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/types' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' export function readSourceControlLaunchRecipeAgentId( recipe: Pick | null | undefined @@ -30,3 +37,122 @@ export function pickSourceControlLaunchAgent(args: { } return getAgentCatalog().find((entry) => enabledAgents.includes(entry.id))?.id ?? null } + +export type SourceControlLaunchAgentScope = { + /** Agent the button would launch, after applying any repo override. */ + effectiveAgentId: TuiAgent | null + /** Agent the button would launch without a repo override (global recipe or default). */ + globalAgentId: TuiAgent | null + /** + * True when this repo pins a different launch agent than the global default — + * the silent-override case we surface instead of auto-launching blindly. + */ + overridesGlobalAgent: boolean +} + +export function resolveSourceControlLaunchAgentScope(input: { + settings: + | Pick + | null + | undefined + repo?: Pick | null + actionId: SourceControlActionId +}): SourceControlLaunchAgentScope { + const effectiveAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: input.repo, + actionId: input.actionId + }) + ) + const globalRecipeAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: null, + actionId: input.actionId + }) + ) + // Why: the note compares against what would run with no override, so fall back + // to the global default agent when no global recipe agent is set. + const defaultTuiAgent = input.settings?.defaultTuiAgent + const globalAgentId = + globalRecipeAgentId ?? (defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null) + const hasRepoAgentOverride = + normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)?.actionOverrides?.[ + input.actionId + ]?.agentId !== undefined + return { + effectiveAgentId, + globalAgentId, + overridesGlobalAgent: + hasRepoAgentOverride && effectiveAgentId !== null && effectiveAgentId !== globalAgentId + } +} + +export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs' + +export type SourceControlActionRecipeOverride = { + repoId: string + repoName: string + fields: SourceControlActionRecipeOverrideField[] +} + +export type SourceControlActionRecipeOverrideSummary = { + count: number + overrides: SourceControlActionRecipeOverride[] +} + +type NormalizedRepoSourceControlAiOverrides = NonNullable< + ReturnType +> + +function hasActionOverride( + overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined, + actionId: SourceControlActionId +): boolean { + return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) +} + +function readRecipeOverrideFields( + recipe: + | NonNullable[SourceControlActionId] + | null + | undefined +): SourceControlActionRecipeOverrideField[] { + const fields: SourceControlActionRecipeOverrideField[] = [] + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) { + fields.push('agent') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) { + fields.push('commandTemplate') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) { + fields.push('agentArgs') + } + return fields +} + +/** + * Which repos own any recipe setting for an action — lets the global recipe + * editor warn that saving globally will not update those repo-specific recipes. + */ +export function summarizeReposOverridingActionRecipe(input: { + repos: readonly Pick[] + actionId: SourceControlActionId +}): SourceControlActionRecipeOverrideSummary { + const overrides: SourceControlActionRecipeOverride[] = [] + for (const repo of input.repos) { + const actionOverrides = normalizeRepoSourceControlAiOverrides( + repo.sourceControlAi + )?.actionOverrides + if (!hasActionOverride(actionOverrides, input.actionId)) { + continue + } + overrides.push({ + repoId: repo.id, + repoName: repo.displayName, + fields: readRecipeOverrideFields(actionOverrides?.[input.actionId]) + }) + } + return { count: overrides.length, overrides } +} From 7e1f9e55f2b8bf9e70a36345adf9e4610700c74c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 5 Jul 2026 00:31:51 -0400 Subject: [PATCH 213/233] fix(worktrees): fall back from stale default base refs (#7398) * fix(worktrees): fall back from stale default base refs (#7312) * docs(worktrees): explain base fallback policy * fix(worktrees): harden stale base fallback edge cases --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/git/repo.test.ts | 68 ++ src/main/git/repo.ts | 74 ++- src/main/git/worktree-base-ref-probe.ts | 13 +- .../ssh-worktree-create-root-registration.ts | 50 ++ src/main/ipc/worktree-remote.ts | 225 +++++-- src/main/ipc/worktrees.test.ts | 583 ++++++++++++++++-- src/main/runtime/orca-runtime.test.ts | 185 +++++- src/main/runtime/orca-runtime.ts | 134 +++- src/main/worktree-create-base-prefetch.ts | 55 +- src/main/worktree-create-base.test.ts | 53 ++ src/main/worktree-create-base.ts | 29 + ...poserState-host-context-boundaries.test.ts | 4 +- src/renderer/src/hooks/useComposerState.ts | 7 +- .../src/runtime/worktree-create-base.test.ts | 32 +- .../src/runtime/worktree-create-base.ts | 13 +- 15 files changed, 1295 insertions(+), 230 deletions(-) create mode 100644 src/main/ipc/ssh-worktree-create-root-registration.ts create mode 100644 src/main/worktree-create-base.test.ts create mode 100644 src/main/worktree-create-base.ts diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 8392b5434a0..d4abbd2501e 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -10,6 +10,7 @@ import { getBranchConflictKind, getRemoteCount, parseAndFilterSearchRefDetails, + resolveDefaultBaseRefViaExec, searchBaseRefDetails, searchBaseRefs } from './repo' @@ -493,6 +494,26 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { expect(result).toBe('origin/main') }) + it('falls through from a stale origin/HEAD target to an existing primary ref', () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/main', sha) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master']) + + const result = getDefaultBaseRef(tmpDir) + + expect(result).toBe('origin/main') + }) + + it('falls through from a stale origin/HEAD primary target to another existing default ref', () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/master', sha) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']) + + const result = getDefaultBaseRef(tmpDir) + + expect(result).toBe('origin/master') + }) + it('does NOT fall through to upstream/main when origin/* is absent', () => { // Why: this is the explicit design decision — the default probe order // is origin-only. upstream-aware defaulting is deferred work. @@ -508,6 +529,53 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => { }) }) +describe('resolveDefaultBaseRefViaExec', () => { + it('falls through from a stale origin/HEAD target to the probe list', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/master\n' } + } + if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') { + return { stdout: 'main-sha\n' } + } + throw new Error('missing ref') + } + + await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main') + + expect(calls).toEqual([ + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'] + ]) + }) + + it('verifies origin/HEAD even when it points at origin/main', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') { + return { stdout: 'master-sha\n' } + } + throw new Error('missing ref') + } + + await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master') + + expect(calls).toEqual([ + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'] + ]) + }) +}) + describe('getRemoteCount', () => { let tmpDir: string diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index d743c8ab5e4..68900f810bf 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -467,6 +467,24 @@ function hasGitRef(path: string, ref: string): boolean { } } +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + + // Why: origin/HEAD may survive a default-branch rename while pointing at a + // deleted ref; verify before trusting it over the probe list. + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + /** * Resolve the default base ref for new worktrees. * Prefer the remote primary branch over a potentially stale local branch. @@ -478,16 +496,9 @@ function hasGitRef(path: string, ref: string): boolean { * degrade gracefully for non-creation uses (e.g. hosted URL building). */ export function getDefaultBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - if (ref) { - return ref.replace(/^refs\/remotes\//, '') - } - } catch { - // Fall through to explicit remote branch probes. + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef } // Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the @@ -595,6 +606,28 @@ export async function getRemoteCount(path: string): Promise { /** Callback shape for a git exec function that yields stdout. */ export type GitExec = (argv: string[]) => Promise<{ stdout: string }> +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + /** * Resolve the default base ref given a git exec callback. Prefers * origin/HEAD's symbolic-ref target; falls back to DEFAULT_BASE_REF_PROBES. @@ -609,24 +642,11 @@ export type GitExec = (argv: string[]) => Promise<{ stdout: string }> * from a genuine transport failure. */ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (ref) { - return ref.replace(/^refs\/remotes\//, '') - } - } catch { - // symbolic-ref returns non-zero when origin/HEAD is unset — expected. - // Fall through to probes. + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef } - return resolveDefaultBaseRefFromProbes(async (ref) => { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } - }) + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) } async function getDefaultBaseRefAsync( diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 0d8e132df40..c7144159dfe 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef( options: GitExecOptions = {} ): Promise { try { - await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], { - cwd: repoPath, - ...options - }) - return true + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], + { + cwd: repoPath, + ...options + } + ) + return stdout.trim().length > 0 } catch { return false } diff --git a/src/main/ipc/ssh-worktree-create-root-registration.ts b/src/main/ipc/ssh-worktree-create-root-registration.ts new file mode 100644 index 00000000000..e364239cdbe --- /dev/null +++ b/src/main/ipc/ssh-worktree-create-root-registration.ts @@ -0,0 +1,50 @@ +import { getActiveMultiplexer } from './ssh' + +const SSH_CONNECTION_UNAVAILABLE_MESSAGE = + 'SSH connection is not available. Please reconnect and try again.' + +type SshRootRegistrationMux = { + request: (method: 'session.registerRoot', payload: { rootPath: string }) => Promise + notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void +} + +async function registerSshWorktreeCreateRoots( + mux: SshRootRegistrationMux, + rootPaths: string[] +): Promise { + try { + await Promise.all( + rootPaths.map((rootPath) => mux.request('session.registerRoot', { rootPath })) + ) + } catch (err) { + if (err instanceof Error && err.message.includes('Method not found')) { + for (const rootPath of rootPaths) { + mux.notify('session.registerRoot', { rootPath }) + } + return + } + throw err + } +} + +export async function registerOptionalSshWorktreeCreateRoots( + connectionId: string, + rootPaths: string[] +): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + return + } + await registerSshWorktreeCreateRoots(mux, rootPaths) +} + +export async function registerRequiredSshWorktreeCreateRoots( + connectionId: string, + rootPaths: string[] +): Promise { + const mux = getActiveMultiplexer(connectionId) + if (!mux) { + throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE) + } + await registerSshWorktreeCreateRoots(mux, rootPaths) +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 8193d6d74c1..e0a2bbf5db1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -30,6 +30,8 @@ import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' import { resolveLocalGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { resolveWorktreeCreateBase } from '../worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' import type { ForgeProviderId } from '../source-control/forge-provider' import { validateGitPushTarget } from '../git/push-target-validation' @@ -53,12 +55,15 @@ import { } from '../hooks' import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { getActiveMultiplexer } from './ssh' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' +import { + registerOptionalSshWorktreeCreateRoots, + registerRequiredSshWorktreeCreateRoots +} from './ssh-worktree-create-root-registration' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -608,6 +613,35 @@ function hasLocalCommitObjectWithOptions( ) } +async function hasLocalWorktreeBaseRefWithOptions( + repoPath: string, + baseRef: string, + gitOptions: { wslDistro?: string } +): Promise { + const refExists = async (qualifiedRef: string) => { + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], + { + cwd: repoPath, + ...gitOptions + } + ) + return stdout.trim().length > 0 + } catch { + return false + } + } + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions) +} + function getLocalGitHubPrForBranch( repoPath: string, branchName: string, @@ -626,6 +660,23 @@ function hasRemoteCommitObject( return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref) } +async function hasRemoteWorktreeBaseRef( + provider: SshGitProvider, + repoPath: string, + baseRef: string +): Promise { + const refExists = (qualifiedRef: string) => + hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasRemoteCommitObject(provider, repoPath, baseRef) +} + // Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is // a symbolic ref (refs/remotes/origin/main), so detect its presence directly so // SSH creates can fall back to an existing local base ref when the refresh @@ -1218,33 +1269,31 @@ async function resolveRemoteTrackingBaseSsh( } } -async function resolveRemoteWorktreeCreateBase( - provider: SshGitProvider, - repo: Repo, - requestedBaseBranch: string | undefined -): Promise { - let baseBranch = requestedBaseBranch || repo.worktreeBaseRef - if (baseBranch) { - return baseBranch - } - try { - const { stdout } = await provider.exec( - ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], - repo.path - ) - baseBranch = stdout.trim() - } catch { - return null - } - return baseBranch || null -} - async function resolveRemoteWorktreeCreateBasePlan( provider: SshGitProvider, repo: Repo, requestedBaseBranch: string | undefined ): Promise { - const baseBranch = await resolveRemoteWorktreeCreateBase(provider, repo, requestedBaseBranch) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + resolveDefaultBaseRefViaExec((argv) => provider.exec(argv, repo.path)), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await resolveRemoteTrackingBaseSsh( + provider, + repo.path, + baseBranchCandidate + ) + if (remoteTrackingBase) { + if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) { + return true + } + return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) + } + return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate) + } + }) if (!baseBranch) { return null } @@ -1280,15 +1329,23 @@ export async function prefetchRemoteWorktreeCreateBase( repo: Repo, args: { baseBranch?: string } ): Promise { + // Why: the shared base-plan probes use generic git.exec, and some relays + // require the repo root to be registered before those probes can see refs. + await registerOptionalSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch) if (!basePlan) { return } if (basePlan.remoteTrackingBase) { - await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) - return + if ( + (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) || + !(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) + ) { + await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase) + return + } } - if (await hasRemoteCommitObject(provider, repo.path, basePlan.baseBranch)) { + if (await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) { // Why: PR/MR resolvers already fetched verified SHA start points. A broad // remote fetch only updates unrelated refs when the commit object exists. return @@ -1471,6 +1528,10 @@ export async function createRemoteWorktree( ? sanitizeWorktreeDisplayName(args.displayName) : undefined + // Why: resolving the create base can probe repo refs through generic git.exec. + // Register the repo root first so relays do not report a valid base as stale. + await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path]) + // Why: SSH targets cannot use the local `gh` account, and git email/name are // commit author identity rather than hosted-account usernames. const username = await getSshGitUsername(provider, repo.path) @@ -1488,7 +1549,8 @@ export async function createRemoteWorktree( 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' ) } - const { baseBranch, remoteTrackingBase } = basePlan + const { baseBranch } = basePlan + let { remoteTrackingBase } = basePlan let branchName = '' let checkoutExistingBranch = false @@ -1594,25 +1656,20 @@ export async function createRemoteWorktree( } } - const mux = getActiveMultiplexer(repo.connectionId!) - if (!mux) { - throw new Error('SSH connection is not available. Please reconnect and try again.') - } - // Why: register before any generic git.exec probe (base-ref existence, the - // local-base advisory) as well as addWorktree. Fresh/older relays may gate - // generic git.exec on registered roots; probing first would falsely report a - // ref missing (and defeat the offline fallback below) even though create works. - try { - await Promise.all([ - mux.request('session.registerRoot', { rootPath: repo.path }), - mux.request('session.registerRoot', { rootPath: remotePath }) - ]) - } catch (err) { - if (err instanceof Error && err.message.includes('Method not found')) { - mux.notify('session.registerRoot', { rootPath: repo.path }) - mux.notify('session.registerRoot', { rootPath: remotePath }) - } else { - throw err + // Why: addWorktree and setup probes run inside the new worktree path; older + // relays need that root registered before accepting git/fs operations there. + await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) + + if (remoteTrackingBase) { + const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + provider, + repo.path, + remoteTrackingBase.ref + ) + const hasLocalBaseRef = + hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch)) + if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null } } @@ -1633,7 +1690,7 @@ export async function createRemoteWorktree( ) } } - } else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) { + } else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) { // Why: local or otherwise non-remote-tracking bases preserve legacy // best-effort fetch behavior. Verified PR/MR SHA bases already have the // commit object locally, so a broad remote fetch only updates unrelated refs. @@ -1914,15 +1971,40 @@ export async function createLocalWorktree( ? sanitizeWorktreeDisplayName(args.displayName) : undefined - // Why: resolve the base before branch/path selection so remote-tracking bases - // can be refreshed before `git worktree add`. Creating first and repairing - // later races setup scripts, agents, and user edits. - const baseBranch = - args.baseBranch || - repo.worktreeBaseRef || - (await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: args.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)), + isBaseUsable: async (baseBranchCandidate) => { + if (runtime) { + const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + if ( + await runtime.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + ) { + return true + } + return hasLocalWorktreeBaseRefWithOptions( + repo.path, + baseBranchCandidate, + localGitExecOptions + ) + } + } + return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions) + } + }) if (!baseBranch) { - // Why: getDefaultBaseRef may return null when none of origin/HEAD, + // Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD, // origin/main, origin/master, local main, or local master exist. Don't // fall back to a hardcoded 'origin/main' — passing a non-existent ref to // `git worktree add` produces an opaque error. Fail here with a clear @@ -1947,23 +2029,30 @@ export async function createLocalWorktree( ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { - const hasLocalBaseRef = await runtime.hasRemoteTrackingRef( + const hasRemoteTrackingBaseRef = await runtime.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) - emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) - remoteTrackingRefresh = { - base: remoteTrackingBase, - hadLocalBaseRef: hasLocalBaseRef, - promise: runtime.getOrStartRemoteTrackingBaseRefresh( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) + const hasLocalBaseRef = + hasRemoteTrackingBaseRef || + (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)) + if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null + } else { + emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) + remoteTrackingRefresh = { + base: remoteTrackingBase, + hadLocalBaseRef: hasRemoteTrackingBaseRef, + promise: runtime.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + } } } else if ( - !(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) + !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) ) { // Why: when the base branch does not match a configured remote prefix // (e.g. plain `main`, `master`, or any local branch), the legacy path @@ -1976,7 +2065,9 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } else { - if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) { + if ( + !(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) + ) { legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], { ...localGitExecOptions, timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index f619d078630..e9f9edfcdae 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -493,16 +493,32 @@ describe('registerWorktreeHandlers', () => { }) it('prefetches the local default create base through the runtime refresh cache', async () => { + const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/master' + } const remoteBase = { remote: 'origin', branch: 'main', ref: 'refs/remotes/origin/main', base: 'origin/main' } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + store.getRepo.mockReturnValue(repo) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'origin/main' ? remoteBase : null + ) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' }) + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/master' + ) expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( '/workspace/repo', 'origin/main' @@ -3668,7 +3684,7 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => { + it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -3679,7 +3695,6 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: 'origin/main' } const provider = { - // Empty rev-parse stdout -> no local remote-tracking base ref to fall back on. exec: vi.fn().mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } @@ -3705,24 +3720,26 @@ describe('registerWorktreeHandlers', () => { await expect( handlers['worktrees:create'](null, { repoId: 'repo-ssh', - name: 'improve-dashboard' + name: 'improve-dashboard', + baseBranch: 'origin/master' }) ).rejects.toThrow( - 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.' ) expect(provider.addWorktree).not.toHaveBeenCalled() + expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled() expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( '/remote/repo', 'origin', - 'main', - 'refs/remotes/origin/main' + 'master', + 'refs/remotes/origin/master' ) }) - it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => { - // Regression: a failed SSH refresh must fall back to an existing local - // remote-tracking base ref instead of blocking creation. + it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected + // primary default instead of blocking creation. const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -3730,20 +3747,25 @@ describe('registerWorktreeHandlers', () => { badgeColor: '#000', addedAt: 0, connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' + worktreeBaseRef: 'origin/master' } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } - // Local remote-tracking base ref resolves -> usable fallback exists. + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: `${'a'.repeat(40)}\n`, stderr: '' } } return { stdout: '', stderr: '' } }), - fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi .fn() @@ -3773,15 +3795,175 @@ describe('registerWorktreeHandlers', () => { name: 'improve-dashboard' }) - // The refresh must be ATTEMPTED (and fail) before falling back — guards - // against a regression that skips the refresh whenever a local ref exists. + expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled() expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( '/remote/repo', 'origin', 'main', 'refs/remotes/origin/main' ) - expect(provider.addWorktree).toHaveBeenCalled() + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'improve-dashboard', + '/remote/improve-dashboard', + { + base: 'origin/main' + } + ) + }) + + it('keeps a usable SSH persisted local branch base after registering the repo root', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'develop' + } + let repoRootRegistered = false + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/local-branch-base', + head: 'develop-sha', + branch: 'refs/heads/local-branch-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + if (payload.rootPath === repo.path) { + repoRootRegistered = true + } + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'local-branch-base' + }) + + expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path }) + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled() + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'local-branch-base', + '/remote/local-branch-base', + { + base: 'develop' + } + ) + }) + + it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'team/feature' + } + let repoRootRegistered = false + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/slash-local-base', + head: 'team-feature-sha', + branch: 'refs/heads/slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + if (payload.rootPath === repo.path) { + repoRootRegistered = true + } + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'slash-local-base' + }) + + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( + '/remote/repo', + 'team', + 'feature', + 'refs/remotes/team/feature' + ) + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'slash-local-base', + '/remote/slash-local-base', + { + base: 'team/feature' + } + ) }) it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { @@ -3986,6 +4168,160 @@ describe('registerWorktreeHandlers', () => { expect(provider.addWorktree).toHaveBeenCalledTimes(1) }) + it('registers the SSH repo root before create-base prefetch probes refs', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/master' + } + const registeredRoots = new Set() + const events: string[] = [] + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`) + if (!registeredRoots.has('/remote/repo')) { + throw new Error('root not registered') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + throw new Error('missing stale base') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/prefetched-worktree', + head: 'abc123', + branch: 'refs/heads/prefetched-worktree', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi + .fn() + .mockImplementation(async (_method: string, payload: { rootPath: string }) => { + events.push(`register:${payload.rootPath}`) + registeredRoots.add(payload.rootPath) + }), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:prefetchCreateBase'](null, { + repoId: 'repo-ssh' + }) + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'prefetched-worktree' + }) + + expect(events[0]).toBe('register:/remote/repo') + expect(events).not.toContain('exec:symbolic-ref:false') + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'prefetched-worktree', + '/remote/prefetched-worktree', + { + base: 'origin/main' + } + ) + }) + + it('does not let SSH prefetch turn a persisted slash-named local branch into a remote-tracking base', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'team/feature' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/slash-local-base', + head: 'team-feature-sha', + branch: 'refs/heads/slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:prefetchCreateBase'](null, { + repoId: 'repo-ssh' + }) + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'slash-local-base' + }) + + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( + '/remote/repo', + 'team', + 'feature', + 'refs/remotes/team/feature' + ) + expect(provider.addWorktree).toHaveBeenCalledWith( + '/remote/repo', + 'slash-local-base', + '/remote/slash-local-base', + { + base: 'team/feature' + } + ) + }) + it('shares in-flight SSH create-base resolution with create', async () => { const repo = { id: 'repo-ssh', @@ -4241,7 +4577,16 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if ( + args[0] === 'rev-parse' && + (args.includes('refs/remotes/origin/master^{commit}') || + args.includes('refs/heads/origin/master^{commit}')) + ) { + throw new Error('missing ref') + } + return { stdout: 'created-sha\n', stderr: '' } + }) const createPromise = handlers['worktrees:create'](null, { repoId: 'repo-1', @@ -4270,17 +4615,82 @@ describe('registerWorktreeHandlers', () => { ) }) - it('creates from an existing local base ref when the pre-create refresh fails', async () => { - // Regression: a failed refresh must not block creation when a usable local - // remote-tracking base ref already exists. + it('creates from the detected default base when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected + // primary default instead of blocking creation. const remoteBase = { remote: 'origin', branch: 'main', ref: 'refs/remotes/origin/main', base: 'origin/main' } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/master' + }) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'origin/main' ? remoteBase : null + ) runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: true, + errorKind: 'git_error' + }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'created-sha', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if ( + args[0] === 'rev-parse' && + (args.includes('refs/remotes/origin/master^{commit}') || + args.includes('refs/heads/origin/master^{commit}')) + ) { + throw new Error('missing ref') + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + })) as CreateWorktreeResult + + expect(addWorktreeMock).toHaveBeenCalled() + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/master' + ) + expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith( + '/workspace/repo', + 'origin/main' + ) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith( + '/workspace/repo', + remoteBase + ) + expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') + }) + + it('keeps a usable persisted local branch base when a detected default exists', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'develop' + }) + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null) runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ ok: false, errorKind: 'git_error' @@ -4294,29 +4704,14 @@ describe('registerWorktreeHandlers', () => { isMainWorktree: false } ]) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' }) - - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'improve-dashboard' - })) as CreateWorktreeResult - - expect(addWorktreeMock).toHaveBeenCalled() - expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') - }) - - it('does not create when the pre-create refresh fails and no local base ref exists', async () => { - const remoteBase = { - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main', - base: 'origin/main' - } - runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) - runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) - runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ - ok: false, - errorKind: 'git_error' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: 'develop-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: 'created-sha\n', stderr: '' } }) await expect( @@ -4324,11 +4719,109 @@ describe('registerWorktreeHandlers', () => { repoId: 'repo-1', name: 'improve-dashboard' }) + ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + 'develop', + false + ) + }) + + it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => { + const remoteBase = { + remote: 'team', + branch: 'feature', + ref: 'refs/remotes/team/feature', + base: 'team/feature' + } + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'team/feature' + }) + runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) => + baseBranch === 'team/feature' ? remoteBase : null + ) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/slash-local-base', + head: 'created-sha', + branch: 'slash-local-base', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'slash-local-base' + }) + ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/slash-local-base', + 'slash-local-base', + 'team/feature', + false + ) + }) + + it('keeps an explicit base strict when the pre-create refresh fails', async () => { + const remoteBase = { + remote: 'origin', + branch: 'master', + ref: 'refs/remotes/origin/master', + base: 'origin/master' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: false, + errorKind: 'git_error' + }) + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main' + }) + + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + baseBranch: 'origin/master' + }) ).rejects.toThrow( - 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' + 'Could not refresh base ref "origin/master" from "origin". Check your network and try again.' ) expect(addWorktreeMock).not.toHaveBeenCalled() + expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled() }) it('delegates remote-tracking base freshness to the runtime before create', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a8ef74053a3..5bc855ee7c4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -811,6 +811,16 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0- const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111' const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +function isOriginMainBaseRefProbe(args: string[]): boolean { + return ( + args[0] === 'rev-parse' && + args[1] === '--verify' && + (args.includes('origin/main') || + args.includes('refs/remotes/origin/main') || + args.includes('refs/remotes/origin/main^{commit}')) + ) +} + function antigravityReadyScreen(model = 'Gemini 3.5 Flash (High)'): string { return [ 'Antigravity CLI 1.0.3', @@ -2370,9 +2380,8 @@ describe('OrcaRuntimeService', () => { } }) - it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => { - // Regression: an offline/auth failure while refreshing the remote-tracking - // base must not block creation when a usable local base ref already exists. + it('creates a runtime local worktree from the detected default when the persisted base is stale', async () => { + // Regression: a stale persisted repo base must fall back to the detected default. const runtime = new OrcaRuntimeService(store) const createdWorktree = { path: '/tmp/workspaces/cli-refresh-fails', @@ -2381,6 +2390,8 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'origin/master' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) computeWorktreePathMock.mockReturnValue(createdWorktree.path) ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) vi.mocked(addWorktree).mockResolvedValueOnce({}) @@ -2392,8 +2403,10 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { return { stdout: '/tmp/repo/.git\n', stderr: '' } } - // Local remote-tracking base ref resolves -> usable fallback exists. - if (args[0] === 'rev-parse' && args[1] === '--verify') { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) { + throw new Error('missing ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: 'base-sha\n', stderr: '' } } if (args[0] === 'fetch') { @@ -2409,8 +2422,135 @@ describe('OrcaRuntimeService', () => { }) ).resolves.toBeDefined() - expect(addWorktree).toHaveBeenCalled() + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'cli-refresh-fails', + 'origin/main', + false, + false, + { + remoteTrackingBase: { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }, + suggestLocalBaseRefUpdate: true + } + ) + expect(getDefaultBaseRef).toHaveBeenCalled() } finally { + getReposSpy.mockRestore() + gitSpy.mockRestore() + } + }) + + it('creates a runtime local worktree from a usable persisted local branch base', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/local-branch-base', + head: 'develop-sha', + branch: 'local-branch-base', + isBare: false, + isMainWorktree: false + } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'develop' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) { + return { stdout: 'develop-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'local-branch-base' + }) + ).resolves.toBeDefined() + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'local-branch-base', + 'develop', + false + ) + } finally { + getReposSpy.mockRestore() + gitSpy.mockRestore() + } + }) + + it('creates a runtime local worktree from a slash-named local branch matching a remote prefix', async () => { + const runtime = new OrcaRuntimeService(store) + const createdWorktree = { + path: '/tmp/workspaces/slash-local-base', + head: 'team-feature-sha', + branch: 'slash-local-base', + isBare: false, + isMainWorktree: false + } + const repo = { ...store.getRepos()[0], worktreeBaseRef: 'team/feature' } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never) + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'team\norigin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: '/tmp/repo/.git\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) { + return { stdout: 'team-feature-sha\n', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'slash-local-base' + }) + ).resolves.toBeDefined() + + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'slash-local-base', + 'team/feature', + false + ) + expect(gitSpy).not.toHaveBeenCalledWith( + ['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'], + expect.any(Object) + ) + } finally { + getReposSpy.mockRestore() gitSpy.mockRestore() } }) @@ -3140,6 +3280,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3244,6 +3387,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3397,6 +3543,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3507,6 +3656,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3609,6 +3761,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -3765,6 +3920,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -4840,6 +4998,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -22332,6 +22493,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -22425,6 +22589,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -22535,6 +22702,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } @@ -23216,6 +23386,9 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } } + if (isOriginMainBaseRefProbe(args)) { + return { stdout: 'main-sha\n', stderr: '' } + } if (args[0] === 'config') { return { stdout: 'origin\n', stderr: '' } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c53bbd9964..12d6c3e9f37 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -46,6 +46,8 @@ import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { resolveWorktreeCreateBase } from '../worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' import type { @@ -581,7 +583,8 @@ import { getRemoteDrift, getRecentDriftSubjects } from '../git/repo' -import { hasLocalCommitObject } from '../git/commit-object-ref' +import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' +import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { resolveLocalGitUsername } from '../git/git-username' import { listWorktrees, @@ -1963,6 +1966,26 @@ type LayoutQueueEntry = { }[] } +async function hasLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: { wslDistro?: string } = {} +): Promise { + const refExists = (qualifiedRef: string) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasCommitObjectViaGitExec( + (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), + baseRef + ) +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -12992,12 +13015,42 @@ export class OrcaRuntimeService { let effectiveSanitizedName = sanitizedName const username = await resolveLocalGitUsername(repo.path) - const baseBranch = - args.baseBranch || - repo.worktreeBaseRef || - (hasLocalWorktreeGitOptions - ? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) - : getDefaultBaseRef(repo.path)) + const baseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: args.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => + hasLocalWorktreeGitOptions + ? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) + : Promise.resolve(getDefaultBaseRef(repo.path)), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate, + ...localWorktreeGitOptionArgs + ) + if (remoteTrackingBase) { + if ( + await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) + ) { + return true + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + return hasLocalWorktreeBaseRef( + repo.path, + baseBranchCandidate, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + ) + } + }) if (!baseBranch) { // Why: getDefaultBaseRef returns null when no suitable ref exists. // Don't fabricate 'origin/main' — passing it to addWorktree would @@ -13128,44 +13181,61 @@ export class OrcaRuntimeService { `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` ) } - const remoteTrackingBase = await this.resolveRemoteTrackingBase( + let remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, baseBranch, ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { - const hadLocalBaseRef = await this.hasRemoteTrackingRef( + const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) - const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) - if (!refreshResult.ok && !hadLocalBaseRef) { - // Why: only block creation when the refresh failed AND there is no - // usable local base ref to fall back on. If a local remote-tracking ref - // already exists, `git worktree add` can create from it — a possibly - // stale but valid base — so a transient offline/auth failure must not - // make the workspace uncreatable. The compare-to-base view reflects any - // drift once the remote is reachable again. - throw new Error( - `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` - ) - } - if ( - !hadLocalBaseRef && - !(await this.hasRemoteTrackingRef( + const hasLocalBaseRef = + hadRemoteTrackingBaseRef || + (await hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + )) + if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) { + remoteTrackingBase = null + } else { + const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs - )) - ) { - throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + ) + if (!refreshResult.ok && !hadRemoteTrackingBaseRef) { + // Why: only block creation when the refresh failed AND there is no + // usable local base ref to fall back on. If a local remote-tracking ref + // already exists, `git worktree add` can create from it — a possibly + // stale but valid base — so a transient offline/auth failure must not + // make the workspace uncreatable. The compare-to-base view reflects any + // drift once the remote is reachable again. + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + if ( + !hadRemoteTrackingBaseRef && + !(await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + )) + ) { + throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) + } } - } else if (!(await hasLocalCommitObject(repo.path, baseBranch))) { + } else if ( + !(await hasLocalWorktreeBaseRef( + repo.path, + baseBranch, + hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} + )) + ) { // Why: local bases keep legacy best-effort fetch behavior. Verified PR // SHA bases already have the commit object needed by `git worktree add`. try { diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 24c22837c22..42add57be66 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -1,9 +1,12 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/types' -import { hasLocalCommitObject } from './git/commit-object-ref' +import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' +import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' import { getDefaultBaseRef } from './git/repo' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' +import { resolveWorktreeCreateBase } from './worktree-create-base' +import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' type RemoteTrackingBaseForPrefetch = { remote: string @@ -17,6 +20,7 @@ type WorktreeCreateBasePrefetchRuntime = { repoPath: string, baseBranch: string ) => Promise + hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise getOrStartRemoteTrackingBaseRefresh: ( repoPath: string, base: RemoteTrackingBaseForPrefetch @@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = { fetchRemoteWithCache: (repoPath: string, remote: string) => Promise } +async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise { + const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef) + const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) + if (resolvedBaseRef !== baseRef) { + return true + } + if (baseRef.startsWith('refs/')) { + return refExists(baseRef) + } + return hasLocalCommitObject(repoPath, baseRef) +} + async function prefetchLocalWorktreeCreateBase( repo: Repo, baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime ): Promise { - const resolvedBaseBranch = baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + const resolvedBaseBranch = await resolveWorktreeCreateBase({ + requestedBaseBranch: baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path), + isBaseUsable: async (baseBranchCandidate) => { + const remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + baseBranchCandidate + ) + if (remoteTrackingBase) { + if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) { + return true + } + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + } + return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate) + } + }) if (!resolvedBaseBranch) { return } - if (await hasLocalCommitObject(repo.path, resolvedBaseBranch)) { - // Why: hosted-review start points can be verified commit SHAs; a broad - // remote fetch cannot make an already-local object fresher. + if ( + isFullGitObjectId(resolvedBaseBranch) && + (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + ) { return } const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch) if (remoteTrackingBase) { - await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + if ( + (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) || + !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) + ) { + await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + return + } + } + if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) { + // Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher. return } diff --git a/src/main/worktree-create-base.test.ts b/src/main/worktree-create-base.test.ts new file mode 100644 index 00000000000..168cae3441c --- /dev/null +++ b/src/main/worktree-create-base.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveWorktreeCreateBase } from './worktree-create-base' + +describe('resolveWorktreeCreateBase', () => { + it('falls back from a stale persisted base to the detected default', async () => { + const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') + const isBaseUsable = vi.fn().mockResolvedValue(false) + + await expect( + resolveWorktreeCreateBase({ + repoWorktreeBaseRef: 'origin/master', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/main') + + expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1) + expect(isBaseUsable).toHaveBeenCalledWith('origin/master') + }) + + it('returns an explicit base without probing defaults or usability', async () => { + const resolveDefaultBaseRef = vi.fn() + const isBaseUsable = vi.fn() + + await expect( + resolveWorktreeCreateBase({ + requestedBaseBranch: 'origin/master', + repoWorktreeBaseRef: 'origin/main', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/master') + + expect(resolveDefaultBaseRef).not.toHaveBeenCalled() + expect(isBaseUsable).not.toHaveBeenCalled() + }) + + it('keeps a usable persisted base when it is still valid', async () => { + const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') + const isBaseUsable = vi.fn().mockResolvedValue(true) + + await expect( + resolveWorktreeCreateBase({ + repoWorktreeBaseRef: 'origin/master', + resolveDefaultBaseRef, + isBaseUsable + }) + ).resolves.toBe('origin/master') + + expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1) + expect(isBaseUsable).toHaveBeenCalledWith('origin/master') + }) +}) diff --git a/src/main/worktree-create-base.ts b/src/main/worktree-create-base.ts new file mode 100644 index 00000000000..67f0427b392 --- /dev/null +++ b/src/main/worktree-create-base.ts @@ -0,0 +1,29 @@ +type ResolveWorktreeCreateBaseArgs = { + requestedBaseBranch?: string + repoWorktreeBaseRef?: string | null + resolveDefaultBaseRef: () => Promise + isBaseUsable: (baseBranch: string) => Promise +} + +export async function resolveWorktreeCreateBase( + args: ResolveWorktreeCreateBaseArgs +): Promise { + if (args.requestedBaseBranch) { + return args.requestedBaseBranch + } + + const defaultBaseRef = await args.resolveDefaultBaseRef() + const repoWorktreeBaseRef = args.repoWorktreeBaseRef + if (!repoWorktreeBaseRef) { + return defaultBaseRef + } + if (!defaultBaseRef) { + return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : null + } + // Resolving the default already proved matching persisted refs exist. + if (repoWorktreeBaseRef === defaultBaseRef) { + return repoWorktreeBaseRef + } + // Stale persisted refs fall back to the detected default; usable custom refs stay authoritative. + return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : defaultBaseRef +} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index c43a051c8ab..00aa3fe4fea 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -512,8 +512,8 @@ describe('useComposerState host-context boundaries', () => { expect(section).toContain('resolveWorktreeCreateBaseBranch') expect(section).toContain('explicitBaseBranch: smartSubmitBaseBranch') - expect(section).toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') - expect(section).toContain('getRuntimeRepoBaseRefDefault') + expect(section).not.toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') + expect(section).not.toContain('getRuntimeRepoBaseRefDefault') }) it('plans new workspace agent startup from the selected repo runtime', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dc6de85382b..33049241411 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -29,7 +29,6 @@ import { import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { resolveWorktreeCreateBaseBranch } from '@/runtime/worktree-create-base' import { buildTaskSourceContextFromRepo, @@ -3971,11 +3970,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) const submitBaseBranch = selectedRepoIsGit ? await resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: smartSubmitBaseBranch, - repoWorktreeBaseRef: selectedRepo.worktreeBaseRef, - loadDefaultBaseRef: async () => - (await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) - ?.defaultBaseRef + explicitBaseBranch: smartSubmitBaseBranch }) : undefined const createDisplayName = diff --git a/src/renderer/src/runtime/worktree-create-base.test.ts b/src/renderer/src/runtime/worktree-create-base.test.ts index 67f74558b3b..2ece667beef 100644 --- a/src/renderer/src/runtime/worktree-create-base.test.ts +++ b/src/renderer/src/runtime/worktree-create-base.test.ts @@ -1,42 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { resolveWorktreeCreateBaseBranch } from './worktree-create-base' describe('resolveWorktreeCreateBaseBranch', () => { - it('uses an explicit Start-from selection before repo defaults', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('uses an explicit Start-from selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: 'origin/feature', - repoWorktreeBaseRef: 'dev', - loadDefaultBaseRef + explicitBaseBranch: ' origin/feature ' }) ).resolves.toBe('origin/feature') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() }) - it('uses the pinned repo worktree base before resolving the git primary', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('omits repo defaults so backend create owns base selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: ' dev ', - loadDefaultBaseRef + explicitBaseBranch: undefined }) - ).resolves.toBe('dev') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() + ).resolves.toBeUndefined() }) - it('falls back to the git primary when no explicit or pinned base exists', async () => { + it('omits blank explicit selections', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: undefined, - loadDefaultBaseRef: vi.fn().mockResolvedValue('origin/main') + explicitBaseBranch: ' ' }) - ).resolves.toBe('origin/main') + ).resolves.toBeUndefined() }) }) diff --git a/src/renderer/src/runtime/worktree-create-base.ts b/src/renderer/src/runtime/worktree-create-base.ts index cb3f2fc62f8..d182e1c90a3 100644 --- a/src/renderer/src/runtime/worktree-create-base.ts +++ b/src/renderer/src/runtime/worktree-create-base.ts @@ -1,15 +1,6 @@ +// Backend base resolution owns stale-ref fallback, so the renderer only forwards explicit selections. export async function resolveWorktreeCreateBaseBranch(args: { explicitBaseBranch: string | undefined - repoWorktreeBaseRef: string | undefined - loadDefaultBaseRef: () => Promise }): Promise { - if (args.explicitBaseBranch) { - return args.explicitBaseBranch - } - const pinnedBaseRef = args.repoWorktreeBaseRef?.trim() - if (pinnedBaseRef) { - return pinnedBaseRef - } - const defaultBaseRef = (await args.loadDefaultBaseRef())?.trim() - return defaultBaseRef || undefined + return args.explicitBaseBranch?.trim() || undefined } From 863d94167c84cd403d423b86f9e7c9471214aca0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:19:28 -0700 Subject: [PATCH 214/233] Use activeWorkspaceKey to reveal active workspace in sidebar (#7406) Older folder-based workspaces are tracked by `activeWorkspaceKey` rather than the legacy `activeWorktreeId`. Deriving the active sidebar workspace ID from the workspace key enables the "Reveal active workspace" action to work correctly for both types of workspaces. --- .../src/components/sidebar/WorktreeList.tsx | 34 ++- tests/e2e/worktree-scroll-to-current.spec.ts | 264 +++++++++++++++++- 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2320889a3e3..dbf77ecf0f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -268,7 +268,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -405,6 +405,22 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } +// Why: folder workspaces are tracked by the scoped active key, while older +// worktree-only paths still read activeWorktreeId. +function getActiveSidebarWorkspaceId( + activeWorkspaceKey: string | null, + activeWorktreeId: string | null +): string | null { + const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null + if (scope?.type === 'folder') { + return folderWorkspaceKey(scope.folderWorkspaceId) + } + if (scope?.type === 'worktree') { + return scope.worktreeId + } + return activeWorktreeId +} + function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] @@ -5151,7 +5167,11 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const currentSidebarWorktreeId = activeWorktreeId + const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const currentSidebarWorktreeId = useMemo( + () => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId), + [activeWorkspaceKey, activeWorktreeId] + ) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) @@ -6642,31 +6662,31 @@ const WorktreeList = React.memo(function WorktreeList({ }) return } - if (!activeWorktreeId) { + if (!currentSidebarWorktreeId) { return } const activeWorktree = getKnownSidebarWorktreeById( - activeWorktreeId, + currentSidebarWorktreeId, worktreeMap, folderWorkspaces ) if (!activeWorktree || activeWorktree.isArchived) { return } - if (!renderedWorktreeIds.includes(activeWorktreeId)) { + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { // Why: the toolbar action promises to reveal the current workspace; when // sidebar filters hide it, relax those filters before queuing the reveal. clearFilters() } - revealWorktreeInSidebar(activeWorktreeId, { + revealWorktreeInSidebar(currentSidebarWorktreeId, { behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ - activeWorktreeId, clearFilters, + currentSidebarWorktreeId, folderWorkspaces, revealSidebarRow, renderedWorktreeIds, diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 2cbd0df4a08..91ae5e88142 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,11 +1,6 @@ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { worktreeRow } from './worktree-row-locators' - -function worktreeOption(page: Page, worktreeId: string) { - return worktreeRow(page, worktreeId) -} async function prepareSidebarForScrollTest(page: Page): Promise { await page.evaluate(() => { @@ -117,7 +112,9 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) await renderedOptions.last().click() @@ -175,10 +172,31 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRows = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const targetRow = targetRows.first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) - await renderedOptions.last().click() + await orcaPage.evaluate((targetId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const target = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === targetId) + if (!target) { + throw new Error(`Target workspace ${targetId} not found`) + } + store.setState({ + activeRepoId: target.repoId, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null + }) + }, targetId) await expect(targetRow).toHaveAttribute('aria-current', 'page') await orcaPage.evaluate(() => { @@ -190,7 +208,7 @@ test.describe('Reveal active workspace button', () => { }) await expect(renderedOptions).toHaveCount(0) - await expect(orcaPage.getByText('No workspaces found')).toBeVisible() + await expect(targetRows).toHaveCount(0) await revealButton.click() @@ -213,4 +231,232 @@ test.describe('Reveal active workspace button', () => { ) .toEqual([]) }) + + test('reveals the current workspace when it starts outside the virtualized window', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const targetId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] + const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `${repo.id}::/virtual-reveal-${suffix}`, + instanceId: `virtual-reveal-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-reveal-${suffix}`, + displayName: `Virtual reveal ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-reveal-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + const target = syntheticWorktrees.at(-1) + if (!target) { + throw new Error('Expected a synthetic target worktree') + } + + store.setState({ + activeRepoId: repo.id, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null, + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [repo.id]: [...seededWorktrees, ...syntheticWorktrees] + } + }) + return target.id + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const scroller = document.querySelector('[data-worktree-sidebar]') + return scroller?.scrollTop ?? null + }), + { timeout: 10_000, message: 'sidebar scroller did not mount' } + ) + .not.toBeNull() + + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, targetId), + { timeout: 10_000, message: 'target workspace should start outside the sidebar viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + + await revealButton.click() + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() + await expect(targetRow).toBeVisible() + await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + }) + + test('uses the active workspace key when the legacy active worktree id is not set', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const folderWorktreeId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const now = Date.now() + const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `reveal-folder-workspace-${suffix}`, + projectGroupId: 'reveal-folder-group', + name: `Reveal folder workspace ${suffix}`, + folderPath: `/tmp/reveal-folder-workspace-${suffix}`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1_000 - index, + lastActivityAt: now - index, + createdAt: now, + updatedAt: now + } + }) + const targetFolder = folderWorkspaces.at(-1) + if (!targetFolder) { + throw new Error('Expected a target folder workspace') + } + const folderWorktreeId = `folder:${targetFolder.id}` + store.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: folderWorktreeId, + projectGroups: [ + { + id: 'reveal-folder-group', + name: 'Reveal folder group', + parentPath: '/tmp/reveal-folder-group', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: now, + updatedAt: now + } + ], + folderWorkspaces + }) + store.getState().setGroupBy('repo') + return folderWorktreeId + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { timeout: 10_000, message: 'target folder workspace should start outside the viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + await revealButton.click() + + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { + timeout: 10_000, + message: 'Reveal button should scroll to the active folder workspace' + } + ) + .toBe(true) + }) }) From 331a30b4542bf355e3c5ab717239623f8c14d7c0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:50:43 -0700 Subject: [PATCH 215/233] Harden rate-limit PTY working directory and abort background usage probes on stop (#7403) * Harden rate-limit PTY cwd and abort background usage probes on stop Resolve a runaway CPU incident where background Claude rate-limit usage probes could inherit a root working directory (such as `/` or `C:\`) and index the entire disk. - Force hidden Claude and Codex usage PTYs to spawn inside a small, dedicated directory under user data or temp. - Thread AbortSignal through rate-limit fetchers to immediately kill and reap in-flight PTY processes when RateLimitService stops. - Harden daemon and local PTY defaults to reject root-like working directories for automated agent startups. * Consolidate PTY path safety checks to prevent runaway CPU Centralize `isRootLikePath` into a dedicated PTY path safety module to prevent terminal launches and hidden usage probes from executing in filesystem or drive roots, avoiding unbounded file discovery. - Centralize path validation supporting POSIX, Windows, and UNC roots. - Fail loudly if no safe default working directory is available. - Enforce bounded directories for hidden usage PTYs and WSL probes. - Check for aborted signals during rate-limit CLI repairs. * Gate agent startup on the effective PTY cwd after default fallback Co-authored-by: Orca * test(repos): verify origin/HEAD target in SSH base-ref tests Co-authored-by: Orca * Pin HOME to assert exact default PTY cwd in spawn test Pin `process.env.HOME` in the fallback test for LocalPtyProvider to assert the exact resolved candidate rather than just checking that it is non-root-like. This catches potential regressions where an unintended home directory is picked, and uses a try/finally block to safely restore the environment afterwards. --------- Co-authored-by: Orca --- src/main/daemon/pty-subprocess.test.ts | 97 ++++++ src/main/daemon/pty-subprocess.ts | 42 +-- src/main/ipc/repos-remote.test.ts | 12 + src/main/providers/local-pty-provider.test.ts | 47 +++ src/main/providers/local-pty-provider.ts | 29 +- src/main/providers/pty-default-cwd.test.ts | 77 +++++ src/main/providers/pty-default-cwd.ts | 43 +++ src/main/providers/pty-path-safety.ts | 17 + src/main/rate-limits/claude-fetcher.ts | 116 ++++++- src/main/rate-limits/claude-pty.test.ts | 41 +++ src/main/rate-limits/claude-pty.ts | 72 +++- src/main/rate-limits/codex-fetcher.test.ts | 139 ++++++-- src/main/rate-limits/codex-fetcher.ts | 131 ++++++- .../rate-limits/hidden-pty-cleanup.test.ts | 36 +- src/main/rate-limits/hidden-pty-cleanup.ts | 15 + .../rate-limits/hidden-rate-limit-pty-cwd.ts | 37 ++ src/main/rate-limits/service.test.ts | 200 +++++++++-- src/main/rate-limits/service.ts | 321 +++++++++++++----- 18 files changed, 1262 insertions(+), 210 deletions(-) create mode 100644 src/main/providers/pty-default-cwd.test.ts create mode 100644 src/main/providers/pty-default-cwd.ts create mode 100644 src/main/providers/pty-path-safety.ts create mode 100644 src/main/rate-limits/hidden-rate-limit-pty-cwd.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 85366a1377b..fccd92e9d53 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -527,6 +527,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', command: 'codex' }) @@ -1038,6 +1039,97 @@ describe('createPtySubprocess', () => { expect(shellArg.length).toBeGreaterThan(0) }) + it('allows an explicitly requested plain daemon shell at POSIX root', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: '/' }) + ) + }) + + it('rejects daemon automatic agent startup without an explicit cwd', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + command: 'codex' + }) + ).toThrow(/requires a non-root workspace/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects daemon automatic agent startup at POSIX root', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/', + command: 'claude' + }) + ).toThrow(/requires a non-root workspace/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects a missing explicit POSIX cwd before node-pty spawn', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + spawnMock.mockClear() + + try { + expect(() => + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/definitely-missing-orca-cwd' + }) + ).toThrow(/definitely-missing-orca-cwd/) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('passes custom env to spawned process', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) @@ -1238,6 +1330,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: 'codex', env: { SHELL: '/bin/zsh' } }) @@ -1264,6 +1357,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready', env: { SHELL: '/bin/zsh' } @@ -1291,6 +1385,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: '/repo', command: "codex --prefill 'linked issue context'", env: { SHELL: '/bin/zsh' } }) @@ -1560,6 +1655,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', shellOverride: 'powershell.exe', command: "& 'codex' '--no-alt-screen'" }) @@ -1589,6 +1685,7 @@ describe('createPtySubprocess', () => { sessionId: 'test', cols: 80, rows: 24, + cwd: 'C:\\repo\\orca', shellOverride: 'cmd.exe', command: `codex ${'x'.repeat(7000)}` }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6dd0554bb87..35e92157ba4 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -54,6 +54,7 @@ import { import { isShellProcess } from '../../shared/shell-process-detection' import { parsePtySessionId } from './pty-session-id' import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' +import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -93,20 +94,7 @@ export type PtySubprocessOptions = { * Returns a stable default working directory for daemon-spawned PTYs. */ function getDefaultCwd(): string { - if (process.platform !== 'win32') { - return process.env.HOME || '/' - } - - // Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with - // HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs - // still start in a valid absolute home directory. - if (process.env.USERPROFILE) { - return process.env.USERPROFILE - } - if (process.env.HOMEDRIVE && process.env.HOMEPATH) { - return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` - } - return 'C:\\' + return resolveSafePtyDefaultCwd() } /** @@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string { * Moves the daemon process to a stable cwd after its original cwd disappears. */ function repairDaemonCwd(): string | null { - const candidates = [ - process.env.ORCA_USER_DATA_PATH, - getDefaultCwd(), - process.platform === 'win32' ? 'C:\\' : '/' - ] + const candidates = [process.env.ORCA_USER_DATA_PATH] + try { + candidates.push(getDefaultCwd()) + } catch { + // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. + } + candidates.push(process.platform === 'win32' ? 'C:\\' : '/') for (const candidate of candidates) { if (isExistingDirectory(candidate)) { try { @@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: { validateWorkingDirectory(args.validationCwd) } +/** + * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT. + */ +function preflightPosixPtySpawnEnvironment(validationCwd: string): void { + if (process.platform === 'win32') { + return + } + validateWorkingDirectory(validationCwd) +} + /** * Wraps native PTY spawn failures with shell and cwd context. */ @@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + if (opts.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(opts.cwd, opts.command) + } const requestedCwd = opts.cwd || getDefaultCwd() let spawnCwd = requestedCwd let validationCwd = spawnCwd @@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // runs in a separate forked process with its own code path. ensureNodePtySpawnHelperExecutable() preflightUnixPtySpawnEnvironment() + preflightPosixPtySpawnEnvironment(validationCwd) preflightWindowsPtySpawnEnvironment({ validationCwd, cwdWasExplicit: opts.cwd !== undefined diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 0b29082bf1e..2a9a66df94d 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2441,6 +2441,12 @@ describe('repos:getBaseRefDefault envelope', () => { match: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, + // The origin/HEAD target is verified before it is trusted, so the + // symbolic-ref result must also resolve via rev-parse. + { + match: isRevParseFor('refs/remotes/origin/main'), + respond: () => Promise.resolve({ stdout: '', stderr: '' }) + }, { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) @@ -2471,6 +2477,12 @@ describe('repos:getBaseRefDefault envelope', () => { match: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, + // The origin/HEAD target is verified before it is trusted, so the + // symbolic-ref result must also resolve via rev-parse. + { + match: isRevParseFor('refs/remotes/origin/main'), + respond: () => Promise.resolve({ stdout: '', stderr: '' }) + }, { match: isRemoteList, respond: () => Promise.reject(new Error('relay exec failed')) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index d37faa0ac57..3fc96c897bf 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -82,6 +82,7 @@ vi.mock('../wsl', () => ({ })) import { LocalPtyProvider } from './local-pty-provider' +import { isRootLikePath } from './pty-path-safety' import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env' describe('LocalPtyProvider', () => { @@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => { ) }) + it('allows an explicitly requested plain shell at POSIX root', async () => { + await provider.spawn({ cols: 80, rows: 24, cwd: '/' }) + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: '/' }) + ) + }) + + it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => { + spawnMock.mockClear() + const origHome = process.env.HOME + // Pin HOME so we assert the exact resolved candidate, not just non-root-ness — + // catches regressions where resolveSafePtyDefaultCwd picks an unintended home. + process.env.HOME = '/home/testuser' + + try { + // Why: an omitted cwd resolves to a guaranteed-safe default home (the + // guard only rejects root-like paths), so the agent must still launch. + await expect( + provider.spawn({ cols: 80, rows: 24, command: 'codex' }) + ).resolves.toBeDefined() + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[2].cwd).toBe('/home/testuser') + expect(isRootLikePath(spawnCall[2].cwd)).toBe(false) + } finally { + if (origHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = origHome + } + } + }) + + it('rejects automatic agent startup at POSIX root', async () => { + spawnMock.mockClear() + + await expect( + provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' }) + ).rejects.toThrow(/requires a non-root workspace/) + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('invokes onSpawned callback', async () => { const onSpawned = vi.fn() provider.configure({ onSpawned }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7e8588a6b64..0d2eb19f1a6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process' import { getAgentForegroundContextPaths } from './agent-foreground-context-paths' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -88,20 +89,7 @@ const exitListeners = new Set() * Returns a stable default cwd for locally spawned PTYs. */ function getDefaultCwd(): string { - if (process.platform !== 'win32') { - return process.env.HOME || '/' - } - - // Why: USERPROFILE is not guaranteed in all Windows launch contexts. - // Falling back to bare HOMEPATH yields a drive-relative path, so combine - // HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home. - if (process.env.USERPROFILE) { - return process.env.USERPROFILE - } - if (process.env.HOMEDRIVE && process.env.HOMEPATH) { - return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` - } - return 'C:\\' + return resolveSafePtyDefaultCwd() } /** @@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider { } const id = allocatePtyId(reattachId ?? undefined) + const startupAgentRecognition = args.command + ? recognizeAgentProcessFromCommandLine(args.command) + : null + const defaultCwd = getDefaultCwd() const cwd = args.cwd || defaultCwd + // Why: gate on the effective cwd (post default-cwd fallback), not the raw + // args.cwd — an omitted cwd resolves to a safe default and must not be + // rejected as if it were a root-like path. + if (args.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(cwd, args.command) + } const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null const worktreeWslContext = process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined @@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider { finalEnv.ORCA_OMP_STATUS_EXTENSION || finalEnv.ORCA_CODEX_HOME || finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR - const isCodexStartupCommand = - recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex' + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' let shellLaunch: ReturnType | null = null if (args.command && isCodexStartupCommand) { const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({ diff --git a/src/main/providers/pty-default-cwd.test.ts b/src/main/providers/pty-default-cwd.test.ts new file mode 100644 index 00000000000..2bb0ef3a79a --- /dev/null +++ b/src/main/providers/pty-default-cwd.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn() +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +import { + assertSafeAgentStartupCwd, + isSafeImplicitPtyCwd, + resolveSafePtyDefaultCwd +} from './pty-default-cwd' + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +describe('pty default cwd safety', () => { + const originalPlatform = process.platform + + beforeEach(() => { + setPlatform(originalPlatform) + homedirMock.mockReset() + homedirMock.mockReturnValue('/home/orca') + }) + + it('rejects root-like implicit cwd values', () => { + expect(isSafeImplicitPtyCwd(undefined)).toBe(false) + expect(isSafeImplicitPtyCwd('')).toBe(false) + expect(isSafeImplicitPtyCwd('/')).toBe(false) + expect(isSafeImplicitPtyCwd('C:\\')).toBe(false) + expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false) + }) + + it('uses a non-root POSIX home and skips root env values', () => { + setPlatform('linux') + + expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice') + expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca') + }) + + it('throws instead of falling back to POSIX root', () => { + setPlatform('darwin') + homedirMock.mockReturnValue('/') + + expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow( + /No safe default working directory/ + ) + }) + + it('uses Windows home candidates without accepting drive roots', () => { + setPlatform('win32') + homedirMock.mockReturnValue('C:\\Users\\os-home') + + expect( + resolveSafePtyDefaultCwd({ + USERPROFILE: 'C:\\', + HOMEDRIVE: 'D:', + HOMEPATH: '\\Users\\alice' + }) + ).toBe('D:\\Users\\alice') + }) + + it('requires automatic agent startup commands to provide a non-root cwd', () => { + expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow( + /requires a non-root workspace/ + ) + expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/) + expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow() + }) +}) diff --git a/src/main/providers/pty-default-cwd.ts b/src/main/providers/pty-default-cwd.ts new file mode 100644 index 00000000000..e9a7b7a5a3b --- /dev/null +++ b/src/main/providers/pty-default-cwd.ts @@ -0,0 +1,43 @@ +import { homedir } from 'node:os' + +import { isRootLikePath } from './pty-path-safety' + +function homeDrivePath(env: NodeJS.ProcessEnv): string | null { + if (!env.HOMEDRIVE || !env.HOMEPATH) { + return null + } + return `${env.HOMEDRIVE}${env.HOMEPATH}` +} + +export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string { + return !isRootLikePath(path) +} + +export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string { + const candidates = + process.platform === 'win32' + ? [env.USERPROFILE, homeDrivePath(env), homedir()] + : [env.HOME, homedir()] + const selected = candidates.find(isSafeImplicitPtyCwd) + // Why: silently falling back to "/" or a drive root is the exact runaway-CPU + // bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud. + if (!selected) { + // Log the rejected candidates so the rare "no safe home at all" environment + // (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque. + console.error( + '[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.', + { platform: process.platform, candidates } + ) + throw new Error('No safe default working directory is available for terminal launch.') + } + return selected +} + +export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void { + if (isSafeImplicitPtyCwd(cwd)) { + return + } + throw new Error( + `Automatic agent startup command "${command}" requires a non-root workspace working directory.` + ) +} diff --git a/src/main/providers/pty-path-safety.ts b/src/main/providers/pty-path-safety.ts new file mode 100644 index 00000000000..71360e14ce1 --- /dev/null +++ b/src/main/providers/pty-path-safety.ts @@ -0,0 +1,17 @@ +import { posix, win32 } from 'node:path' + +// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a +// security boundary here — an empty, "/", drive-root, or network-share-root cwd +// triggers the unbounded file discovery this guard exists to prevent. Keep the +// detection in one place so a fix to one enforcement path can't skip the other. +export function isRootLikePath(path: string | null | undefined): boolean { + const trimmed = path?.trim() ?? '' + if (!trimmed) { + return true + } + // A filesystem root is its own parent. Check both POSIX and Windows semantics + // so a Windows drive/UNC root is still rejected when running on POSIX (and vice + // versa) — path.dirname alone only understands the host platform's separators. + const normalized = posix.normalize(trimmed) + return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed +} diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e302c90b6d1..d4dbad55fd8 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = { attemptedSources: UsageRateLimitSource[] } +function abortedClaudeRateLimitResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + function parseResetTimestamp(value: string | number | undefined): number | null { if (typeof value === 'number') { if (!Number.isFinite(value)) { @@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null ) } -async function fetchViaOAuth(token: string): Promise { +async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } await ensureProxyFromEnv() + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + const abortFromExternalSignal = (): void => { + controller.abort() + } + signal?.addEventListener('abort', abortFromExternalSignal, { once: true }) try { // Why: net.fetch uses Chromium's networking stack which respects OS proxy @@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise { } const data = (await res.json()) as OAuthUsageResponse + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } return { provider: 'claude', @@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise { error: null, status: 'ok' } + } catch (err) { + if (signal?.aborted) { + return abortedClaudeRateLimitResult() + } + throw err } finally { clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromExternalSignal) } } @@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: { oauthCredentials: OAuthCredentialReadResult attempts: ClaudeUsageAttemptState networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { recordAttempt(input.attempts, 'cli') const limits = await fetchViaPty({ authPreparation: input.authPreparation, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) return withClaudeUsageMetadata( limits, @@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: { attempts: ClaudeUsageAttemptState allowUsagePanelSupplement: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { - if (!canSupplementOAuthUsageFromCli(input)) { + if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) { return input.oauthLimits } try { @@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: { authPreparation: input.authPreparation, oauthCredentials: input.oauthCredentials, attempts: input.attempts, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) return mergeClaudeUsageWindows(input.oauthLimits, cliLimits) } catch (err) { @@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: { attempts: ClaudeUsageAttemptState oauthCredentials: OAuthCredentialReadResult }): Promise { + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } let cliResult: ProviderRateLimits | null = null try { cliResult = await fetchClaudeUsageViaCli({ authPreparation: input.options?.authPreparation, oauthCredentials: input.oauthCredentials, attempts: input.attempts, - networkProxySettings: input.options?.networkProxySettings + networkProxySettings: input.options?.networkProxySettings, + signal: input.options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err) } + // Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair. + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } + const refreshedCredentials = await readOAuthCredentials( resolveOAuthCredentialReadOptions(input.options?.authPreparation) ) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (refreshedCredentials.token) { recordAttempt(input.attempts, 'oauth') try { - const oauthRetry = await fetchViaOAuth(refreshedCredentials.token) + const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal) + if (input.options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult) return withClaudeUsageMetadata( supplemented, @@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = { allowPtyFallback?: boolean allowUsagePanelSupplement?: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal } export type FetchManagedAccountUsageOptions = { allowUsagePanelSupplement?: boolean networkProxySettings?: NetworkProxySettings + signal?: AbortSignal } export async function fetchClaudeRateLimits( options?: FetchClaudeRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const attempts: ClaudeUsageAttemptState = { attemptedSources: [] } const allowCliFallback = options?.allowPtyFallback !== false const plan = resolveClaudeUsageRefreshPlan({ @@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits( const oauthCredentials = await readOAuthCredentials( resolveOAuthCredentialReadOptions(options?.authPreparation) ) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { recordAttempt(attempts, 'oauth') try { - const oauthLimits = await fetchViaOAuth(oauthCredentials.token) + const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const limits = await supplementOAuthUsageFromCli({ oauthLimits, authPreparation: options?.authPreparation, @@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits( attempts, networkProxySettings: options?.networkProxySettings, allowUsagePanelSupplement: - options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation) + options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation), + signal: options?.signal }) + if (options?.signal?.aborted) { + return abortedClaudeRateLimitResult() + } return withClaudeUsageMetadata( limits, metadataForAttempt({ @@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (ptyError) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError) @@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) @@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits( authPreparation: options?.authPreparation, oauthCredentials, attempts, - networkProxySettings: options?.networkProxySettings + networkProxySettings: options?.networkProxySettings, + signal: options?.signal }) } catch (err) { warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err) @@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: { credentialsJson: string oauthLimits: ProviderRateLimits networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { + if (input.signal?.aborted) { + return null + } const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) if (!authPreparation) { return null @@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: { return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => { const cliLimits = await fetchViaPty({ authPreparation, - networkProxySettings: input.networkProxySettings + networkProxySettings: input.networkProxySettings, + signal: input.signal }) + if (input.signal?.aborted) { + return null + } if ( !canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, { requireMatchingOAuthWindow: input.location.kind === 'keychain' @@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage( account: InactiveClaudeAccountInfo, options: FetchManagedAccountUsageOptions = {} ): Promise { + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } const location = resolveManagedCredentialsLocation(account) let credentialsJson = location ? await readManagedCredentialsJson(location) : null + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (!location || !credentialsJson) { return { provider: 'claude', @@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage( let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token if (isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if (refreshed) { try { await writeManagedCredentialsJson(location, refreshed) @@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage( // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY // path is used only as a supplement after OAuth succeeds, and it points // directly at the managed account's isolated config so selection is unchanged. - const oauthLimits = await fetchViaOAuth(token) + const oauthLimits = await fetchViaOAuth(token, options.signal) + if (options.signal?.aborted) { + return abortedClaudeRateLimitResult() + } if ( !canSupplementOAuthUsageFromCli({ oauthLimits, @@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage( location, credentialsJson, oauthLimits, - networkProxySettings: options.networkProxySettings + networkProxySettings: options.networkProxySettings, + signal: options.signal }) return mergeClaudeUsageWindows(oauthLimits, cliLimits) } catch (err) { diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index f6f4d22e6a0..f2fd976821b 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({ })) import { fetchViaPty } from './claude-pty' +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' function makeDisposable() { return { dispose: vi.fn() } @@ -97,6 +98,22 @@ describe('fetchViaPty', () => { await resultPromise }) + it('spawns the hidden usage PTY in a bounded non-root cwd', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + await vi.advanceTimersByTimeAsync(0) + + const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/) + + term.emitExit() + await resultPromise + }) + it('leaves the spawn env proxy untouched when no proxy is configured', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) @@ -135,6 +152,8 @@ describe('fetchViaPty', () => { const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]] expect(spawnFile).toBe('wsl.exe') const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'") expect(bashCommand).toContain('exec claude') @@ -142,6 +161,28 @@ describe('fetchViaPty', () => { await resultPromise }) + it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + const controller = new AbortController() + const killMock = term.kill + + const resultPromise = fetchViaPty({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(killMock).toHaveBeenCalledTimes(1) + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + it('clears the startup delay timer when the hidden PTY exits early', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f2000489275..88301b9c9dd 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' +import { + getHiddenRateLimitWslCwdSetupCommands, + resolveHiddenRateLimitPtyCwd +} from './hidden-rate-limit-pty-cwd' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string { return 'Claude usage is unavailable right now.' } +function abortedClaudeUsageResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + export async function fetchViaPty(options?: { authPreparation?: ClaudeRuntimeAuthPreparation networkProxySettings?: NetworkProxySettings + signal?: AbortSignal }): Promise { + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedClaudeUsageResult() + } return new Promise((resolve) => { let output = '' @@ -251,10 +273,13 @@ export async function fetchViaPty(options?: { // Why: Windows-side env does not cross into the distro without WSLENV, // so export the configured proxy inside the command for the inner claude. [ + // Why: hidden usage probes must not inherit a root-like WSL cwd; + // keep Claude discovery bounded to a tiny temp directory. + ...getHiddenRateLimitWslCwdSetupCommands(), `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`, ...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`), 'exec claude' - ].join('; ') + ].join(' && ') ] : isWin32 ? ['/c', `"${claudeCommand}"`] @@ -264,10 +289,14 @@ export async function fetchViaPty(options?: { name: 'xterm-256color', cols: 120, rows: 40, + // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a + // drive root), which can trigger unbounded file discovery. + cwd: resolveHiddenRateLimitPtyCwd(), env: spawnEnv }) - const termDisposables: { dispose: () => void }[] = [] + const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] let enterInterval: ReturnType | null = null + let timeout: ReturnType | null = null function clearFollowupTimers(): void { if (startupDelayTimer) { @@ -288,7 +317,32 @@ export async function fetchViaPty(options?: { } } - const timeout = setTimeout(() => { + function settleAborted(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + clearFollowupTimers() + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve(abortedClaudeUsageResult()) + } + + if (options?.signal) { + if (options.signal.aborted) { + settleAborted() + return + } + options.signal.addEventListener('abort', settleAborted, { once: true }) + termDisposables.push({ + dispose: () => options.signal?.removeEventListener('abort', settleAborted) + }) + } + + timeout = setTimeout(() => { if (!resolved) { resolved = true clearFollowupTimers() @@ -342,7 +396,10 @@ export async function fetchViaPty(options?: { return } resolved = true - clearTimeout(timeout) + if (timeout) { + clearTimeout(timeout) + timeout = null + } clearFollowupTimers() cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) @@ -443,7 +500,10 @@ export async function fetchViaPty(options?: { clearFollowupTimers() if (!resolved) { resolved = true - clearTimeout(timeout) + if (timeout) { + clearTimeout(timeout) + timeout = null + } const clean = stripTerminalControlSequences(output) const { session, weekly, fableWeekly } = parsePtyUsage(clean) resolve({ diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 4c7221e9e96..daaec0ce7fd 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({ import { fetchCodexRateLimits } from './codex-fetcher' import { codexAuthExists } from './codex-auth-presence' +import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' function makeDisposable() { return { dispose: vi.fn() } @@ -52,6 +53,25 @@ function makeRpcChild() { return child } +function makePtyTerm() { + let dataHandler: ((data: string) => void) | null = null + let exitHandler: (() => void) | null = null + return { + onData: vi.fn((callback: (data: string) => void) => { + dataHandler = callback + return makeDisposable() + }), + onExit: vi.fn((callback: () => void) => { + exitHandler = callback + return makeDisposable() + }), + write: vi.fn(), + kill: vi.fn(), + emitData: (data: string) => dataHandler?.(data), + emitExit: () => exitHandler?.() + } +} + describe('fetchCodexRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => { ) }) + it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + + const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) + + rpcChild.emit('close') + await resultPromise + }) + + it('spawns the PTY fallback in a bounded non-root cwd', async () => { + const term = makePtyTerm() + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue(term) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string + expect(spawnCwd).toContain('rate-limit-pty-cwd') + expect(spawnCwd).not.toBe('/') + expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/) + + term.emitExit() + await resultPromise + }) + + it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + const controller = new AbortController() + + const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(rpcChild.kill).toHaveBeenCalledTimes(1) + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + + it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => { + const term = makePtyTerm() + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue(term) + const controller = new AbortController() + const killMock = term.kill + + const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + controller.abort() + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(killMock).toHaveBeenCalledTimes(1) + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + it('falls back to the PTY status reader when RPC exits before returning usage', async () => { const rpcChild = makeRpcChild() const ptyHandlers: { onData?: (data: string) => void } = {} @@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => { await vi.advanceTimersByTimeAsync(1) await resultPromise - expect(childSpawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-lc', - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'" - ], + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('wsl.exe') + const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') + expect(bashCommand).toContain( + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + ) + expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(spawnOptions).toEqual( expect.objectContaining({ + cwd: expect.stringContaining('rate-limit-pty-cwd'), env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) }) ) @@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => { rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) - expect(ptySpawnMock).toHaveBeenCalledWith( - 'wsl.exe', - [ - '-d', - 'Ubuntu', - '--', - 'bash', - '-lc', - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex " - ], + const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] + expect(spawnFile).toBe('wsl.exe') + const bashCommand = spawnArgs.at(-1) as string + expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') + expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') + expect(bashCommand).toContain( + "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + ) + expect(bashCommand).toContain('exec codex ') + expect(spawnOptions).toEqual( expect.objectContaining({ + cwd: expect.stringContaining('rate-limit-pty-cwd'), env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) }) ) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 439c5438d2f..8ffbd9c8d95 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence' import { resolveCodexCommand } from '../codex-cli/command' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + getHiddenRateLimitWslCwdSetupCommands, + resolveHiddenRateLimitPtyCwd +} from './hidden-rate-limit-pty-cwd' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null allowPtyFallback?: boolean + signal?: AbortSignal } // --------------------------------------------------------------------------- @@ -117,9 +122,10 @@ function buildWslCodexCommand( return null } const script = [ + ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, `exec codex ${args.map(shellQuote).join(' ')}` - ].join('; ') + ].join(' && ') return { command: 'wsl.exe', args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] @@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string { return status?.toLowerCase() ?? 'unknown' } +function abortedCodexRateLimitResult(): ProviderRateLimits { + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} + function getNextAvailableCreditExpiry( credits: RateLimitResetCredits['credits'] | undefined ): number | null { @@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits( async function getCodexBackendAuthHeaders( options?: FetchCodexRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return null + } const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json') const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile const accessToken = auth.tokens?.access_token @@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders( async function fetchBackendRateLimitResetCredits( options?: FetchCodexRateLimitsOptions ): Promise { + if (options?.signal?.aborted) { + return null + } const auth = await getCodexBackendAuthHeaders(options) if (!auth) { return null } + if (options?.signal?.aborted) { + return null + } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. - const response = await fetch( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', - auth - ) + const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { + ...auth, + signal: options?.signal + }) if (!response.ok) { return null } @@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits( options?: FetchCodexRateLimitsOptions ): Promise { if ( + options?.signal?.aborted || limits.provider !== 'codex' || (limits.rateLimitResetCredits?.nextExpiresAt !== undefined && limits.rateLimitResetCredits.nextExpiresAt !== null) @@ -373,6 +400,9 @@ function mapRpcWindow( // --------------------------------------------------------------------------- async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } return new Promise((resolve) => { let buffer = '' let stderr = '' @@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { settle( { @@ -632,7 +676,13 @@ function parsePtyStatus(output: string): { } async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const pty = await import('node-pty') + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null const codexCommand = wslCodex ? 'codex' : resolveCodexCommand() @@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise | null = null + let timeout: ReturnType | null = null const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', cols: 120, rows: 40, + cwd: resolveHiddenRateLimitPtyCwd(), env: { ...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env), TERM: 'xterm-256color', ...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {}) } }) - const termDisposables: { dispose: () => void }[] = [] + const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] - const timeout = setTimeout(() => { + function settleAborted(): void { + if (resolved) { + return + } + resolved = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) + resolve(abortedCodexRateLimitResult()) + } + + if (options?.signal) { + if (options.signal.aborted) { + settleAborted() + return + } + options.signal.addEventListener('abort', settleAborted, { once: true }) + termDisposables.push({ + dispose: () => options.signal?.removeEventListener('abort', settleAborted) + }) + } + + timeout = setTimeout(() => { if (!resolved) { resolved = true if (settleTimer) { @@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } // Why: never spawn the `codex` binary unless the user has signed in. Without // auth the RPC/PTY paths can only error, and spawning them shows up as an // unexpected background Codex process for users who don't use Codex. @@ -790,8 +879,12 @@ export async function fetchCodexRateLimits( // Path A: try RPC first try { const rpcResult = await fetchViaRpc(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') { - return await withBackendRateLimitResetCredits(rpcResult, options) + const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits } if (isCodexAuthError(rpcResult.error)) { return rpcResult @@ -802,6 +895,9 @@ export async function fetchCodexRateLimits( // Why: app-server can fail independently of the interactive CLI. Keep the // status-bar useful by trying the older /status PTY reader on RPC errors. } catch { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } if (options?.allowPtyFallback === false) { return { provider: 'codex', @@ -817,8 +913,19 @@ export async function fetchCodexRateLimits( // Path B: PTY fallback try { - return await withBackendRateLimitResetCredits(await fetchViaPty(options), options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + const ptyResult = await fetchViaPty(options) + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } + const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options) + return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits } catch (err) { + if (options?.signal?.aborted) { + return abortedCodexRateLimitResult() + } const message = err instanceof Error ? err.message : 'Unknown error' const isNotInstalled = message.includes('ENOENT') return { diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index 094931a97b0..43e8453fa0f 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup' +import { + cleanupHiddenRateLimitPty, + getActiveHiddenRateLimitPtyCount, + registerHiddenRateLimitPty +} from './hidden-pty-cleanup' function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => { expect(term.kill).toHaveBeenCalledTimes(1) expect(term.destroy).toHaveBeenCalledTimes(1) }) + + it('removes registered hidden PTYs when cleanup kills them', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: true }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) + + it('removes registered hidden PTYs after natural exit cleanup', () => { + setPlatform('darwin') + const term = { + kill: vi.fn(), + destroy: vi.fn() + } + const registration = registerHiddenRateLimitPty(term) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(1) + + cleanupHiddenRateLimitPty(term, [registration], { kill: false }) + + expect(getActiveHiddenRateLimitPtyCount()).toBe(0) + }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 6d85421203c..068b937ee84 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -7,6 +7,21 @@ type Disposable = { dispose: () => void } +const activeHiddenRateLimitPtys = new Set() + +export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { + activeHiddenRateLimitPtys.add(term) + return { + dispose: () => { + activeHiddenRateLimitPtys.delete(term) + } + } +} + +export function getActiveHiddenRateLimitPtyCount(): number { + return activeHiddenRateLimitPtys.size +} + export function cleanupHiddenRateLimitPty( term: HiddenPty, disposables: Disposable[], diff --git a/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts new file mode 100644 index 00000000000..bb78f041bd5 --- /dev/null +++ b/src/main/rate-limits/hidden-rate-limit-pty-cwd.ts @@ -0,0 +1,37 @@ +import { mkdirSync, realpathSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { isRootLikePath } from '../providers/pty-path-safety' + +const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd' +const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd' + +// Why: the hidden usage PTY must run in a bounded, never-root directory so +// Claude's discovery cannot walk a whole filesystem — reject a root-like user +// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md). +function resolveUserDataRoot(userDataPath?: string | null): string { + const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim() + if (root && !isRootLikePath(root)) { + return root + } + return join(tmpdir(), 'orca-rate-limit-pty') +} + +export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string { + const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR) + mkdirSync(cwd, { recursive: true }) + const realCwd = realpathSync(cwd) + if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) { + throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`) + } + return realCwd +} + +export function getHiddenRateLimitWslCwdSetupCommands(): string[] { + return [ + `orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`, + 'mkdir -p "$orca_rate_limit_cwd"', + 'cd "$orca_rate_limit_cwd"' + ] +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 5af9abca04f..1b810859ce7 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -333,6 +333,110 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + it('aborts the active fetch cycle and clears queued refreshes on stop', async () => { + const service = new RateLimitService() + const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {} + + vi.mocked(fetchClaudeRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.claude = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('claude', 'aborted')), + { once: true } + ) + }) + ) + vi.mocked(fetchCodexRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.codex = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('codex', 'aborted')), + { once: true } + ) + }) + ) + + const activeFetch = serviceInternals(service).fetchAll() + await Promise.resolve() + await Promise.resolve() + + const queuedRefresh = service.refresh() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.claude?.aborted).toBe(true) + expect(capturedSignals.codex?.aborted).toBe(true) + + await queuedRefresh + await activeFetch + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + }) + + it('aborts inactive Claude preview fetches on stop', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + const capturedSignals: { claude?: AbortSignal } = {} + service.setInactiveClaudeAccountsResolver(() => [account]) + vi.mocked(fetchManagedAccountUsage).mockImplementation( + (_account, options) => + new Promise((resolve) => { + capturedSignals.claude = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('claude', 'aborted')), + { once: true } + ) + }) + ) + + const previewFetch = service.fetchInactiveClaudeAccountsOnOpen() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.claude?.aborted).toBe(true) + + await previewFetch + + expect(service.getState().inactiveClaudeAccounts).toEqual([]) + }) + + it('aborts inactive Codex preview fetches on stop', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' } + const capturedSignals: { codex?: AbortSignal } = {} + service.setInactiveCodexAccountsResolver(() => [account]) + vi.mocked(fetchCodexRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.codex = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('codex', 'aborted')), + { once: true } + ) + }) + ) + + const previewFetch = service.fetchInactiveCodexAccountsOnOpen() + await Promise.resolve() + + service.stop() + + expect(capturedSignals.codex?.aborted).toBe(true) + + await previewFetch + + expect(service.getState().inactiveCodexAccounts).toEqual([]) + }) + it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ @@ -351,11 +455,14 @@ describe('RateLimitService', () => { await service.refresh() expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: undefined, + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) @@ -453,16 +560,19 @@ describe('RateLimitService', () => { await service.refresh() expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ - runtime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxConfigDir: '/home/jin/.claude', - stripAuthEnv: true - }), - allowPtyFallback: true, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + stripAuthEnv: true + }), + allowPtyFallback: true, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) @@ -483,11 +593,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ provenance: 'system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { @@ -498,11 +611,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: undefined, - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: undefined, + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { @@ -523,11 +639,14 @@ describe('RateLimitService', () => { await service.refresh() - expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ - authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), - allowPtyFallback: false, - allowUsagePanelSupplement: true - }) + expect(fetchClaudeRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false, + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not cache host Codex usage under an outgoing WSL account', async () => { @@ -604,10 +723,13 @@ describe('RateLimitService', () => { await service.fetchInactiveCodexAccountsOnOpen() - expect(fetchCodexRateLimits).toHaveBeenCalledWith({ - codexHomePath: wslCodexHome, - allowPtyFallback: false - }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + codexHomePath: wslCodexHome, + allowPtyFallback: false, + signal: expect.any(AbortSignal) + }) + ) expect(service.getState().inactiveCodexAccounts).toEqual([ { accountId: 'account-1', @@ -629,9 +751,13 @@ describe('RateLimitService', () => { await service.fetchInactiveClaudeAccountsOnOpen() - expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, { - allowUsagePanelSupplement: true - }) + expect(fetchManagedAccountUsage).toHaveBeenCalledWith( + account, + expect.objectContaining({ + allowUsagePanelSupplement: true, + signal: expect.any(AbortSignal) + }) + ) }) it('does not start overlapping inactive Codex preview fetches', async () => { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index e3befa61404..840f4e4a788 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -103,6 +103,7 @@ export class RateLimitService { private fullFetchQueued = false private codexOnlyFetchQueued = false private claudeOnlyFetchQueued = false + private activeFetchAbortControllers = new Set() private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 @@ -221,6 +222,11 @@ export class RateLimitService { } stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() this.stopTimer() this.clearDeferredStartupRefresh() this.detachWindowListeners?.() @@ -369,30 +375,18 @@ export class RateLimitService { return } const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal for (const account of accounts) { this.inactiveClaudeFetching.add(account.id) } this.pushToRenderer() - for (const account of accounts) { - if ( - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() - }) + try { + for (const account of accounts) { if ( + signal.aborted || fetchGeneration !== this.inactiveClaudeAccountsGeneration || !this.isCurrentInactiveClaudeAccount(account.id) ) { @@ -403,24 +397,46 @@ export class RateLimitService { this.pushToRenderer() continue } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one Keychain rejection or - // network error from aborting the remaining accounts in the batch. - if ( - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one Keychain rejection or + // network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - if (fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) } } @@ -439,36 +455,18 @@ export class RateLimitService { // Why: account switching can make a previewed account active while its // RPC-only usage fetch is still in flight; stale results must be ignored. const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal for (const account of accounts) { this.inactiveCodexFetching.add(account.id) } this.pushToRenderer() - for (const account of accounts) { - if ( - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - // Why: fetchCodexRateLimits already accepts codexHomePath, so we can - // point it at the managed account's home directory directly without - // materializing credentials into the shared runtime location. - // Why: opening the account switcher should never start hidden PTYs for - // every inactive account. On Windows that fallback can crash inside - // ConPTY; RPC-only is enough for this non-critical preview surface. - const fresh = await fetchCodexRateLimits({ - codexHomePath: account.managedHomePath, - allowPtyFallback: false - }) + try { + for (const account of accounts) { if ( + signal.aborted || fetchGeneration !== this.inactiveCodexAccountsGeneration || !this.isCurrentInactiveCodexAccount(account.id) ) { @@ -479,23 +477,51 @@ export class RateLimitService { this.pushToRenderer() continue } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) + try { + // Why: fetchCodexRateLimits already accepts codexHomePath, so we can + // point it at the managed account's home directory directly without + // materializing credentials into the shared runtime location. + // Why: opening the account switcher should never start hidden PTYs for + // every inactive account. On Windows that fallback can crash inside + // ConPTY; RPC-only is enough for this non-critical preview surface. + const fresh = await fetchCodexRateLimits({ + codexHomePath: account.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - if (fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) } } @@ -644,8 +670,13 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchAllCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false shouldContinue = true @@ -653,11 +684,21 @@ export class RateLimitService { } if (this.codexOnlyFetchQueued) { this.codexOnlyFetchQueued = false - await this.runFetchCodexOnlyCycle() + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } } if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false - await this.runFetchClaudeOnlyCycle() + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) + if (claudeSignal.aborted) { + break + } } } } finally { @@ -679,11 +720,21 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchCodexOnlyCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false - await this.runFetchAllCycle() + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) + if (fullSignal.aborted) { + break + } continue } if (this.codexOnlyFetchQueued) { @@ -692,7 +743,12 @@ export class RateLimitService { } if (this.claudeOnlyFetchQueued) { this.claudeOnlyFetchQueued = false - await this.runFetchClaudeOnlyCycle() + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) + if (claudeSignal.aborted) { + break + } } } } finally { @@ -714,11 +770,21 @@ export class RateLimitService { try { let shouldContinue = true while (shouldContinue) { - await this.runFetchClaudeOnlyCycle() + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) shouldContinue = false + if (signal.aborted) { + break + } if (this.fullFetchQueued) { this.fullFetchQueued = false - await this.runFetchAllCycle() + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) + if (fullSignal.aborted) { + break + } continue } if (this.claudeOnlyFetchQueued) { @@ -727,7 +793,12 @@ export class RateLimitService { } if (this.codexOnlyFetchQueued) { this.codexOnlyFetchQueued = false - await this.runFetchCodexOnlyCycle() + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } } } } finally { @@ -769,6 +840,49 @@ export class RateLimitService { } } + private beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + private finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + private async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + private abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + private clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + } + + private resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + private isSameCodexTarget( left: NormalizedCodexAccountSelectionTarget, right: NormalizedCodexAccountSelectionTarget @@ -850,9 +964,15 @@ export class RateLimitService { return { ...current, status: 'fetching' } } - private async runFetchAllCycle(): Promise { + private async runFetchAllCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const claudeTarget = this.claudeFetchTarget const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const codexTarget = this.codexFetchTarget @@ -898,18 +1018,24 @@ export class RateLimitService { authPreparation: claudeAuthPreparation, allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() + networkProxySettings: this.networkProxySettingsResolver?.(), + signal }), missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback() + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), fetchKimiRateLimits() ]) + if (signal.aborted) { + return + } + const claude = claudeResult.status === 'fulfilled' ? claudeResult.value @@ -979,6 +1105,9 @@ export class RateLimitService { const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = @@ -1013,7 +1142,10 @@ export class RateLimitService { this.lastFetchAt = Date.now() } - private async runFetchCodexOnlyCycle(): Promise { + private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const codexTarget = this.codexFetchTarget const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) @@ -1033,7 +1165,8 @@ export class RateLimitService { ? Promise.resolve(missingWslCodexHome) : fetchCodexRateLimits({ codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback() + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal }) ).catch( (err): ProviderRateLimits => ({ @@ -1046,6 +1179,10 @@ export class RateLimitService { }) ) + if (signal.aborted) { + return + } + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath) const shouldApplyCodex = @@ -1059,9 +1196,15 @@ export class RateLimitService { this.lastFetchAt = Date.now() } - private async runFetchClaudeOnlyCycle(): Promise { + private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } const claudeTarget = this.claudeFetchTarget const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const claudeGeneration = this.claudeFetchGeneration const previousState = this.state @@ -1075,7 +1218,8 @@ export class RateLimitService { authPreparation: claudeAuthPreparation, allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.() + networkProxySettings: this.networkProxySettingsResolver?.(), + signal }).catch( (err): ProviderRateLimits => ({ provider: 'claude', @@ -1087,7 +1231,14 @@ export class RateLimitService { }) ) + if (signal.aborted) { + return + } + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const shouldApplyClaude = claudeGeneration === this.claudeFetchGeneration && From e94c83d164bc448b415e5cc165f9efdd723c28e7 Mon Sep 17 00:00:00 2001 From: Avichal Dwivedi <150160561+AvichalDwivedi2205@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:26:55 +0530 Subject: [PATCH 216/233] fix(ai-vault): make SSH session history host-aware (#7367) * fix(ai-vault): scan sessions by execution host * fix(ai-vault): route history resume by host * test(e2e): cover SSH AI Vault history * Generalize remote session scanning for all AI Vault agents Replace the Codex-only remote SSH session history scanner with a unified scanner supporting all registered agents. This ensures remote transcripts for Claude, Gemini, Devin, Droid, and others are scanned and listed alongside local history. - Propagate host metadata (host ID and platform) to scanned sessions - Scope remote actions by host, disabling local OS path actions on remote session logs - Resolve ambiguous project/worktree matching for overlapping paths by verifying matching host setup IDs - Update tests and E2E specs to validate multi-agent remote scanning --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../remote-session-scanner-codex-index.ts | 51 +++ .../remote-session-scanner-sources.ts | 221 +++++++++++ .../ai-vault/remote-session-scanner-types.ts | 34 ++ .../ai-vault/remote-session-scanner.test.ts | 344 ++++++++++++++++++ src/main/ai-vault/remote-session-scanner.ts | 296 +++++++++++++++ .../ai-vault/session-scanner-accumulator.ts | 15 +- .../ai-vault/session-scanner-agent-parser.ts | 7 +- .../ai-vault/session-scanner-codex-parser.ts | 67 +++- .../ai-vault/session-scanner-devin-parser.ts | 19 +- .../ai-vault/session-scanner-droid-parser.ts | 120 ++++++ .../ai-vault/session-scanner-graph-parsers.ts | 112 ++---- .../session-scanner-primary-parsers.ts | 101 ++++- .../session-scanner-secondary-parsers.ts | 93 ++++- src/main/ai-vault/session-scanner-types.ts | 2 + src/main/ai-vault/session-scanner.ts | 36 +- src/main/ipc/ai-vault.test.ts | 163 +++++++++ src/main/ipc/ai-vault.ts | 174 ++++++++- src/main/ipc/ssh.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 24 ++ .../components/right-sidebar/AiVaultPanel.tsx | 17 +- .../right-sidebar/AiVaultPanelControls.tsx | 62 +++- .../right-sidebar/AiVaultPanelHeader.tsx | 14 +- .../AiVaultSessionActionMenuItems.tsx | 108 ++++++ .../right-sidebar/AiVaultSessionDetails.tsx | 32 +- .../right-sidebar/AiVaultSessionRow.tsx | 111 +----- .../AiVaultSessionVirtualList.tsx | 11 +- .../SessionRowTrailingActions.tsx | 6 +- .../right-sidebar/ai-vault-host-scope.test.ts | 120 ++++++ .../right-sidebar/ai-vault-host-scope.ts | 64 ++++ .../ai-vault-original-pane.test.ts | 1 + .../ai-vault-session-display.test.ts | 1 + .../ai-vault-session-filters.test.ts | 1 + .../ai-vault-session-launch-actions.ts | 26 +- .../ai-vault-session-path-actions.test.ts | 14 + .../ai-vault-session-path-actions.ts | 13 + .../ai-vault-session-projects.test.ts | 30 +- .../ai-vault-session-projects.ts | 57 ++- .../ai-vault-session-refresh.test.ts | 80 +++- .../right-sidebar/ai-vault-session-refresh.ts | 156 ++++---- .../right-sidebar/ai-vault-session-resume.ts | 21 +- .../ai-vault-session-worktree.test.ts | 65 +++- .../ai-vault-session-worktree.ts | 33 +- .../tab-group/AiVaultSessionDropLayer.tsx | 13 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../src/lib/ai-vault-resume-command.test.ts | 107 ++++++ .../src/lib/ai-vault-resume-command.ts | 23 +- .../src/lib/ai-vault-resume-target.test.ts | 74 ++++ .../src/lib/ai-vault-resume-target.ts | 82 +++++ src/renderer/src/lib/ai-vault-session-drag.ts | 16 +- src/shared/ai-vault-types.ts | 5 + .../e2e/ssh-ai-vault-session-history.spec.ts | 293 +++++++++++++++ 55 files changed, 3197 insertions(+), 403 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-codex-index.ts create mode 100644 src/main/ai-vault/remote-session-scanner-sources.ts create mode 100644 src/main/ai-vault/remote-session-scanner-types.ts create mode 100644 src/main/ai-vault/remote-session-scanner.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner.ts create mode 100644 src/main/ai-vault/session-scanner-droid-parser.ts create mode 100644 src/main/ipc/ai-vault.test.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts create mode 100644 tests/e2e/ssh-ai-vault-session-history.spec.ts diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts new file mode 100644 index 00000000000..d6a305ba4cf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -0,0 +1,51 @@ +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +export async function remoteCodexIndexTitles(args: { + provider: IFilesystemProvider + codexHome: string + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +}): Promise> { + const cached = args.titleCaches.get(args.codexHome) + if (cached) { + return cached + } + const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) + args.titleCaches.set(args.codexHome, pending) + return pending +} + +async function readRemoteCodexIndexTitles( + provider: IFilesystemProvider, + codexHome: string, + hostPlatform: RemoteHostPlatform +): Promise> { + const titleBySessionId = new Map() + try { + const { content, isBinary } = await provider.readFile( + joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) + ) + if (isBinary) { + return titleBySessionId + } + for (const line of content.split(/\r?\n/)) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex indexes are opportunistic; raw transcripts remain sufficient. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts new file mode 100644 index 00000000000..f232b0adf81 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -0,0 +1,221 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseCodexSessionContent } from './session-scanner-codex-parser' +import { parseDevinSessionContent } from './session-scanner-devin-parser' +import { parseDroidSessionContent } from './session-scanner-droid-parser' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + parseClaudeSessionContent, + parseGeminiSessionContent +} from './session-scanner-primary-parsers' +import { + parseCopilotSessionContent, + parseCursorSessionContent, + parseHermesSessionContent +} from './session-scanner-secondary-parsers' +import type { FileWithMtime } from './session-scanner-types' +import { normalizePiSessionsDir } from './session-scanner-values' +import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import type { + RemoteParserOptions, + RemoteScannerContext, + RemoteSessionSource +} from './remote-session-scanner-types' + +type RemoteContentParser = ( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +) => Promise | AiVaultSession | null + +export function remoteSessionSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + ...remoteCodexSources(remoteHome, hostPlatform), + jsonlSource( + 'claude', + remoteHome, + hostPlatform, + ['.claude', 'projects'], + parseClaudeSessionContent + ), + source( + 'gemini', + remoteHome, + hostPlatform, + ['.gemini', 'tmp'], + ['.json', '.jsonl'], + parseGeminiSessionContent + ), + jsonlSource( + 'copilot', + remoteHome, + hostPlatform, + ['.copilot', 'session-state'], + parseCopilotSessionContent + ), + jsonlSource( + 'cursor', + remoteHome, + hostPlatform, + ['.cursor', 'projects'], + parseCursorSessionContent, + (path) => remotePathSegments(path).includes('agent-transcripts') + ), + source( + 'hermes', + remoteHome, + hostPlatform, + ['.hermes', 'sessions'], + ['.json'], + parseHermesSessionContent + ), + source( + 'devin', + remoteHome, + hostPlatform, + ['.local', 'share', 'devin', 'cli', 'transcripts'], + ['.json'], + parseDevinSessionContent + ), + jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'sessions'], + parseDroidSessionContent + ), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'projects'], + parseDroidSessionContent + ), + ...remoteOpenClawSources(remoteHome, hostPlatform) + ] +} + +function source( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + extensions: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return { + agent, + rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), + extensions, + filePredicate, + parse: (file, content, context) => + Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) + } +} + +function jsonlSource( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate) +} + +function remoteCodexSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + joinRemotePath(hostPlatform, remoteHome, '.codex'), + joinRemotePath( + hostPlatform, + remoteHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + ].map((codexHome) => ({ + agent: 'codex', + rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + extensions: ['.jsonl'], + parse: (file, content, context) => + parseCodexSessionContent({ + file, + content, + platform: context.hostPlatform.os, + codexHome, + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os, + readIndexedTitle: async (sessionId) => + ( + await remoteCodexIndexTitles({ + provider: context.provider, + codexHome, + hostPlatform, + titleCaches: context.titleCaches + }) + ).get(sessionId) ?? null + }) + })) +} + +function remoteOpenClawSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return ['.openclaw', '.clawdbot'].map((rootName) => + jsonlSource( + 'openclaw', + remoteHome, + hostPlatform, + [rootName, 'agents'], + openClawParser, + (path) => remotePathSegments(path).includes('sessions') + ) + ) +} + +function parserOptions(context: RemoteScannerContext): RemoteParserOptions { + return { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + } +} + +function piParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('pi', file, content, platform, options) +} + +function openClawParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('openclaw', file, content, platform, options) +} + +function remotePathSegments(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean) +} + +function remotePiSessionsSegments(): string[] { + return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts new file mode 100644 index 00000000000..d5a4b2f1acf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -0,0 +1,34 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { FileWithMtime } from './session-scanner-types' + +export type RemoteScannerContext = { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +} + +export type RemoteParserOptions = { + executionHostId: ExecutionHostId + executionHostPlatform: NodeJS.Platform +} + +export type RemoteSessionSource = { + agent: AiVaultAgent + rootDir: string + extensions: readonly string[] + filePredicate?: (path: string) => boolean + parse: ( + file: FileWithMtime, + content: string, + context: RemoteScannerContext + ) => Promise +} + +export type RemoteSessionCandidate = { + source: RemoteSessionSource + file: FileWithMtime +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts new file mode 100644 index 00000000000..16173f7b736 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from 'vitest' +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' + +class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('scanRemoteAiVaultSessions', () => { + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/session_index.jsonl', + jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]), + 1 + ) + provider.addFile( + '/home/ada/.codex/sessions/2026/07/04/default.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T01:00:00.000Z', + type: 'session_meta', + payload: { id: 'default-session', cwd: '/home/ada/repo' } + }, + { + timestamp: '2026-07-04T01:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Fallback default title' }] + } + } + ]), + 10 + ) + provider.addFile( + '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T02:00:00.000Z', + type: 'session_meta', + payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' } + }, + { + timestamp: '2026-07-04T02:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed remote title' }] + } + } + ]), + 20 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.title)).toEqual([ + 'Managed remote title', + 'Indexed remote title' + ]) + expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2) + expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true) + expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true) + expect( + result.sessions.find((session) => session.sessionId === 'default-session') + ).toMatchObject({ + codexHome: '/home/ada/.codex', + resumeCommand: + "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'" + }) + expect( + result.sessions.find((session) => session.sessionId === 'runtime-session') + ).toMatchObject({ + codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home', + resumeCommand: + "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'" + }) + }) + + it('parses non-Codex transcripts through the same remote scanner', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.claude/projects/repo/claude-session.jsonl', + jsonLines([ + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:00.000Z', + type: 'user', + message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] } + }, + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:01.000Z', + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Sure.' } + } + ]), + 40 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'claude', + sessionId: 'claude-session', + title: 'Summarize the remote branch', + model: 'claude-opus-4', + filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl' + }) + }) + + it('builds resume commands with the remote host platform', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + 'C:/Users/Ada/.codex/sessions/win.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T03:00:00.000Z', + type: 'session_meta', + payload: { id: 'win-session', cwd: 'C:/repo/app' } + }, + { + timestamp: '2026-07-04T03:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Windows remote title' }] + } + } + ]), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]?.executionHostPlatform).toBe('win32') + expect(result.sessions[0]?.resumeCommand).toBe( + 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""' + ) + }) + + it('continues past skipped candidates to fill the remote scan limit', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/worker.jsonl', + codexTranscript({ + sessionId: 'worker-session', + title: 'Internal worker', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T04:00:00.000Z', + threadSource: 'agent' + }), + 40 + ) + provider.addFile( + '/home/ada/.codex/sessions/user.jsonl', + codexTranscript({ + sessionId: 'user-session', + title: 'Visible user session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T03:00:00.000Z' + }), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) + }) + + it('keeps scoped remote sessions even when they are older than the recency cap', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/other.jsonl', + codexTranscript({ + sessionId: 'other-session', + title: 'Other workspace', + cwd: '/home/ada/other', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 50 + ) + provider.addFile( + '/home/ada/.codex/sessions/scoped.jsonl', + codexTranscript({ + sessionId: 'scoped-session', + title: 'Scoped workspace', + cwd: '/home/ada/repo/app', + timestamp: '2026-07-04T01:00:00.000Z' + }), + 10 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-session', + 'scoped-session' + ]) + }) +}) + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string + threadSource?: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { + id: args.sessionId, + cwd: args.cwd, + ...(args.threadSource ? { thread_source: args.threadSource } : {}) + } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts new file mode 100644 index 00000000000..1b7d66cb7b0 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -0,0 +1,296 @@ +import { extname } from 'node:path' +import type { + AiVaultAgent, + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { sessionSortTime } from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import { remoteSessionSources } from './remote-session-scanner-sources' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const DEFAULT_REMOTE_SCAN_LIMIT = 1000 +const REMOTE_SCAN_CONCURRENCY = 8 +const REMOTE_SCOPE_PARSE_LIMIT = 2000 + +export async function scanRemoteAiVaultSessions(args: { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + limit?: number + scopePaths?: readonly string[] +}): Promise { + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT + const issues: AiVaultScanIssue[] = [] + const context: RemoteScannerContext = { + provider: args.provider, + executionHostId: args.executionHostId, + hostPlatform: args.hostPlatform, + titleCaches: new Map() + } + const candidates = ( + await mapRemoteScanConcurrently( + remoteSessionSources(args.remoteHome, args.hostPlatform), + (source) => discoverRemoteSourceCandidates({ source, context, issues }) + ) + ) + .flat() + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + + const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) + const cappedSessions = parsed.sessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) + const parsedScopeSessions = parsed.sessions.filter((session) => + isRemoteSessionInScope(session, scopePaths) + ) + const extraScopeSessions = await scanRemoteInScopeSessions({ + candidates, + context, + issues, + scopePaths, + alreadyParsedFilePaths: parsed.parsedFilePaths + }) + + return { + sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]), + issues, + scannedAt: new Date().toISOString() + } +} + +async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const paths = await walkRemoteSessionFiles( + args.source, + args.context.provider, + args.context.hostPlatform + ) + const files = await mapRemoteScanConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ source: args.source, file })) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + provider: IFilesystemProvider, + hostPlatform: RemoteHostPlatform, + dirPath = source.rootDir +): Promise { + let entries + try { + entries = await provider.readDir(dirPath) + } catch { + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) + if (entry.isDirectory && !entry.isSymlink) { + files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function parseRemoteSessionCandidates(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + limit: number +}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> { + const sessions: AiVaultSession[] = [] + const parsedFilePaths = new Set() + let index = 0 + + while (index < args.candidates.length) { + if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + break + } + + const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + for (const candidate of batch) { + parsedFilePaths.add(candidate.file.path) + } + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push(...results.filter(isAiVaultSession)) + index += batch.length + } + + return { sessions, parsedFilePaths } +} + +async function scanRemoteInScopeSessions(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + scopePaths: readonly string[] + alreadyParsedFilePaths: ReadonlySet +}): Promise { + if (args.scopePaths.length === 0) { + return [] + } + + const candidates = args.candidates + .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)) + .slice(0, REMOTE_SCOPE_PARSE_LIMIT) + const sessions: AiVaultSession[] = [] + + for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push( + ...results.filter( + (session): session is AiVaultSession => + isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) + ) + ) + } + + return sessions +} + +async function parseRemoteSessionCandidate( + candidate: RemoteSessionCandidate, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + try { + const read = await context.provider.readFile(candidate.file.path) + if (read.isBinary) { + return null + } + return candidate.source.parse(candidate.file, read.content, context) + } catch (err) { + issues.push({ + executionHostId: context.executionHostId, + agent: candidate.source.agent, + path: candidate.file.path, + message: errorMessage(err) + }) + return null + } +} + +function mergeRemoteSessions( + cappedSessions: AiVaultSession[], + scopeSessions: AiVaultSession[] +): AiVaultSession[] { + if (scopeSessions.length === 0) { + return cappedSessions + } + const byId = new Map() + for (const session of cappedSessions) { + byId.set(session.id, session) + } + for (const session of scopeSessions) { + byId.set(session.id, session) + } + return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) +} + +function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { + const cwd = session.cwd + return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) +} + +function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { + return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + return null + } +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +function canStopParsingRemoteSessions( + sessions: AiVaultSession[], + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const visibleCutoff = sessions + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + + // Transcript mtimes bound the remaining candidate order; once the visible + // cutoff is newer, older files cannot enter the unscoped top-N result. + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} + +function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { + return Boolean(session) +} + +async function mapRemoteScanConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index a6e8cc293dd..85b33a962d6 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -6,6 +6,7 @@ import { type AiVaultSession, type AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { extractPreviewContentText, @@ -43,7 +44,11 @@ export function createAccumulator(args: { export function finalizeSession( accumulator: SessionAccumulator, platform: NodeJS.Platform, - options: { codexHome?: string | null } = {} + options: { + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + } = {} ): AiVaultSession | null { const sessionId = accumulator.sessionId.trim() if (!sessionId) { @@ -54,8 +59,14 @@ export function finalizeSession( accumulator.fallbackTitle || `${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}` + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + return { - id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + executionHostId, + ...(options.executionHostPlatform + ? { executionHostPlatform: options.executionHostPlatform } + : {}), agent: accumulator.agent, sessionId, title, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index adfb2aa86db..1114c1b8bb2 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,11 +1,8 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' -import { - parseDroidSessionFile, - parseMessageGraphSessionFile, - parseRovoSessionFile -} from './session-scanner-graph-parsers' +import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index a42e0af7962..b18721a8296 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import { addPreviewContent, createAccumulator, @@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) - for await (const line of lines) { + return parseCodexSessionLines({ + file, + lines, + platform, + codexHome, + executionHostId, + titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) + }) +} + +export async function parseCodexSessionContent(args: { + file: FileWithMtime + content: string + platform?: NodeJS.Platform + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + readIndexedTitle?: (sessionId: string) => Promise +}): Promise { + return parseCodexSessionLines({ + file: args.file, + lines: args.content.split(/\r?\n/), + platform: args.platform ?? process.platform, + codexHome: args.codexHome ?? null, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform, + titleReader: args.readIndexedTitle + }) +} + +async function parseCodexSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + codexHome: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + titleReader?: (sessionId: string) => Promise +}): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -70,7 +111,7 @@ export async function parseCodexSessionFile( } const indexedTitle = extractCodexSessionMetadataTitle(payload) ?? - (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + (await args.titleReader?.(accumulator.sessionId)) if (indexedTitle) { accumulator.title = indexedTitle } @@ -158,7 +199,11 @@ export async function parseCodexSessionFile( } } - return finalizeSession(accumulator, platform, { codexHome }) + return finalizeSession(accumulator, args.platform, { + codexHome: args.codexHome, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform + }) } function addCodexUsage( diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4750dd788ef..4ad6fa45f78 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' import { addPreviewContent, @@ -17,11 +18,25 @@ import { numberValue } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export function parseDevinSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -69,7 +84,7 @@ export async function parseDevinSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } function extractDevinStepText(step: Record): string | null { diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts new file mode 100644 index 00000000000..d99795bbbd3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractMessageText, + extractPreviewContentText, + extractString, + normalizeTitleText, + parseJsonObject, + tokenTotal +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseDroidSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseDroidSessionLines({ file, lines, platform }) +} + +export async function parseDroidSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseDroidSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseDroidSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'droid', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, record.timestamp) + if (record.type === 'session_start') { + accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + continue + } + if (record.type === 'system') { + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.model = extractString(record.model) ?? accumulator.model + } + const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) + if (streamSessionId) { + accumulator.sessionId = streamSessionId + } + if (record.type === 'message') { + consumeDroidMessage(accumulator, record) + } else if (record.type === 'completion') { + accumulator.messageCount++ + accumulator.totalTokens += tokenTotal(record.usage) + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(record.finalText), + timestamp: record.timestamp + }) + } + } + return finalizeSession(accumulator, args.platform, args.options) +} + +function consumeDroidMessage( + accumulator: SessionAccumulator, + record: Record +): void { + const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) + if (role !== 'user' && role !== 'assistant') { + return + } + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= + normalizeTitleText(extractString(record.text) ?? '') || + extractMessageText(asRecord(record.message)) + } + addPreviewMessage(accumulator, { + role, + text: + extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content), + timestamp: record.timestamp + }) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3013925a548..a52c83b3213 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -17,15 +18,18 @@ import { asRecord, extractContentText, extractMessageText, - extractPreviewContentText, extractString, firstString, - normalizeTitleText, parseJsonObject, readJsonObjectIfExists, tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent, - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseMessageGraphSessionLines({ agent, file, lines, platform }) +} - for await (const line of lines) { +export async function parseMessageGraphSessionContent( + agent: 'openclaw' | 'pi', + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseMessageGraphSessionLines({ + agent, + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseMessageGraphSessionLines(args: { + agent: 'openclaw' | 'pi' + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: args.agent, + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile( } } - return finalizeSession(accumulator, platform) -} - -export async function parseDroidSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const accumulator = createAccumulator({ - agent: 'droid', - file, - sessionId: sessionIdFromFileName(file.path) - }) - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, record.timestamp) - if (record.type === 'session_start') { - accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - continue - } - if (record.type === 'system') { - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - accumulator.model = extractString(record.model) ?? accumulator.model - } - const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) - if (streamSessionId) { - accumulator.sessionId = streamSessionId - } - if (record.type === 'message') { - const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= - normalizeTitleText(extractString(record.text) ?? '') || - extractMessageText(asRecord(record.message)) - } - addPreviewMessage(accumulator, { - role, - text: - extractString(record.text) ?? - extractPreviewContentText(asRecord(record.message)?.content), - timestamp: record.timestamp - }) - } - } else if (record.type === 'completion') { - accumulator.messageCount++ - accumulator.totalTokens += tokenTotal(record.usage) - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(record.finalText), - timestamp: record.timestamp - }) - } - } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 363bfe636a8..28a37602a6c 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -23,24 +24,51 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'claude', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let metaTitle: string | null = null - let generatedTitle: string | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseClaudeSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseClaudeSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseClaudeSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseClaudeSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'claude', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let metaTitle: string | null = null + let generatedTitle: string | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -92,7 +120,7 @@ export async function parseClaudeSessionFile( } accumulator.fallbackTitle = generatedTitle ?? metaTitle - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseGeminiSessionFile( @@ -103,7 +131,33 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseGeminiSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) + } + return parseGeminiJsonSessionContent(file, content, platform, options) +} + +function parseGeminiJsonSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -117,24 +171,33 @@ export async function parseGeminiSessionFile( for (const message of arrayValue(record.messages)) { consumeGeminiMessage(accumulator, asRecord(message)) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } export async function parseGeminiJsonlSessionFile( file: FileWithMtime, platform: NodeJS.Platform ): Promise { - const accumulator = createAccumulator({ - agent: 'gemini', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseGeminiJsonlSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +async function parseGeminiJsonlSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'gemini', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile( consumeGeminiMessage(accumulator, record) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export function consumeGeminiMessage( diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index edc03cf5815..1edcf6292f0 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -29,21 +30,49 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseCopilotSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'copilot', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCopilotSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCopilotSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCopilotSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCopilotSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'copilot', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -94,24 +123,47 @@ export async function parseCopilotSessionFile( } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseCursorSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'cursor', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCursorSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCursorSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCursorSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCursorSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'cursor', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -132,7 +184,7 @@ export async function parseCursorSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseOpenCodeSessionFile( @@ -207,7 +259,16 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseHermesSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -234,5 +295,5 @@ export async function parseHermesSessionFile( if (accumulator.messageCount === 0) { accumulator.messageCount = numberValue(record.message_count) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b6c6ae96412..5d2208b7b60 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -4,6 +4,7 @@ import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' export type AiVaultScanOptions = { claudeProjectsDir?: string @@ -33,6 +34,7 @@ export type AiVaultScanOptions = { // the recency cap (see discoverInScopeClaudeFiles). scopePaths?: readonly string[] platform?: NodeJS.Platform + executionHostId?: ExecutionHostId } export type FileWithMtime = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 181bf1466ea..59f92dafc7a 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -3,6 +3,7 @@ import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' @@ -41,6 +42,7 @@ export async function scanAiVaultSessions( const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) @@ -63,6 +65,7 @@ export async function scanAiVaultSessions( candidates, limit, platform, + executionHostId, issues }) @@ -75,12 +78,13 @@ export async function scanAiVaultSessions( scopePaths: options.scopePaths ?? [], alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), platform, + executionHostId, issues }) return { sessions: mergeSessions(cappedSessions, scopeSessions), - issues, + issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() } } @@ -110,6 +114,7 @@ async function scanInScopeSessions(args: { scopePaths: readonly string[] alreadyParsedFilePaths: ReadonlySet platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { if (args.scopePaths.length === 0) { @@ -136,6 +141,7 @@ async function scanInScopeSessions(args: { candidates, limit: candidates.length, platform: args.platform, + executionHostId: args.executionHostId, issues: args.issues }) } @@ -144,6 +150,7 @@ async function parseSessionCandidates(args: { candidates: SessionFileCandidate[] limit: number platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { const sessions: AiVaultSession[] = [] @@ -159,7 +166,9 @@ async function parseSessionCandidates(args: { const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining) const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( - batch.map((candidate) => parseSessionCandidate(candidate, args.platform)) + batch.map((candidate) => + parseSessionCandidate(candidate, args.platform, args.executionHostId) + ) ) for (const result of results) { @@ -179,15 +188,20 @@ async function parseSessionCandidates(args: { async function parseSessionCandidate( candidate: SessionFileCandidate, - platform: NodeJS.Platform + platform: NodeJS.Platform, + executionHostId: ExecutionHostId ): Promise { try { const session = await parseAgentSessionFile(candidate, platform) - return { session, issue: null } + return { + session: session ? withSessionExecutionHost(session, executionHostId) : null, + issue: null + } } catch (err) { return { session: null, issue: { + executionHostId, agent: candidate.agent, path: candidate.file.path, message: errorMessage(err) @@ -196,6 +210,20 @@ async function parseSessionCandidate( } } +function withSessionExecutionHost( + session: AiVaultSession, + executionHostId: ExecutionHostId +): AiVaultSession { + if (session.executionHostId === executionHostId) { + return session + } + return { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } +} + function canStopParsingSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts new file mode 100644 index 00000000000..9cc8060eee2 --- /dev/null +++ b/src/main/ipc/ai-vault.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn(), + scanRemoteAiVaultSessions: vi.fn(), + getAiVaultWslHomeDirs: vi.fn(), + getSshFilesystemProvider: vi.fn(), + getActiveSshAiVaultHostInfo: vi.fn(), + getActiveSshAiVaultHostInfos: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { on: vi.fn() }, + ipcMain: { handle: vi.fn() } +})) + +vi.mock('../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) + +vi.mock('../ai-vault/remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions +})) + +vi.mock('../wsl', () => ({ + getWslHomeAsync: mocks.getAiVaultWslHomeDirs, + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) + +vi.mock('./ssh', () => ({ + getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos +})) + +const { _internals } = await import('./ai-vault') + +const provider = {} as IFilesystemProvider + +beforeEach(() => { + vi.clearAllMocks() + _internals.resetAiVaultCacheForTests() + mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')])) + mocks.scanRemoteAiVaultSessions.mockResolvedValue( + result([session('ssh:dev-box', 'remote-session')]) + ) + mocks.getSshFilesystemProvider.mockReturnValue(provider) + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) +}) + +describe('listAiVaultSessions host routing', () => { + it('routes local scope to the local scanner', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + scopePaths: ['/repo'], + executionHostId: 'local' + }) + ) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + + it('routes SSH scope to only that SSH target', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo'] + }) + + expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled() + expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + scopePaths: ['/home/ada/repo'] + }) + ) + }) + + it('merges local plus connected SSH targets for all hosts', async () => { + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) + }) + + it('returns a scan issue for a disconnected SSH target', async () => { + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) + mocks.getSshFilesystemProvider.mockReturnValue(undefined) + + const result = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:disconnected' + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toMatchObject([ + { + executionHostId: 'ssh:disconnected', + agent: 'codex', + path: 'disconnected' + } + ]) + }) + + it('keeps host scope in the cache key', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) + +function hostInfo(targetId: string) { + return { + targetId, + executionHostId: `ssh:${targetId}` as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + } +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: new Date().toISOString() } +} + +function session( + executionHostId: AiVaultSession['executionHostId'], + sessionId: string +): AiVaultSession { + return { + id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`, + executionHostId, + agent: 'codex', + sessionId, + title: sessionId, + cwd: '/repo', + branch: null, + model: null, + filePath: `/tmp/${sessionId}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: + sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z', + modifiedAt: '2026-07-04T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + resumeCommand: `codex resume ${sessionId}` + } +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f96aa0b6d61..4551379afde 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,8 +1,26 @@ import { app, ipcMain } from 'electron' import { join } from 'node:path' +import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { scanAiVaultSessions } from '../ai-vault/session-scanner' +import { sessionSortTime } from '../ai-vault/session-scanner-accumulator' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { + AiVaultListArgs, + AiVaultListResult, + AiVaultScanIssue +} from '../../shared/ai-vault-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostScope, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostScope +} from '../../shared/execution-host' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' +import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' const AI_VAULT_CACHE_TTL_MS = 15_000 @@ -22,10 +40,14 @@ let inflightKey: string | null = null let handlerOptions: AiVaultHandlerOptions = {} async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + const executionHostScope = normalizeExecutionHostScope( + args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID + ) // Scope paths change the result set, so they must be part of the cache key. const key = JSON.stringify({ limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [] + scopePaths: args?.scopePaths ?? [], + executionHostScope }) const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL @@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? - [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs() - }))() + inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope) .then((result) => { cachedList = { key, @@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - inflightKey = null - inflightList = null + // Only clear tracking if it still refers to this request: a concurrent + // different-scope scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } }) return inflightList } +async function scanAiVaultSessionsByHostScope( + args: AiVaultListArgs | undefined, + executionHostScope: ExecutionHostScope +): Promise { + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scanLocalAiVaultSessions(args) + } + + if (executionHostScope === 'all') { + return mergeAiVaultListResults( + await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ) + ]), + args?.limit + ) + } + + const parsed = parseExecutionHostId(executionHostScope) + if (parsed?.kind === 'ssh') { + return scanSshAiVaultSessions(parsed.targetId, args) + } + + return { + sessions: [], + issues: [ + { + executionHostId: executionHostScope, + agent: 'codex', + path: executionHostScope, + message: 'Agent Session History is not available for this execution host.' + } + ], + scannedAt: new Date().toISOString() + } +} + +async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { + const additionalCodexSessionsDirs = + handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? + [] + return scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + executionHostId: LOCAL_EXECUTION_HOST_ID + }) +} + +async function scanSshAiVaultSessions( + targetId: string, + args?: AiVaultListArgs +): Promise { + const executionHostId = toSshExecutionHostId(targetId) + const hostInfo = getActiveSshAiVaultHostInfo(targetId) + const provider = getSshFilesystemProvider(targetId) + if (!hostInfo || !provider) { + return sshScanIssueResult({ + executionHostId, + targetId, + message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + }) + } + return scanRemoteAiVaultSessions({ + provider, + executionHostId: hostInfo.executionHostId, + remoteHome: hostInfo.remoteHome, + hostPlatform: hostInfo.hostPlatform, + limit: args?.limit, + scopePaths: args?.scopePaths + }) +} + +function sshScanIssueResult(args: { + executionHostId: `ssh:${string}` + targetId: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId: args.executionHostId, + agent: 'codex', + path: args.targetId, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} + +function mergeAiVaultListResults( + results: readonly AiVaultListResult[], + rawLimit: number | undefined +): AiVaultListResult { + const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { + byId.set(session.id, session) + } + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() + } +} + export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => @@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } +function resetAiVaultCacheForTests(): void { + cachedList = null + inflightList = null + inflightKey = null + handlerOptions = {} +} + +export const _internals = { + listAiVaultSessions, + resetAiVaultCacheForTests +} + async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 15f95306c78..ff676112218 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -5,7 +5,7 @@ import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { SshRelaySession } from '../ssh/ssh-relay-session' +import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, @@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise // scattered Maps/Sets that previously tracked this state independently. const activeSessions = new Map() +export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { + if (isRuntimeOwnedSshTargetId(targetId)) { + return null + } + return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null +} + +export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { + return [...activeSessions.values()].flatMap((session) => { + if (isRuntimeOwnedSshTargetId(session.targetId)) { + return [] + } + const info = session.getAiVaultHostInfo() + return info ? [info] : [] + }) +} + async function detachActiveSshSession(targetId: string): Promise { await teardownActiveSshSession(targetId, (session) => session.detach()) } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 58bf3fbf798..85761621d44 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -62,10 +62,12 @@ import { import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' type RemoteCliBridgeEnv = { + remoteHome: string binDir: string relayDir: string nodePath: string @@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = { deliveredAt: number } +export type SshRelayAiVaultHostInfo = { + targetId: string + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform +} + const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 const REPLAY_FINGERPRINT_EDGE_CHARS = 128 @@ -189,6 +198,19 @@ export class SshRelaySession { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } + getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { + const env = this.remoteCliBridgeEnv + if (!env) { + return null + } + return { + targetId: this.targetId, + executionHostId: toSshExecutionHostId(this.targetId), + remoteHome: env.remoteHome, + hostPlatform: env.hostPlatform + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -218,6 +240,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, @@ -345,6 +368,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index f67c7d82a3a..3fd7ae88809 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { 'runtime', [activeWorktreeId, resumeTargetState] ) + const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } = + useAiVaultExecutionHostScope({ + activeWorktreeId: activeWorktreeId ?? null, + resumeTargetState + }) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( @@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element { }), [activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection] ) - const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths) + const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( + scopePaths, + executionHostScope + ) const sessionProjectById = useMemo( () => buildAiVaultProjectContext({ @@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, + repos, worktrees: allWorktrees, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null }) @@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeState({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { activeWorktreePath={activeWorktreePath} activeProjectKey={activeProjectKey} scope={scope} + executionHostScope={executionHostScope} + activeSshExecutionHostScope={activeSshExecutionHostScope} agents={agents} sort={sort} group={group} @@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element { adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} + onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 227e96ccca9..2b3cc2802b0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -7,7 +7,8 @@ import { FolderOpen, ListFilter, LoaderCircle, - PanelsTopLeft + PanelsTopLeft, + Server } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -31,6 +32,12 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' @@ -175,6 +182,59 @@ export function VaultScopeSwitch({ ) } +export function VaultHostScopeMenu({ + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange +}: { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +}): React.JSX.Element { + const hostOptions = [ + LOCAL_EXECUTION_HOST_ID, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), + ALL_EXECUTION_HOSTS_SCOPE + ] as const + const label = getExecutionHostLabel(executionHostScope) + + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')} + + onExecutionHostScopeChange(value as ExecutionHostScope)} + > + {hostOptions.map((scope) => ( + + {getExecutionHostLabel(scope)} + + ))} + + + + ) +} + export function VaultViewMenu({ agents, sort, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 6f40190617e..b463e188a7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -7,7 +7,8 @@ import type { AiVaultScope, AiVaultSort } from '../../../../shared/ai-vault-types' -import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' type AiVaultPanelHeaderProps = { query: string @@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = { activeWorktreePath: string | null activeProjectKey: string | null scope: AiVaultScope + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = { adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void @@ -42,6 +46,8 @@ export function AiVaultPanelHeader({ activeWorktreePath, activeProjectKey, scope, + executionHostScope, + activeSshExecutionHostScope, agents, sort, group, @@ -49,6 +55,7 @@ export function AiVaultPanelHeader({ adjustmentCount, onQueryChange, onScopeChange, + onExecutionHostScopeChange, onAgentEnabledChange, onSortChange, onGroupChange, @@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+ void + onJumpToOriginalPane?: () => void + showJumpToWorktree: boolean + onJumpToWorktree?: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog?: () => void + onRevealLog?: () => void + onOpenCwd?: () => void +}) { + const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem + const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + + return ( + <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + {showJumpToWorktree ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + + ) : null} + + + {resumeLabel} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + {hasLocalPathActions ? ( + <> + + {onOpenLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + ) : null} + {onRevealLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + ) : null} + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 58923a53479..d5a538b44fd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -31,7 +31,7 @@ export function SessionInlineDetails({ } onResumeInWorktree: () => void onResumeInNewTab: () => void - onOpenLog: () => void + onOpenLog?: () => void }): React.JSX.Element { const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) @@ -132,20 +132,22 @@ export function SessionInlineDetails({ )} ) : null} - + {onOpenLog ? ( + + ) : null}
) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 5ea6bd9d307..0826eed9509 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,14 +1,6 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { Badge } from '@/components/ui/badge' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { AgentIcon } from '@/lib/agent-catalog' @@ -27,6 +19,7 @@ import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { @@ -74,8 +67,8 @@ export function VaultSessionRow({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt @@ -102,6 +95,7 @@ export function VaultSessionRow({ title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) @@ -223,101 +217,6 @@ export function VaultSessionRow({ ) } -export function SessionActionMenuItems({ - menuKind = 'dropdown', - resumeDisabled, - resumeLabel, - onResume, - onJumpToOriginalPane, - showJumpToWorktree, - onJumpToWorktree, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd -}: { - menuKind?: 'dropdown' | 'context' - resumeDisabled: boolean - resumeLabel: string - onResume: () => void - onJumpToOriginalPane?: () => void - showJumpToWorktree: boolean - onJumpToWorktree?: () => void - onCopyResume: () => void - onCopyId: () => void - onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void - onOpenCwd?: () => void -}) { - const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem - const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator - - return ( - <> - {onJumpToOriginalPane ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', - 'Jump to Original Pane' - )} - - ) : null} - {showJumpToWorktree ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', - 'Jump to Worktree' - )} - - ) : null} - - - {resumeLabel} - - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} - - {onOpenCwd ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', - 'Open Working Directory' - )} - - ) : null} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', - 'Copy Session ID' - )} - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - - - ) -} - function getSessionDetailsId(sessionId: string): string { return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index e7293f15de7..3d2bdde0384 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -19,6 +19,7 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -275,6 +276,8 @@ function AiVaultVirtualRow({ const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) return (
onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={() => onOpenLog(row.session)} - onRevealLog={() => onRevealLog(row.session)} - onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined} + onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 27a29bbf032..10167da346b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionActionMenuItems } from './AiVaultSessionRow' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { aiVaultWorktreeJumpTooltip, type AiVaultSessionWorktreeInfo @@ -67,8 +67,8 @@ export function SessionRowTrailingActions({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts new file mode 100644 index 00000000000..73af6ab915e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' + +type HostScopeResult = ReturnType + +let root: Root | null = null +let latest: HostScopeResult | null = null + +function HookProbe(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): null { + latest = useAiVaultExecutionHostScope(props) + return null +} + +async function renderHook(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): Promise { + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render(createElement(HookProbe, props)) + }) +} + +function stateForWorktree(args: { + worktreeId: string + repoId: string + executionHostId?: string | null + hostId?: string | null +}): AiVaultSessionResumeTargetState { + return { + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: args.repoId, + connectionId: null, + executionHostId: args.executionHostId ?? 'local' + } + ], + worktreesByRepo: { + [args.repoId]: [ + { + id: args.worktreeId, + repoId: args.repoId, + hostId: args.hostId ?? null + } + ] + } + } as unknown as Pick +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + root = null + latest = null + document.body.replaceChildren() +}) + +describe('useAiVaultExecutionHostScope', () => { + it('defaults SSH worktrees to their SSH execution host', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + }) + + expect(latest?.executionHostScope).toBe('ssh:dev-box') + expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box') + }) + + it('defaults local worktrees to local history', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/local/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/local/repo', + repoId: 'repo-1' + }) + }) + + expect(latest?.executionHostScope).toBe('local') + expect(latest?.activeSshExecutionHostScope).toBeNull() + }) + + it('preserves manual host scope changes across unrelated rerenders', async () => { + const props = { + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + } + await renderHook(props) + + await act(async () => { + latest?.onExecutionHostScopeChange('all') + }) + await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } }) + + expect(latest?.executionHostScope).toBe('all') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts new file mode 100644 index 00000000000..8993e8a00e6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' + +export function useAiVaultExecutionHostScope(args: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +} { + const userChangedHostScopeRef = useRef(false) + const activeExecutionHostId = useMemo( + () => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId), + [args.activeWorktreeId, args.resumeTargetState] + ) + const activeExecutionHost = parseExecutionHostId(activeExecutionHostId) + const defaultExecutionHostScope: ExecutionHostScope = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID + const activeSshExecutionHostScope: ExecutionHostScope | null = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null + const [executionHostScope, setExecutionHostScope] = + useState(defaultExecutionHostScope) + + useEffect(() => { + // Why: preserve an explicit user choice (e.g. "All") across incidental + // rerenders, but reset to the new default once that choice no longer + // applies to the active worktree's host. + const allowedScopes = new Set([ + LOCAL_EXECUTION_HOST_ID, + ALL_EXECUTION_HOSTS_SCOPE, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []) + ]) + if (!allowedScopes.has(executionHostScope)) { + setExecutionHostScope(defaultExecutionHostScope) + userChangedHostScopeRef.current = false + return + } + if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) { + setExecutionHostScope(defaultExecutionHostScope) + } + }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope]) + + const handleExecutionHostScopeChange = useCallback( + (nextScope: ExecutionHostScope) => { + userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope + setExecutionHostScope(nextScope) + }, + [defaultExecutionHostScope] + ) + + return { + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange: handleExecutionHostScopeChange + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index cec8a30a270..c47f64b9720 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the pane focus', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index b341558c628..3da906781d9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -9,6 +9,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the flaky golden tests', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 3190221f389..acbe0b051a1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement vault filters', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index dc2ed601f83..c55acb1e939 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -13,6 +13,7 @@ import { import { useAppStore } from '@/store' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -79,6 +80,7 @@ export function useAiVaultSessionLaunchActions({ (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -130,6 +132,7 @@ export type AiVaultSessionLaunchTarget = export function resolveAiVaultSessionLaunchTarget(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -143,7 +146,18 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + targetWorktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return { status: 'unsupported', targetStatus } } @@ -159,12 +173,12 @@ function aiVaultResumeUnsupportedMessage( 'Resume from history is not available in runtime-hosted workspaces.' ) } - // Why: 'ssh' only reaches the unsupported branch when the session file lives - // on this machine, so the message explains the host mismatch, not SSH itself. - if (targetStatus === 'ssh') { + // Why: local and SSH targets can both be valid generally; this branch means + // the session's recorded host does not match the selected workspace. + if (targetStatus === 'ssh' || targetStatus === 'local') { return translate( - 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Open a workspace on the same host to resume it.' ) } return translate( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts new file mode 100644 index 00000000000..93fee6c94e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' + +describe('canUseLocalAiVaultSessionPathActions', () => { + it('allows OS path actions for local session history', () => { + expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true) + }) + + it('blocks OS path actions for non-local or unknown session history', () => { + expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts new file mode 100644 index 00000000000..f723bbb1959 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -0,0 +1,13 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function canUseLocalAiVaultSessionPathActions( + executionHostId: ExecutionHostId | null | undefined +): boolean { + // Why: Electron shell open/reveal APIs only validate paths on this computer; + // SSH session history exposes paths that exist on the remote host instead. + return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index fa53c685da8..d0e4c1a1c6e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement project history', @@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => { repoId: repo.id, path: '/runtime/orca' }) - const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-worktree', + cwd: '/runtime/orca/src', + executionHostId: 'runtime:preview' + }) const context = buildAiVaultProjectContext({ repos: [repo], @@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') }) - it('falls back to folder when a hostless session matches multiple host buckets', () => { + it('uses the session host when matching overlapping local and SSH project paths', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const sshRepo = makeRepo({ id: 'ssh', @@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => { path: '/srv/orca', connectionId: 'target-1' }) - const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:ssh-session', + cwd: '/srv/orca/src', + executionHostId: 'ssh:target-1' + }) const context = buildAiVaultProjectContext({ repos: [localRepo, sshRepo], @@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - kind: 'folder', - key: 'folder:/srv/orca/src', - label: 'orca/src' + kind: 'repo', + key: 'repo:ssh', + label: 'SSH', + hostKey: 'ssh:target-1' }) }) - it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => { + it('falls back to folder when a legacy hostless session matches multiple host buckets', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' }) - const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-ambiguous', + cwd: '/srv/orca/src', + executionHostId: undefined as unknown as AiVaultSession['executionHostId'] + }) const context = buildAiVaultProjectContext({ repos: [localRepo, runtimeRepo], diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index fdb78325025..c0734a11881 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,4 +1,9 @@ -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' @@ -14,7 +19,7 @@ export type AiVaultSessionProject = { label: string projectId?: string repoId?: string - hostKey?: string + hostKey?: ExecutionHostId } export type AiVaultProjectContext = { @@ -27,7 +32,7 @@ export type AiVaultProjectContext = { type SessionProjectCandidate = { source: 'worktree' | 'setup' normalizedPath: string - hostKey: string + hostKey: ExecutionHostId projectId: string | null repoId: string | null } @@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({ for (const session of sessions) { sessionProjectById.set( session.id, - resolveSessionProject(session.cwd, candidates, projectLabelByKey) + resolveSessionProject(session, candidates, projectLabelByKey) ) } @@ -147,10 +152,11 @@ function buildProjectCandidates( candidates.push({ source: 'worktree', normalizedPath: normalizeRuntimePathForComparison(worktree.path), - hostKey: - worktree.hostId ?? - setup?.hostId ?? - (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), projectId: worktree.projectId ?? setup?.projectId ?? null, repoId: worktree.repoId }) @@ -168,7 +174,7 @@ function buildProjectCandidates( candidates.push({ source: 'setup', normalizedPath: normalizeRuntimePathForComparison(setup.path), - hostKey: setup.hostId || getRepoExecutionHostId(setup), + hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), projectId: setup.projectId, repoId: setup.repoId || null }) @@ -193,15 +199,28 @@ function buildProjectCandidates( return candidates } +function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + function hasCandidatePath(pathValue: string): boolean { return pathValue.trim().length > 0 } function resolveSessionProject( - cwd: string | null, + session: AiVaultSession, candidates: readonly SessionProjectCandidate[], projectLabelByKey: ReadonlyMap ): AiVaultSessionProject { + const cwd = session.cwd if (!cwd) { return { kind: 'unknown', key: 'unknown', label: '' } } @@ -209,14 +228,22 @@ function resolveSessionProject( const matches = candidates.filter((candidate) => isPathInsideOrEqual(candidate.normalizedPath, cwd) ) - const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey)) - if (hostBuckets.size > 1) { - // Why: session rows do not carry host ids yet, so overlapping local/SSH - // paths must stay visible without being attributed to the wrong project. + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const hostMatches = sessionHostId + ? matches.filter((candidate) => candidate.hostKey === sessionHostId) + : matches + if (sessionHostId && matches.length > 0 && hostMatches.length === 0) { + // Why: same-path projects can exist on several hosts; a tagged transcript + // should never be attributed to a project on a different machine. return folderProject(cwd) } - const bestCandidate = matches.sort(compareCandidates)[0] + const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey)) + if (!sessionHostId && hostBuckets.size > 1) { + return folderProject(cwd) + } + + const bestCandidate = hostMatches.sort(compareCandidates)[0] if (!bestCandidate) { return folderProject(cwd) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index cdff27604d9..2ea75f40f48 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] let latest: ReturnType | null = null -function HookProbe(props: { scopePaths: readonly string[] }): null { - latest = useAiVaultSessionRefresh(props.scopePaths) +function HookProbe(props: { + scopePaths: readonly string[] + executionHostScope?: 'local' | 'all' | `ssh:${string}` +}): null { + latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local') return null } -async function renderHook(scopePaths: readonly string[] = []): Promise { +async function renderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) await act(async () => { - root.render(createElement(HookProbe, { scopePaths })) + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) + }) +} + +async function rerenderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { + const root = roots.at(-1) + if (!root) { + throw new Error('renderHook must be called before rerenderHook') + } + await act(async () => { + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) }) } @@ -126,7 +145,58 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true }) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ + executionHostScope: 'local', + force: true + }) + }) + + it('passes the requested execution host scope to the scanner', async () => { + await renderHook(['/repo'], 'ssh:dev-box') + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/repo'] + }) + }) + + it('does not apply stale results after the host scope changes mid-scan', async () => { + let resolveLocal: ((result: AiVaultListResult) => void) | null = null + let resolveSsh: ((result: AiVaultListResult) => void) | null = null + listSessionsMock + .mockImplementationOnce( + () => new Promise((resolve) => (resolveLocal = resolve)) + ) + .mockImplementationOnce( + () => new Promise((resolve) => (resolveSsh = resolve)) + ) + + await renderHook([], 'local') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await rerenderHook(['/remote/repo'], 'ssh:dev-box') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult).toBeNull() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/remote/repo'] + }) + + await act(async () => { + resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) it('force re-scans on refocus once the throttle allows it', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 98539f858ce..88757297603 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' const SESSION_LIMIT = 500 @@ -26,7 +27,10 @@ export function resetAiVaultForcedRescanThrottleForTest(): void { type AiVaultRefreshArgs = { force?: boolean; background?: boolean } -export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { +export function useAiVaultSessionRefresh( + scopePaths: readonly string[], + executionHostScope: ExecutionHostScope +): { error: string | null loading: boolean refresh: (args?: AiVaultRefreshArgs) => Promise @@ -45,77 +49,99 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) + const scanScopeKey = `${executionHostScope}\n${scopePathsKey}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths + const executionHostScopeRef = useRef(executionHostScope) + executionHostScopeRef.current = executionHostScope + const currentScanScopeKey = useCallback( + () => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`, + [] + ) - const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => { - // A scope change during an in-flight scan must not be dropped; queue one more - // scan so the current scoped view is refreshed after the older scan settles. - if (refreshInFlightRef.current) { - pendingRefreshRef.current = true - pendingForceRef.current ||= args.force === true - pendingBackgroundRef.current &&= args.background === true - return - } - - refreshInFlightRef.current = true - const refreshId = refreshIdRef.current + 1 - refreshIdRef.current = refreshId - // A manual force scan counts against the throttle so an auto rescan right - // after the button press doesn't trigger a second full scan. - if (args.force === true) { - lastForcedRescanAt = Date.now() - } - // Background (refocus) refreshes usually resolve from the main-process - // cache; suppressing the loading flag avoids a spinner flash on every - // return to the app. - if (args.background !== true) { - setLoading(true) - } - setError(null) - const scopeKey = scopePathsRef.current.join('\n') - try { - const result = await window.api.aiVault.listSessions({ - limit: SESSION_LIMIT, - scopePaths: scopePathsRef.current, - force: args.force - }) - if (!mountedRef.current || refreshIdRef.current !== refreshId) { + const refresh = useCallback( + async (args: AiVaultRefreshArgs = {}): Promise => { + // A scope change during an in-flight scan must not be dropped; queue one more + // scan so the current scoped view is refreshed after the older scan settles. + if (refreshInFlightRef.current) { + pendingRefreshRef.current = true + pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } - // A cache hit returns the snapshot already on screen; skip the state - // updates so refocus flips don't force pointless re-renders. - if ( - lastAppliedScanRef.current?.scopeKey === scopeKey && - lastAppliedScanRef.current.scannedAt === result.scannedAt - ) { - return + + refreshInFlightRef.current = true + const refreshId = refreshIdRef.current + 1 + refreshIdRef.current = refreshId + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() } - lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt } - setScanResult(result) - setSessions(result.sessions) - } catch (err) { - if (mountedRef.current && refreshIdRef.current === refreshId) { - setError(err instanceof Error ? err.message : String(err)) + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) } - } finally { - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) + setError(null) + const scopeKey = scopePathsRef.current.join('\n') + const hostScope = executionHostScopeRef.current + const scanKey = `${hostScope}\n${scopeKey}` + try { + const result = await window.api.aiVault.listSessions({ + limit: SESSION_LIMIT, + scopePaths: scopePathsRef.current, + executionHostScope: hostScope, + force: args.force + }) + if (!mountedRef.current || refreshIdRef.current !== refreshId) { + return + } + // Why: host/scope changes queue a follow-up scan, but the older result + // may resolve first and must not briefly paint the wrong history list. + if (scanKey !== currentScanScopeKey()) { + return + } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scanKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } + setScanResult(result) + setSessions(result.sessions) + } catch (err) { + if ( + mountedRef.current && + refreshIdRef.current === refreshId && + scanKey === currentScanScopeKey() + ) { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + refreshInFlightRef.current = false + if (mountedRef.current && refreshIdRef.current === refreshId) { + setLoading(false) + } + if (pendingRefreshRef.current && mountedRef.current) { + pendingRefreshRef.current = false + const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current + pendingForceRef.current = false + pendingBackgroundRef.current = true + void refresh({ force, background }) + } } - if (pendingRefreshRef.current && mountedRef.current) { - pendingRefreshRef.current = false - const force = pendingForceRef.current - // The queued refresh is background-only if every queued caller was. - const background = pendingBackgroundRef.current - pendingForceRef.current = false - pendingBackgroundRef.current = true - void refresh({ force, background }) - } - } - // Deps are intentionally empty: refresh reads changing values through refs - // and recurses on itself, so its identity must stay stable. - }, []) + // Deps intentionally avoid changing scope values: refresh reads them + // through refs and recurses on itself, so its identity must stay stable. + }, + [currentScanScopeKey] + ) // Forced rescans triggered by events (refocus, agent-session starts) run // immediately when the throttle allows, otherwise once as soon as it frees @@ -162,7 +188,7 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (!force) { requestForcedRescan() } - }, [refresh, requestForcedRescan, scopePathsKey]) + }, [refresh, requestForcedRescan, scanScopeKey]) // Sessions started while the app was backgrounded should appear when the // user returns, so refocus also bypasses the scan cache (throttled). OS diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index b8a428cd0c0..eb82d910cb9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,8 +1,10 @@ import type { Repo, Worktree } from '../../../../shared/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -34,6 +36,7 @@ export type AiVaultSessionResumeActions = { export function resolveAiVaultSessionResumeState(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -56,6 +59,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId, targetState }) @@ -78,6 +82,7 @@ export function resolveAiVaultSessionResumeState(args: { export function resolveAiVaultSessionResumeActions(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -92,11 +97,13 @@ export function resolveAiVaultSessionResumeActions(args: { const sessionTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -142,6 +149,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( function resolveSupportedResumeWorktreeId(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -154,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + args.worktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return null } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index b378afe3d42..58687977a33 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { aiVaultWorktreeCompactPath, aiVaultWorktreeJumpTooltip, @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Find the pane', @@ -55,6 +56,19 @@ function makeWorktree(overrides: Partial = {}): Worktree { return { ...worktree, ...overrides } } +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + connectionId: null, + executionHostId: 'local', + ...overrides + } +} + describe('resolveAiVaultSessionWorktreeInfo', () => { it('marks the selected owning worktree as current', () => { const worktree = makeWorktree() @@ -135,6 +149,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => { path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' }) }) + + it('uses the session host when multiple worktrees share the same path', () => { + const localWorktree = makeWorktree({ + id: 'repo-local::/srv/orca', + repoId: 'repo-local', + displayName: 'local', + path: '/srv/orca', + hostId: 'local' + }) + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca', + hostId: 'ssh:target-1' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + worktrees: [localWorktree, sshWorktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: sshWorktree.id + }) + }) + + it('uses repo host ownership when a legacy worktree lacks host metadata', () => { + const worktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })], + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: worktree.id + }) + }) }) describe('canJumpToAiVaultSessionWorktree', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index bd7aeef1bea..31222bec1b5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -1,6 +1,12 @@ import { useMemo } from 'react' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isPathInsideOrEqual, isRuntimePathAbsolute, @@ -8,7 +14,7 @@ import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { translate } from '@/i18n/i18n' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -23,16 +29,19 @@ export type AiVaultSessionWorktreeInfo = { type WorktreeCandidate = { worktree: Worktree path: string + hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' } export function resolveAiVaultSessionWorktreeInfo({ session, + repos = [], worktrees, activeWorktreeId }: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -40,8 +49,10 @@ export function resolveAiVaultSessionWorktreeInfo({ return null } - const candidates = buildWorktreeCandidates(worktrees) + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const candidates = buildWorktreeCandidates(worktrees, repos) .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) .sort(compareWorktreeCandidates) const best = candidates[0] @@ -85,6 +96,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul export function resolveAiVaultSessionWorktreeDisplay(args: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -117,10 +129,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { export function useAiVaultSessionWorktreeMap({ sessions, + repos = [], worktrees, activeWorktreeId }: { sessions: readonly AiVaultSession[] + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): ReadonlyMap { @@ -130,13 +144,14 @@ export function useAiVaultSessionWorktreeMap({ sessions.flatMap((session) => { const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ session, + repos, worktrees, activeWorktreeId }) return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] }) ), - [activeWorktreeId, sessions, worktrees] + [activeWorktreeId, repos, sessions, worktrees] ) } @@ -192,13 +207,22 @@ export function aiVaultWorktreeJumpTooltip( ) } -function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { +function buildWorktreeCandidates( + worktrees: readonly Worktree[], + repos: readonly Pick[] +): WorktreeCandidate[] { const candidates: WorktreeCandidate[] = [] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) for (const worktree of worktrees) { + const repo = repoById.get(worktree.repoId) + const hostId = + normalizeExecutionHostId(worktree.hostId) ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { candidates.push({ worktree, path: worktree.path, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'current-path' }) @@ -211,6 +235,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid candidates.push({ worktree, path: parsed.worktreePath, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'prior-path' }) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index ee41d5c6e81..af09c852cab 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 're import { toast } from 'sonner' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import { @@ -169,7 +170,9 @@ export default function AiVaultSessionDropLayer({ return true } - const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId) + const state = useAppStore.getState() + const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId) if (targetStatus === 'runtime') { toast.error( translate( @@ -191,13 +194,15 @@ export default function AiVaultSessionDropLayer({ if ( !canResumeAiVaultSessionOnTarget({ sessionFilePath: payload.sessionFilePath ?? null, - targetStatus + sessionExecutionHostId: payload.sessionExecutionHostId ?? null, + targetStatus, + targetExecutionHostId }) ) { toast.error( translate( - 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Drop it onto a workspace on the same host.' ) ) return true diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ea81c2c094a..1080958f7c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2589,6 +2589,7 @@ "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { @@ -9590,6 +9591,7 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Project", "currentProjectLower": "current project", - "project": "Project" + "project": "Project", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 97cbdff6f63..b7a29585352 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2589,7 +2589,8 @@ "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.", "openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.", "openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Proyecto", "currentProjectLower": "proyecto actual", - "project": "Proyecto" + "project": "Proyecto", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "Actualizado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 590f6d505cf..e6ff4fc77f5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2589,7 +2589,8 @@ "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9619,7 +9621,9 @@ "globalScope": "グローバル", "projectScope": "プロジェクト", "currentProjectLower": "現在のプロジェクト", - "project": "プロジェクト" + "project": "プロジェクト", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新日時", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8ae5f66134f..705815c7739 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2589,7 +2589,8 @@ "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9619,7 +9621,9 @@ "globalScope": "전역", "projectScope": "프로젝트", "currentProjectLower": "현재 프로젝트", - "project": "프로젝트" + "project": "프로젝트", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4166c860372..db284162150 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2589,7 +2589,8 @@ "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9619,7 +9621,9 @@ "globalScope": "全局", "projectScope": "项目", "currentProjectLower": "当前项目", - "project": "项目" + "project": "项目", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新时间", diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 1fa2559074d..dc0345491bf 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -294,4 +294,111 @@ describe('ai vault resume command runtime', () => { }) ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) + + it('returns the remote resume command verbatim for non-local host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" }) + }) + + it('bypasses the resume pipeline even when the command override is blank', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: ' ', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'") + }) + + it('rebuilds the command when a non-blank override is supplied for a remote session', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + }) + + it('rebuilds overridden remote commands with the recorded remote host platform', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:/Users/alice/repo', + codexHome: 'C:/Users/alice/.codex', + executionHostId: 'ssh:win-box', + executionHostPlatform: 'win32', + resumeCommand: + 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" + ) + }) + + it('ignores a stored resume command for local-host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'local', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 95da5ee49de..691d69efc09 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -19,10 +19,14 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -type AiVaultResumeCommandSession = Pick +type AiVaultResumeCommandSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' +> & + Partial> export type AiVaultResumeStartup = { command: string @@ -65,7 +69,20 @@ export function buildAiVaultResumeStartupForWorktree(args: { session: AiVaultResumeCommandSession commandOverride?: string | null }): AiVaultResumeStartup { - const platform = getAiVaultResumePlatform(args.state, args.worktreeId) + if ( + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.resumeCommand && + !args.commandOverride?.trim() + ) { + return { command: args.session.resumeCommand } + } + const platform = + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.executionHostPlatform + ? args.session.executionHostPlatform + : getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) // Why: the queued command is typed verbatim into the freshly spawned tab whose // live shell is the configured Windows shell (default PowerShell). Hardcoding diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index a2c827cf99b..207bc525a59 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, @@ -68,6 +69,51 @@ describe('ai vault session storage compatibility', () => { ).toBe(true) }) + it('allows host-tagged SSH sessions only on the matching SSH target', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:other-box' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local', + targetExecutionHostId: 'local' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local' + }) + ).toBe(false) + }) + + it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: wslSessionFile, + sessionExecutionHostId: 'local', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + }) + it('never allows runtime or unknown targets', () => { expect( canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) @@ -166,6 +212,34 @@ describe('ai vault resume target ownership', () => { ).toBe('runtime') }) + it('resolves exact execution host ids for active workspaces', () => { + const state = makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1', hostId: 'ssh:ssh-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }) + + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'repo-1::/repo/orca')).toBe('ssh:ssh-1') + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'worktree:repo-1::/repo/orca')).toBe( + 'ssh:ssh-1' + ) + }) + + it('resolves local execution host ids for local workspaces', () => { + expect( + getAiVaultResumeWorkspaceExecutionHostId( + makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }), + 'repo-1::/repo/orca' + ) + ).toBe('local') + }) + it('supports folder workspaces owned by SSH project groups', () => { expect( getAiVaultResumeWorkspaceTargetStatus( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 74a1c8c48f2..823840ec85b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, @@ -43,11 +44,33 @@ export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | u export function canResumeAiVaultSessionOnTarget(args: { sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null targetStatus: AiVaultResumeTargetStatus + targetExecutionHostId?: ExecutionHostId | null }): boolean { if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { return false } + const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) + if (sessionExecutionHostId) { + if (targetExecutionHostId) { + if (sessionExecutionHostId === targetExecutionHostId) { + return true + } + // Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the + // file lives under a WSL UNC path reachable from any SSH shell into this + // machine, so we bypass the exact host-id match for that case. + return ( + sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID && + args.targetStatus === 'ssh' && + isWslStoredAiVaultSessionFile(args.sessionFilePath) + ) + } + if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) { + return false + } + } // Why: vault sessions are scanned from this machine's disk (host home dirs // plus local WSL homes). An SSH shell can only reach the WSL-stored ones // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist @@ -85,6 +108,32 @@ export function getAiVaultResumeWorktreeTargetStatus(args: { ) } +export function getAiVaultResumeWorkspaceExecutionHostId( + state: Pick, + workspaceId: string | null +): ExecutionHostId | null { + if (!workspaceId) { + return null + } + + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type === 'folder') { + return getAiVaultResumeFolderExecutionHostId(state, workspaceKey.folderWorkspaceId) + } + + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + const worktree = Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === worktreeId) + const worktreeHostId = normalizeExecutionHostId(worktree?.hostId) + if (worktreeHostId) { + return worktreeHostId + } + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + export function getAiVaultResumeWorkspaceTargetStatus( state: Pick, workspaceId: string | null @@ -136,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus( ) } +function getAiVaultResumeFolderExecutionHostId( + state: Pick, + folderWorkspaceId: string +): ExecutionHostId | null { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + const groupHostId = normalizeExecutionHostId(group?.executionHostId) + if (groupHostId) { + return groupHostId + } + const explicitConnectionId = (workspace.connectionId ?? group?.connectionId ?? '').trim() + if (explicitConnectionId) { + return toSshExecutionHostId(explicitConnectionId) + } + return mergeAiVaultResumeExecutionHostIds( + getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getRepoExecutionHostId) + ) +} + function getAiVaultResumeExecutionHostTargetStatus( hostId: ExecutionHostId | null | undefined ): AiVaultResumeTargetStatus { @@ -162,3 +234,13 @@ function mergeAiVaultResumeExecutionHostTargetStatuses( } return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' } + +function mergeAiVaultResumeExecutionHostIds( + hostIds: readonly ExecutionHostId[] +): ExecutionHostId | null { + if (hostIds.length === 0) { + return LOCAL_EXECUTION_HOST_ID + } + const uniqueHostIds = new Set(hostIds) + return uniqueHostIds.size === 1 ? (hostIds[0] ?? null) : null +} diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 2b52359e534..7b5ec941dd7 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -1,6 +1,7 @@ import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session' export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start' @@ -15,6 +16,7 @@ export type AiVaultSessionDragPayload = { // Why: drop targets must know where the session file lives (host vs local // WSL) to reject SSH panes that cannot reach it. sessionFilePath?: string + sessionExecutionHostId?: ExecutionHostId // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -67,6 +69,8 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && + (payload.sessionExecutionHostId === undefined || + Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -114,13 +118,23 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed + const { + agent, + sessionId, + title, + command, + sessionFilePath, + sessionExecutionHostId, + env, + launchConfig + } = parsed return { agent, sessionId, title, command, ...(sessionFilePath ? { sessionFilePath } : {}), + ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 70d2054d623..58bf82597ab 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -5,6 +5,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { TuiAgent } from './types' +import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', @@ -53,6 +54,8 @@ export type AiVaultSessionPreviewMessage = { export type AiVaultSession = { id: string + executionHostId: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null agent: AiVaultAgent sessionId: string title: string @@ -71,6 +74,7 @@ export type AiVaultSession = { } export type AiVaultScanIssue = { + executionHostId?: ExecutionHostId agent: AiVaultAgent path: string message: string @@ -82,6 +86,7 @@ export type AiVaultListArgs = { // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] + executionHostScope?: ExecutionHostScope } export type AiVaultListResult = { diff --git a/tests/e2e/ssh-ai-vault-session-history.spec.ts b/tests/e2e/ssh-ai-vault-session-history.spec.ts new file mode 100644 index 00000000000..0cb6a485ca4 --- /dev/null +++ b/tests/e2e/ssh-ai-vault-session-history.spec.ts @@ -0,0 +1,293 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver' +import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('SSH Agent Session History', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.') + + test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const stamp = Date.now() + const defaultSessionId = `remote-ai-vault-${stamp}` + const runtimeSessionId = `remote-ai-vault-runtime-${stamp}` + const claudeSessionId = `remote-ai-vault-claude-${stamp}` + const defaultTitle = `Remote AI Vault ${stamp}` + const runtimeTitle = `Remote Runtime AI Vault ${stamp}` + const claudeTitle = `Remote Claude AI Vault ${stamp}` + + try { + target = startDockerSshRelayTarget(testInfo) + seedRemoteAiVaultHistory(target, { + defaultSessionId, + runtimeSessionId, + claudeSessionId, + defaultTitle, + runtimeTitle, + claudeTitle + }) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + const sshScope = `ssh:${encodeURIComponent(remote.targetId)}` + + const scan = await orcaPage.evaluate( + async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => { + const local = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + const ssh = await window.api.aiVault.listSessions({ + executionHostScope: sshScope, + force: true + }) + const all = await window.api.aiVault.listSessions({ + executionHostScope: 'all', + force: true + }) + return { + localHasRemote: local.sessions.some((session) => session.title === defaultTitle), + sshTitles: ssh.sessions.map((session) => session.title), + allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle), + allHasClaude: all.sessions.some((session) => session.title === claudeTitle), + remoteHostIds: ssh.sessions + .filter((session) => + [defaultTitle, runtimeTitle, claudeTitle].includes(session.title) + ) + .map((session) => session.executionHostId), + remoteCommands: ssh.sessions + .filter((session) => session.title === defaultTitle || session.title === runtimeTitle) + .map((session) => session.resumeCommand) + } + }, + { sshScope, defaultTitle, runtimeTitle, claudeTitle } + ) + expect(scan.localHasRemote).toBe(false) + expect(scan.sshTitles).toEqual( + expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle]) + ) + expect(scan.allHasRuntime).toBe(true) + expect(scan.allHasClaude).toBe(true) + expect(new Set(scan.remoteHostIds)).toEqual(new Set([sshScope])) + expect(scan.remoteCommands.join('\n')).toContain("CODEX_HOME='/root/.codex'") + expect(scan.remoteCommands.join('\n')).toContain( + "CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'" + ) + + const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true }) + const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true }) + + await openAiVaultSidebar(orcaPage) + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + const hostButton = orcaPage.getByRole('button', { name: /Session History host:/ }) + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: /Local/ }).click() + await expect(defaultSessionTitle).toHaveCount(0, { timeout: 30_000 }) + + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: 'All hosts' }).click() + await expect(runtimeSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await hostButton.click() + await orcaPage + .getByRole('menuitemradio') + .filter({ hasNotText: /Local|All hosts/ }) + .click() + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await installStartupQueueProbe(orcaPage) + await defaultSessionTitle.first().click() + await orcaPage.getByText('Resume in Worktree', { exact: true }).click() + + await expect + .poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 }) + .toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`) + const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage) + expect(queuedWorktreeId).toBe(remote.worktreeId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) + +function seedRemoteAiVaultHistory( + target: DockerSshRelayTarget, + args: { + defaultSessionId: string + runtimeSessionId: string + claudeSessionId: string + defaultTitle: string + runtimeTitle: string + claudeTitle: string + } +): void { + dockerExec( + target, + [ + 'mkdir -p /root/.codex/sessions/2026/07/04', + 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', + 'mkdir -p /root/.claude/projects/orca' + ].join(' && ') + ) + dockerWriteFile( + target, + '/root/.codex/session_index.jsonl', + jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), + '600' + ) + dockerWriteFile( + target, + `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, + codexTranscript({ + sessionId: args.defaultSessionId, + title: args.defaultTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T01:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, + codexTranscript({ + sessionId: args.runtimeSessionId, + title: args.runtimeTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T02:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, + claudeTranscript({ + sessionId: args.claudeSessionId, + title: args.claudeTitle, + timestamp: '2026-07-04T03:00:00.000Z' + }), + '600' + ) +} + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { id: args.sessionId, cwd: args.cwd } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { + return jsonLines([ + { + sessionId: args.sessionId, + timestamp: args.timestamp, + type: 'user', + message: { content: [{ type: 'text', text: args.title }] } + }, + { + sessionId: args.sessionId, + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } + } + ]) +} + +async function openAiVaultSidebar(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) +} + +async function installStartupQueueProbe(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string; startup: { command: string } }[] + } + holder.__aiVaultQueuedStartups = [] + const current = store.getState() + const original = current.queueTabStartupCommand + store.setState({ + queueTabStartupCommand: (tabId, startup) => { + holder.__aiVaultQueuedStartups?.push({ tabId, startup: { command: startup.command } }) + original(tabId, startup) + } + }) + }) +} + +async function readLastQueuedStartupCommand(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { startup: { command: string } }[] + } + return holder.__aiVaultQueuedStartups?.at(-1)?.startup.command ?? null + }) +} + +async function readLastQueuedStartupWorktreeId(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string }[] + } + const tabId = holder.__aiVaultQueuedStartups?.at(-1)?.tabId + if (!tabId) { + return null + } + const state = window.__store?.getState() + if (!state) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null + }) +} From 17d5eff5d69cd088311698a35c2f291aed53db48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:02:14 +0000 Subject: [PATCH 217/233] release: v1.4.123-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 083696f8850..eb6139b6668 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.123-rc.0", + "version": "1.4.123-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 136cb50bcbb740814fbf7d0d90e9bee730f39161 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:29:05 -0700 Subject: [PATCH 218/233] Reflow and edit hard-wrapped prose within single paragraph blocks (#7407) * Reflow and edit hard-wrapped prose within single paragraph blocks Instead of splitting consecutive markdown source lines into multiple visual paragraph nodes during document initialization, preserve them as a single paragraph containing literal newlines. - Use `white-space: normal` CSS to reflow soft breaks naturally. - Introduce `deleteAdjacentEmptyParagraph` to handle Backspace/Delete without converting soft newlines to hard break elements. - Update the cut handler to delete only a visual line on Cmd+X within hard-wrapped paragraphs. - Avoid split-pane/sync phantom dirty states caused by structural block splitting. * Document why normalizeEmptyListItems is used for paragraph reflow Add comments to clarify that normalizeEmptyListItems preserves hard-wrapped paragraphs as single paragraphs, allowing them to reflow via CSS instead of being split on load or external sync. --- .../src/assets/rich-markdown-editor.css | 7 + .../components/editor/RichMarkdownEditor.tsx | 4 +- .../editor/RichMarkdownErrorBoundary.tsx | 2 +- .../editor/markdown-dirty-state.test.ts | 42 +-- .../editor/rich-markdown-cut.test.ts | 142 +++++--- .../editor/rich-markdown-editor-config.ts | 7 +- ...ch-markdown-empty-paragraph-delete.test.ts | 311 ++++++++++++++++++ .../rich-markdown-empty-paragraph-delete.ts | 241 ++++++++++++++ .../editor/rich-markdown-key-handler.ts | 16 +- .../editor/rich-markdown-normalize.test.ts | 20 +- .../editor/rich-markdown-normalize.ts | 31 +- .../editor/useRichMarkdownProgrammaticSync.ts | 6 +- .../src/hooks/useEditorExternalWatch.ts | 2 +- tests/e2e/markdown-prose-reflow.spec.ts | 246 ++++++++++++++ 14 files changed, 987 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts create mode 100644 tests/e2e/markdown-prose-reflow.spec.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index a8a03d39ca4..395b18131a7 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -566,6 +566,13 @@ margin: 0.75em 0; } +/* Why: document markdown files treat source newlines inside a paragraph as + soft breaks, so prose must collapse them instead of inheriting Tiptap's + editable-root `break-spaces` behavior. */ +.rich-markdown-editor-shell .rich-markdown-editor p { + white-space: normal; +} + .rich-markdown-editor .tiptap-mathematics-render { border-radius: 4px; } diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 28f275a0437..990d2fdaf38 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -114,12 +114,12 @@ export default function RichMarkdownEditor({ const editorRef = useRef(null) const cancelAutoFocusRef = useRef<(() => void) | null>(null) const serializeTimerRef = useRef(null) - // Why: normalizeSoftBreaks dispatches a ProseMirror transaction inside onCreate + // Why: empty-list repair dispatches a ProseMirror transaction inside onCreate // which triggers onUpdate. Without this guard the editor immediately marks the // file dirty before the user has typed anything. const isInitializingRef = useRef(true) // Why: internal maintenance paths can dispatch transactions after mount - // (external reloads, soft-break normalization, image-path refresh). Those + // (external reloads, empty-list repair, image-path refresh). Those // are not user edits, so onUpdate must ignore them or split panes can flip a // shared file dirty without any real content change. const isApplyingProgrammaticUpdateRef = useRef(false) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index c99cc1a675b..105be54524f 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -13,7 +13,7 @@ type State = { } // Why: a thrown exception inside the TipTap/ProseMirror render or in the -// effect that runs `setContent` + `normalizeSoftBreaks` on external-reload +// effect that runs `setContent` + empty-list repair on external-reload // would escape to the React root and — without this boundary — cause React // 18 to unmount the entire renderer subtree, blacking out the whole Orca // window (see issue #826). Scoping the boundary to the rich-markdown editor diff --git a/src/renderer/src/components/editor/markdown-dirty-state.test.ts b/src/renderer/src/components/editor/markdown-dirty-state.test.ts index 108772204a0..46b786ddd75 100644 --- a/src/renderer/src/components/editor/markdown-dirty-state.test.ts +++ b/src/renderer/src/components/editor/markdown-dirty-state.test.ts @@ -8,7 +8,7 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' const testExtensions = [ StarterKit, @@ -49,12 +49,12 @@ function shouldSyncPropIntoEditor( } /** - * Simulates the onCreate flow: normalizeSoftBreaks then getMarkdown(). + * Simulates the onCreate flow: empty-list repair then getMarkdown(). */ function simulateOnCreate(diskContent: string): string { const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) return editor.getMarkdown() } finally { editor.destroy() @@ -65,7 +65,7 @@ function simulateOnCreate(diskContent: string): string { // 1. trimEnd normalization prevents phantom dirty from trailing newlines // // getMarkdown() always appends a trailing \n. For content that round-trips -// cleanly (no soft-break normalization), the ONLY difference is that +// cleanly (no structural repair changes), the ONLY difference is that // trailing newline. trimEnd() must eliminate that false positive. // ----------------------------------------------------------------------- describe('trailing newline does not cause false dirty state', () => { @@ -94,45 +94,37 @@ describe('trailing newline does not cause false dirty state', () => { }) // ----------------------------------------------------------------------- -// 2. normalizeSoftBreaks produces structural differences that getMarkdown() -// serializes differently. These cannot be hidden by trimEnd — they are -// handled at runtime by the isInitializingRef guard in onUpdate. -// -// The tests below document the known divergence so that future changes -// to the serializer or normalizer don't silently shift which category -// a given input falls into. +// 2. Hard-wrapped prose must stay structurally clean. The rich editor renders +// soft breaks through CSS reflow, not by splitting the document model. // ----------------------------------------------------------------------- -describe('normalizeSoftBreaks: known structural changes', () => { - it('splits consecutive lines into separate paragraphs', () => { +describe('document soft-break round-trip', () => { + it('keeps consecutive source lines in one paragraph', () => { const editor = createEditor('Line one\nLine two\nLine three') try { const before = countParagraphs(editor) - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const after = countParagraphs(editor) - expect(after).toBeGreaterThan(before) - expect(after).toBe(3) + expect(before).toBe(1) + expect(after).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe('Line one\nLine two\nLine three') } finally { editor.destroy() } }) - it('serialized soft-break content differs from disk content', () => { + it('round-trips a hard-wrapped paragraph without blank-line expansion', () => { const disk = 'Line one\nLine two' const serialized = simulateOnCreate(disk) - // After normalization each line is its own paragraph, serialized with - // blank-line separators. This difference is NOT a bug — the - // isInitializingRef guard prevents it from marking the file dirty. - expect(trimEnd(serialized)).not.toBe(trimEnd(disk)) - expect(trimEnd(serialized)).toBe('Line one\n\nLine two') + expect(trimEnd(serialized)).toBe(trimEnd(disk)) }) it('does not modify content without soft breaks', () => { const editor = createEditor('# Title\n\nBody text') try { const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() expect(docAfter).toEqual(docBefore) @@ -171,7 +163,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# README\n\nOriginal text' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Insert text via a ProseMirror transaction (no DOM required) const { tr } = editor.state @@ -189,7 +181,7 @@ describe('real edits are detected as dirty', () => { const diskContent = '# Title\n\nParagraph to keep\n\nParagraph to delete' const editor = createEditor(diskContent) try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) // Delete the last paragraph node const doc = editor.state.doc diff --git a/src/renderer/src/components/editor/rich-markdown-cut.test.ts b/src/renderer/src/components/editor/rich-markdown-cut.test.ts index 3ca4064c4b8..67eb9f3766f 100644 --- a/src/renderer/src/components/editor/rich-markdown-cut.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-cut.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' +import { TextSelection } from '@tiptap/pm/state' +import type { EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -8,7 +11,8 @@ import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { handleRichMarkdownCut } from './rich-markdown-cut-handler' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' /** * Minimal extensions matching the rich editor schema without UI dependencies. @@ -33,6 +37,10 @@ function createEditor(markdown: string): Editor { }) } +afterEach(() => { + vi.restoreAllMocks() +}) + /** * Simulates the cut handler's depth walk to determine what node would be cut. * This mirrors the logic in RichMarkdownEditor.tsx handleDOMEvents.cut, @@ -101,6 +109,25 @@ function countParagraphs(editor: Editor): number { return count } +function createClipboardEventMock(): { + data: Map + event: ClipboardEvent + preventDefault: ReturnType +} { + const data = new Map() + const preventDefault = vi.fn() + const event = { + clipboardData: { + setData: vi.fn((type: string, value: string) => { + data.set(type, value) + }) + }, + preventDefault + } as unknown as ClipboardEvent + + return { data, event, preventDefault } +} + describe('rich markdown cut handler behavior', () => { it('heading + paragraph: cuts only the paragraph', () => { const editor = createEditor('# Title\n\nBody text here.\n') @@ -121,20 +148,25 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('multi-line markdown (no blank separator): after normalization, each line is a separate paragraph', () => { + it('hard-wrapped document prose stays one paragraph after normalization', () => { const editor = createEditor('Line one\nLine two\nLine three\n') - // Before normalization: single paragraph with \n chars expect(countParagraphs(editor)).toBe(1) expect(editor.state.doc.firstChild!.textContent).toContain('\n') - // Normalize: splits the single paragraph into three + normalizeEmptyListItems(editor) + + expect(countParagraphs(editor)).toBe(1) + expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three') + + editor.destroy() + }) + + it('soft-break normalization still creates visible paragraph breaks', () => { + const editor = createEditor('Line one\nLine two\nLine three\n') normalizeSoftBreaks(editor) - // After normalization: three separate paragraph nodes expect(countParagraphs(editor)).toBe(3) - - // Each paragraph is its own line — no \n inside any of them const paragraphs: string[] = [] editor.state.doc.forEach((node) => { if (node.type.name === 'paragraph') { @@ -144,37 +176,71 @@ describe('rich markdown cut handler behavior', () => { }) expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three']) - // Cmd+X on the first paragraph only cuts "Line one", not all three lines - const result = simulateCut(editor, 1) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line one') - editor.destroy() }) - it('multi-line: Cmd+X on second line only cuts that line after normalization', () => { - const editor = createEditor('Line one\nLine two\nLine three\n') - normalizeSoftBreaks(editor) + it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => { + const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays') + try { + normalizeEmptyListItems(editor) + expect(countParagraphs(editor)).toBe(1) - // Find the second paragraph ("Line two") - const doc = editor.state.doc - let secondParaPos = -1 - let count = 0 - doc.forEach((node, offset) => { - if (node.type.name === 'paragraph') { - count++ - if (count === 2) { - secondParaPos = offset + 1 + const text = editor.state.doc.firstChild!.textContent + const paraStart = 1 + const paraEnd = paraStart + text.length + const lineFrom = paraStart + text.indexOf('Middle') + const nextLineFrom = paraStart + text.indexOf('Omega') + const cursorPos = lineFrom + 'Middle'.length + + let viewState = editor.state.apply( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, cursorPos)) + ) + + const paragraphElement = document.createElement('p') + vi.spyOn(paragraphElement, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({ x: 20, y: 0, width: 600, height: 60 }) + ) + const view = { + get state() { + return viewState + }, + dispatch: vi.fn((tr) => { + viewState = viewState.apply(tr) + }), + domAtPos: vi.fn(() => ({ node: paragraphElement, offset: 0 })), + coordsAtPos: vi.fn((pos: number) => { + if (pos === paraStart) { + return { top: 0, bottom: 20, left: 20, right: 20 } + } + if (pos === paraEnd) { + return { top: 40, bottom: 60, left: 280, right: 280 } + } + return { top: 20, bottom: 40, left: 120, right: 120 } + }), + posAtCoords: vi.fn((coords: { top: number }) => { + return { pos: coords.top < 40 ? lineFrom : nextLineFrom, inside: -1 } + }) + } as unknown as EditorView + + const clipboard = createClipboardEventMock() + const handled = handleRichMarkdownCut(view, clipboard.event) + + expect(handled).toBe(true) + expect(clipboard.preventDefault).toHaveBeenCalled() + expect(clipboard.data.get('text/plain')).toBe('Middle segment is cut\n') + expect(view.state.doc.firstChild!.textContent).toBe( + 'Alpha segment stays\nOmega segment stays' + ) + let paragraphCount = 0 + view.state.doc.forEach((node) => { + if (node.type.name === 'paragraph') { + paragraphCount++ } - } - }) - - expect(secondParaPos).toBeGreaterThan(0) - const result = simulateCut(editor, secondParaPos) - expect(result.cutNodeType).toBe('paragraph') - expect(result.cutText).toBe('Line two') - - editor.destroy() + }) + expect(paragraphCount).toBe(1) + } finally { + editor.destroy() + } }) it('paragraphs separated by blank lines are separate blocks', () => { @@ -348,11 +414,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks is idempotent on already-clean documents', () => { + it('normalizeEmptyListItems is idempotent on already-clean documents', () => { const editor = createEditor('First.\n\nSecond.\n\nThird.\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // Already separated paragraphs should not be modified @@ -361,11 +427,11 @@ describe('rich markdown cut handler behavior', () => { editor.destroy() }) - it('normalizeSoftBreaks does not modify list items or blockquotes', () => { + it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => { const editor = createEditor('- Item 1\n- Item 2\n') const docBefore = editor.state.doc.toJSON() - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const docAfter = editor.state.doc.toJSON() // List structure should be unchanged (no top-level paragraphs to split) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index de2e520d631..28536b258c5 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -2,7 +2,7 @@ import type { Editor, UseEditorOptions } from '@tiptap/react' import { handleRichMarkdownCut } from './rich-markdown-cut-handler' import { handleRichMarkdownPaste } from './rich-markdown-paste-handler' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { syncSlashMenu, @@ -208,7 +208,10 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE clearAnnotationTarget() }, onCreate: ({ editor: nextEditor }) => { - normalizeSoftBreaks(nextEditor) + // Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped + // source paragraphs stay one paragraph and reflow via CSS instead of being + // split on load. + normalizeEmptyListItems(nextEditor) lastCommittedMarkdownRef.current = content isInitializingRef.current = false cancelAutoFocusRef.current?.() diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts new file mode 100644 index 00000000000..bfdb60af423 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Markdown } from '@tiptap/markdown' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' + +const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] + +const hardWrappedMarkdown = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + '## Next section' + +const hardWrappedTwoParagraphs = + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.\n\n' + + 'Second paragraph starts here.' + +const headingBeforeHardWrappedParagraph = + '## Existing section\n\n' + + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + +function createEditor(content = hardWrappedMarkdown): Editor { + return new Editor({ + element: null, + extensions, + content, + contentType: 'markdown' + }) +} + +function findNodeTextPosition( + editor: Editor, + nodeName: string, + textNeedle: string +): { from: number; to: number } { + let result: { from: number; to: number } | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === nodeName && node.textContent.includes(textNeedle)) { + result = { from: pos + 1, to: pos + node.nodeSize - 1 } + return false + } + return true + }) + + if (!result) { + throw new Error(`Could not find ${nodeName} containing ${textNeedle}`) + } + + return result +} + +function countHardBreaks(editor: Editor): number { + let count = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'hardBreak') { + count += 1 + } + }) + return count +} + +function expectHardWrappedParagraphPreserved(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown().trimEnd()).toBe(hardWrappedMarkdown) +} + +function expectNoHardBreaks(editor: Editor): void { + expect(countHardBreaks(editor)).toBe(0) +} + +describe('rich markdown empty paragraph deletion', () => { + it('Backspace in an inserted empty paragraph preserves soft-wrapped prose', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace at the next block start removes a previous empty paragraph without joining', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete at a soft-wrapped paragraph end removes the following empty paragraph', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + editor.commands.setTextSelection(paragraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Delete in an empty paragraph before soft-wrapped prose preserves soft newlines', () => { + const editor = createEditor(`\n\n${hardWrappedMarkdown}`) + try { + editor.commands.setTextSelection(1) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace after deleting slash text in an empty command paragraph preserves soft newlines', () => { + const editor = createEditor() + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.to) + expect(editor.commands.splitBlock()).toBe(true) + const slashPos = editor.state.selection.from + editor.view.dispatch(editor.state.tr.insertText('/', slashPos)) + editor.view.dispatch(editor.state.tr.delete(slashPos, slashPos + 1)) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectHardWrappedParagraphPreserved(editor) + } finally { + editor.destroy() + } + }) + + it('Backspace joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining two non-empty paragraphs preserves source soft newlines', () => { + const editor = createEditor(hardWrappedTwoParagraphs) + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const heading = findNodeTextPosition(editor, 'heading', 'Next section') + editor.commands.setTextSelection(heading.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Delete joining a following heading preserves source soft newlines', () => { + const editor = createEditor() + try { + const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(firstParagraph.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild?.type.name).toBe('paragraph') + expect(editor.state.doc.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Next section' + ) + } finally { + editor.destroy() + } + }) + + it('Backspace joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime') + editor.commands.setTextSelection(paragraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('Delete joining from a previous heading preserves source soft newlines', () => { + const editor = createEditor(headingBeforeHardWrappedParagraph) + try { + const heading = findNodeTextPosition(editor, 'heading', 'Existing section') + editor.commands.setTextSelection(heading.to) + + expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(0).type.name).toBe('heading') + expect(editor.state.doc.child(0).textContent).toBe( + 'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native' + ) + expect(editor.state.doc.child(1).type.name).toBe('paragraph') + expect(editor.state.doc.child(1).textContent).toBe( + 'chat owns conversion, ordering, and transcript reconciliation.' + ) + expect(editor.getMarkdown()).not.toContain(' \n') + } finally { + editor.destroy() + } + }) + + it('works inside blockquotes without converting soft newlines to hard breaks', () => { + const editor = createEditor( + '> Alpha owns launch-lifetime state keyed by tab id, while native\n' + + '> chat owns conversion, ordering, and transcript reconciliation.\n' + + '>\n' + + '> Second paragraph starts here.' + ) + try { + const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph') + editor.commands.setTextSelection(secondParagraph.from) + + expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true) + + expectNoHardBreaks(editor) + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe( + 'Alpha owns launch-lifetime state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.' + ) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts new file mode 100644 index 00000000000..37081d47813 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.ts @@ -0,0 +1,241 @@ +import type { Editor } from '@tiptap/react' +import { Fragment, type Node as PmNode, type Schema } from '@tiptap/pm/model' +import { TextSelection, type Transaction } from '@tiptap/pm/state' + +const BLOCKED_CONTAINER_TYPES = new Set(['listItem', 'taskItem', 'tableCell', 'tableHeader']) +const PARAGRAPH_JOIN_NEIGHBOR_TYPES = new Set(['paragraph', 'heading']) + +function isEmptyParagraph(node: PmNode | null | undefined): boolean { + return node?.type.name === 'paragraph' && node.content.size === 0 +} + +function hasSoftNewline(node: PmNode): boolean { + return node.textContent.includes('\n') +} + +function setSelectionNear(tr: Transaction, pos: number): void { + const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size)) + tr.setSelection(TextSelection.near(tr.doc.resolve(clampedPos))) +} + +type TextblockBoundary = { + after: PmNode | null + before: PmNode | null + current: PmNode + currentEnd: number + currentStart: number +} + +function getTextblockBoundary(editor: Editor): TextblockBoundary | null { + const { selection } = editor.state + if (!selection.empty || !selection.$from.parent.isTextblock) { + return null + } + + const { $from } = selection + const textblockDepth = $from.depth + for (let depth = 1; depth < textblockDepth; depth += 1) { + if (BLOCKED_CONTAINER_TYPES.has($from.node(depth).type.name)) { + return null + } + } + + const parentIndex = $from.index(textblockDepth - 1) + const container = $from.node(textblockDepth - 1) + return { + after: container.maybeChild(parentIndex + 1) ?? null, + before: container.maybeChild(parentIndex - 1) ?? null, + current: $from.parent, + currentEnd: $from.after(textblockDepth), + currentStart: $from.before(textblockDepth) + } +} + +type SoftNewlineJoin = { + nodes: PmNode[] + selectionOffset: number +} + +function splitParagraphAtFirstSoftNewline( + paragraph: PmNode, + schema: Schema +): { after: Fragment; before: Fragment } | null { + const beforeNodes: PmNode[] = [] + const afterNodes: PmNode[] = [] + let foundSoftNewline = false + + paragraph.content.forEach((child) => { + if (foundSoftNewline) { + afterNodes.push(child) + return + } + + if (!child.isText || !child.text?.includes('\n')) { + beforeNodes.push(child) + return + } + + const newlineIndex = child.text.indexOf('\n') + if (newlineIndex > 0) { + beforeNodes.push(schema.text(child.text.slice(0, newlineIndex), child.marks)) + } + if (newlineIndex + 1 < child.text.length) { + afterNodes.push(schema.text(child.text.slice(newlineIndex + 1), child.marks)) + } + foundSoftNewline = true + }) + + if (!foundSoftNewline) { + return null + } + + return { + after: Fragment.fromArray(afterNodes), + before: Fragment.fromArray(beforeNodes) + } +} + +function createSoftNewlineJoin( + left: PmNode, + right: PmNode, + schema: Schema +): SoftNewlineJoin | null { + if (left.type.name === 'paragraph' && PARAGRAPH_JOIN_NEIGHBOR_TYPES.has(right.type.name)) { + if (!hasSoftNewline(left) && (right.type.name !== 'paragraph' || !hasSoftNewline(right))) { + return null + } + + // Why: ProseMirror Transform.join substitutes paragraph text `\n` with + // hardBreak nodes at textblock boundaries, which makes hard-wrapped prose narrow. + const content = left.content.append(right.content) + if (!left.type.validContent(content)) { + return null + } + + return { + nodes: [left.type.create(left.attrs, content, left.marks)], + selectionOffset: left.content.size + } + } + + if (left.type.name !== 'heading' || right.type.name !== 'paragraph' || !hasSoftNewline(right)) { + return null + } + + const split = splitParagraphAtFirstSoftNewline(right, schema) + if (!split) { + return null + } + + const headingContent = left.content.append(split.before) + if (!left.type.validContent(headingContent)) { + return null + } + + const nodes = [left.type.create(left.attrs, headingContent, left.marks)] + if (split.after.size > 0) { + if (!right.type.validContent(split.after)) { + return null + } + nodes.push(right.type.create(right.attrs, split.after, right.marks)) + } + + return { + nodes, + selectionOffset: left.content.size + } +} + +function dispatchSoftNewlineJoin( + editor: Editor, + from: number, + to: number, + join: SoftNewlineJoin +): void { + const tr = editor.state.tr.replaceWith(from, to, join.nodes) + setSelectionNear(tr, from + 1 + join.selectionOffset) + editor.view.dispatch(tr) +} + +export function deleteAdjacentEmptyParagraph(editor: Editor, direction: 'backward' | 'forward') { + const boundary = getTextblockBoundary(editor) + if (!boundary) { + return false + } + + const { selection } = editor.state + const { $from } = selection + const { after, before, current, currentEnd, currentStart } = boundary + + if (direction === 'backward') { + if ($from.parentOffset !== 0) { + return false + } + + if (isEmptyParagraph(current)) { + if (!before) { + return false + } + // Why: ProseMirror's default Backspace join converts soft `\n` text in + // the previous paragraph into hardBreak nodes. Delete the blank block only. + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart - 1) + editor.view.dispatch(tr) + return true + } + + if (before && isEmptyParagraph(before)) { + const from = currentStart - before.nodeSize + const tr = editor.state.tr.delete(from, currentStart) + setSelectionNear(tr, tr.mapping.map(selection.from, -1)) + editor.view.dispatch(tr) + return true + } + + if (before && current.isTextblock && before.isTextblock) { + const join = createSoftNewlineJoin(before, current, editor.state.schema) + if (!join) { + return false + } + const from = currentStart - before.nodeSize + dispatchSoftNewlineJoin(editor, from, currentEnd, join) + return true + } + + return false + } + + if ($from.parentOffset !== current.content.size) { + return false + } + + if (isEmptyParagraph(current)) { + if (!after) { + return false + } + const tr = editor.state.tr.delete(currentStart, currentEnd) + setSelectionNear(tr, currentStart) + editor.view.dispatch(tr) + return true + } + + if (after && isEmptyParagraph(after)) { + // Why: Delete at the end of a soft-wrapped paragraph should remove the + // blank line without running ProseMirror's newline-to-hardBreak join path. + const tr = editor.state.tr.delete(currentEnd, currentEnd + after.nodeSize) + setSelectionNear(tr, currentEnd - 1) + editor.view.dispatch(tr) + return true + } + + if (after && current.isTextblock && after.isTextblock) { + const join = createSoftNewlineJoin(current, after, editor.state.schema) + if (!join) { + return false + } + dispatchSoftNewlineJoin(editor, currentStart, currentEnd + after.nodeSize, join) + return true + } + + return false +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 42111c8c368..b5e9ced95a9 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -17,6 +17,7 @@ import { convertEmptyNestedOrderedItemToContinuation, exitTrailingEmptyOrderedListItem } from './rich-markdown-list-continuation' +import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete' export type KeyHandlerContext = { isMac: boolean @@ -162,7 +163,20 @@ export function createRichMarkdownKeyHandler( ed && !isComposingMarkdownInput(event, ed) && (convertEmptyNestedOrderedItemToContinuation(ed) || - collapseEmptyListContinuationParagraph(ed)) + collapseEmptyListContinuationParagraph(ed) || + deleteAdjacentEmptyParagraph(ed, 'backward')) + ) { + event.preventDefault() + return true + } + } + + if (event.key === 'Delete') { + const ed = ctx.editorRef.current + if ( + ed && + !isComposingMarkdownInput(event, ed) && + deleteAdjacentEmptyParagraph(ed, 'forward') ) { event.preventDefault() return true diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts index 62599e8df7b..01cb1d96cb2 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Editor } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { Markdown } from '@tiptap/markdown' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize' const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] @@ -24,7 +24,7 @@ describe('rich markdown normalization', () => { const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n') try { - normalizeSoftBreaks(editor) + normalizeEmptyListItems(editor) const list = editor.state.doc.child(0) const emptyItem = list.child(2) @@ -37,7 +37,21 @@ describe('rich markdown normalization', () => { } }) - it('normalizes newline-heavy soft-break paragraphs without splitting text nodes', () => { + it('leaves hard-wrapped document prose as one paragraph', () => { + const editor = createEditor('Line one\nLine two\nLine three') + + try { + normalizeEmptyListItems(editor) + + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.child(0).type.name).toBe('paragraph') + expect(editor.state.doc.child(0).textContent).toBe('Line one\nLine two\nLine three') + } finally { + editor.destroy() + } + }) + + it('keeps soft-break splitting without splitting text nodes', () => { const editor = createEditor('Line one\nLine two\nLine three') try { diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts index c1beb2d7773..695d99440ef 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts @@ -1,22 +1,19 @@ import type { Editor } from '@tiptap/core' import { Fragment, type Node as PmNode } from '@tiptap/pm/model' -/** - * Why: the `marked` parser (with `breaks: false`, the default) treats consecutive - * lines without a blank separator as a single paragraph with literal `\n` characters - * in the text content (e.g. "Line one\nLine two\nLine three"). These `\n` chars are - * invisible in the rendered HTML (normal `white-space` collapsing), but they cause - * the block-cut handler to remove the entire multi-line paragraph on Cmd+X instead - * of just one logical line. - * - * This function normalises the ProseMirror document by splitting any paragraph whose - * text nodes contain `\n` into separate paragraph nodes — one per line — and by - * giving empty parsed list items a paragraph caret target. Inline marks (bold, - * italic, links, etc.) are preserved on each resulting paragraph. This is - * structurally correct for the editing model: each visual line becomes its own block, - * so the cut handler (and all other block-level operations) work on a per-line basis. - */ +type NormalizeOptions = { + splitSoftBreakParagraphs: boolean +} + +export function normalizeEmptyListItems(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: false }) +} + export function normalizeSoftBreaks(editor: Editor): void { + normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: true }) +} + +function normalizeRichMarkdownDocument(editor: Editor, options: NormalizeOptions): void { // Why: we read from editor.view.state (not editor.state) so that the doc // we traverse and the transaction we later create share the same base state. // After setContent(), editor.state can be stale (last React render), while @@ -52,6 +49,10 @@ export function normalizeSoftBreaks(editor: Editor): void { if (node.type !== paragraphType) { return true // continue descending into container nodes } + if (!options.splitSoftBreakParagraphs) { + // Document-editor prose reflows soft breaks through CSS, preserving clean diffs. + return false + } if (!node.textContent.includes('\n')) { return false // no need to descend into inline content } diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index db34644c66a..2defa250401 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react' import type { MarkdownDocument } from '../../../../shared/types' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' -import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { normalizeEmptyListItems } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' import { createRichMarkdownImageResolverContext, @@ -133,7 +133,9 @@ function applyExternalRichMarkdownContent( contentType: 'markdown', emitUpdate: false }) - normalizeSoftBreaks(editor) + // Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from + // external content, matching onCreate's single-paragraph reflow behavior. + normalizeEmptyListItems(editor) lastCommittedMarkdownRef.current = content if (hadFocus) { const docSize = editor.state.doc.content.size diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index de0f681998c..157b91df034 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -33,7 +33,7 @@ import { isGitRepoKind } from '../../../shared/repo-kind' // VSCode) land as a short burst of `update` events — or `delete + create` on // renamers — within a few milliseconds for the same path. Dispatching an // `ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT` per raw event fan-outs into N full -// `setContent` + `normalizeSoftBreaks` doc rebuilds per mounted EditorPanel, +// `setContent` + document-repair rebuilds per mounted EditorPanel, // which under split-pane + large markdown is enough to wedge the renderer // and black out the window (issue #826). Coalescing per (worktreeId + path) // on a short debounce collapses that burst into one reload notification. diff --git a/tests/e2e/markdown-prose-reflow.spec.ts b/tests/e2e/markdown-prose-reflow.spec.ts new file mode 100644 index 00000000000..6767d3ea2ae --- /dev/null +++ b/tests/e2e/markdown-prose-reflow.spec.ts @@ -0,0 +1,246 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const HARD_WRAPPED_MARKDOWN = + 'The store owns launch-lifetime presentation state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation through pure\n' + + 'helpers. This keeps the bridge removable later if provider-native launch\n' + + 'delivery becomes universal.\n\n' + + '## Next section\n' + +type ReflowMetrics = { + hardBreakCount: number + lineCount: number + paragraphCount: number + sourceLineCount: number + textContent: string + whiteSpace: string +} + +type PageEditorDocNode = { + textContent?: string + type?: { name?: string } +} + +type PageRichMarkdownEditorElement = Element & { + editor?: { + commands?: { + focus?: () => boolean + setTextSelection?: (position: number) => boolean + } + state?: { + doc?: { + descendants?: (callback: (node: PageEditorDocNode, pos: number) => boolean) => void + } + } + } +} + +async function openHardWrappedFixture( + page: Parameters[0], + testInfo: { workerIndex: number } +): Promise { + const context = await getActiveWorktreeContext(page) + const filePath = await createMarkdownFixture( + context, + 'prose-reflow', + testInfo.workerIndex, + HARD_WRAPPED_MARKDOWN + ) + await openMarkdownFixture(page, context, filePath) + await waitForRichMarkdownEditor(page) + return filePath +} + +async function getGoalParagraphMetrics( + page: Parameters[0] +): Promise { + return page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + if (!editor) { + throw new Error('Rich markdown editor was not mounted') + } + + const paragraphs = Array.from(editor.querySelectorAll('p')).filter((paragraph) => + paragraph.textContent?.includes('launch-lifetime') + ) + const paragraph = paragraphs[0] + if (!paragraph) { + throw new Error('Hard-wrapped paragraph was not rendered') + } + + const range = document.createRange() + range.selectNodeContents(paragraph) + const lineTops: number[] = [] + for (const rect of Array.from(range.getClientRects())) { + if (rect.width <= 0 || rect.height <= 0) { + continue + } + if (!lineTops.some((top) => Math.abs(top - rect.top) < 2)) { + lineTops.push(rect.top) + } + } + + return { + hardBreakCount: paragraph.querySelectorAll('br').length, + lineCount: lineTops.length, + paragraphCount: paragraphs.length, + sourceLineCount: paragraph.textContent?.split('\n').length ?? 0, + textContent: paragraph.textContent ?? '', + whiteSpace: window.getComputedStyle(paragraph).whiteSpace + } + }) +} + +async function placeCaretAtGoalParagraphEnd( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const paragraph = Array.from(editorElement?.querySelectorAll('p') ?? []).find((candidate) => + candidate.textContent?.includes('launch-lifetime') + ) as + | (HTMLParagraphElement & { + pmViewDesc?: { + posAtEnd?: number + } + }) + | undefined + const selectionPosition = paragraph?.pmViewDesc?.posAtEnd + if (!editorElement?.editor?.commands || typeof selectionPosition !== 'number') { + throw new Error('Cannot place caret at the hard-wrapped paragraph end') + } + + editorElement.editor.commands.setTextSelection?.(selectionPosition) + editorElement.editor.commands.focus?.() + }) +} + +async function placeCaretAtHeadingStart( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const editor = editorElement?.editor + let selectionPosition: number | null = null + editor?.state?.doc?.descendants?.((node, pos) => { + if (node.type?.name === 'heading' && node.textContent?.includes('Next section')) { + selectionPosition = pos + 1 + return false + } + return true + }) + + if (!editor?.commands || selectionPosition === null) { + throw new Error('Cannot place caret at the heading start') + } + + editor.commands.setTextSelection?.(selectionPosition) + editor.commands.focus?.() + }) +} + +test.describe('Markdown prose reflow', () => { + test.beforeEach(async ({ orcaPage }) => { + await orcaPage.setViewportSize({ width: 1440, height: 900 }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('hard-wrapped prose reflows as one document paragraph', async ({ orcaPage }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting an inserted empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting slash text then the empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type('/') + await orcaPage.keyboard.press('Backspace') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting the block boundary before a heading keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtHeadingStart(orcaPage) + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) From f11a2109c821ea62bf1de3f0845b54e4ec4ec0fd Mon Sep 17 00:00:00 2001 From: guihirsch <121435005+guihirsch@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:33:25 -0300 Subject: [PATCH 219/233] feat(rate-limits): add MiniMax token control (#7387) --- resources/minimax-icon.svg | 9 + .../runtime-home-service.test.ts | 2 + src/main/codex-accounts/service.test.ts | 2 + src/main/index.ts | 9 + src/main/ipc/minimax-credentials.test.ts | 122 +++++ src/main/ipc/minimax-credentials.ts | 33 ++ src/main/ipc/rate-limits.test.ts | 57 +++ src/main/ipc/rate-limits.ts | 1 + src/main/ipc/register-core-handlers.test.ts | 8 + src/main/ipc/register-core-handlers.ts | 2 + src/main/minimax/minimax-cookie-store.test.ts | 148 ++++++ src/main/minimax/minimax-cookie-store.ts | 66 +++ src/main/rate-limits/minimax-fetcher.test.ts | 423 ++++++++++++++++++ src/main/rate-limits/minimax-fetcher.ts | 276 ++++++++++++ .../minimax-request-context.test.ts | 353 +++++++++++++++ .../rate-limits/minimax-request-context.ts | 178 ++++++++ src/main/rate-limits/service.test.ts | 89 +++- src/main/rate-limits/service.ts | 74 ++- src/preload/api-types.ts | 6 + src/preload/index.ts | 10 + .../src/components/settings/AccountsPane.tsx | 397 +++++++++++++++- .../settings/accounts-search.test.ts | 44 ++ .../components/settings/accounts-search.ts | 23 +- .../src/components/status-bar/StatusBar.tsx | 60 ++- .../src/components/status-bar/icons.test.tsx | 24 + .../src/components/status-bar/icons.tsx | 19 + .../status-bar-provider-visibility.test.ts | 67 ++- .../status-bar-provider-visibility.ts | 21 +- .../src/components/status-bar/tooltip.test.ts | 55 +++ .../src/components/status-bar/tooltip.tsx | 5 +- .../status-bar/usage-error-copy.test.ts | 27 ++ .../components/status-bar/usage-error-copy.ts | 3 + src/renderer/src/i18n/locales/en.json | 49 +- src/renderer/src/i18n/locales/es.json | 49 +- src/renderer/src/i18n/locales/ja.json | 49 +- src/renderer/src/i18n/locales/ko.json | 49 +- src/renderer/src/i18n/locales/zh.json | 49 +- .../src/lib/window-label-formatter.test.ts | 17 + src/renderer/src/store/slices/rate-limits.ts | 2 + src/renderer/src/web/web-preload-api.ts | 3 + src/shared/constants.test.ts | 12 + src/shared/constants.ts | 2 + src/shared/rate-limit-types.test.ts | 27 ++ src/shared/rate-limit-types.ts | 10 +- src/shared/status-bar-defaults.ts | 1 + src/shared/types.ts | 5 + 46 files changed, 2889 insertions(+), 48 deletions(-) create mode 100644 resources/minimax-icon.svg create mode 100644 src/main/ipc/minimax-credentials.test.ts create mode 100644 src/main/ipc/minimax-credentials.ts create mode 100644 src/main/ipc/rate-limits.test.ts create mode 100644 src/main/minimax/minimax-cookie-store.test.ts create mode 100644 src/main/minimax/minimax-cookie-store.ts create mode 100644 src/main/rate-limits/minimax-fetcher.test.ts create mode 100644 src/main/rate-limits/minimax-fetcher.ts create mode 100644 src/main/rate-limits/minimax-request-context.test.ts create mode 100644 src/main/rate-limits/minimax-request-context.ts create mode 100644 src/renderer/src/components/settings/accounts-search.test.ts create mode 100644 src/renderer/src/components/status-bar/icons.test.tsx create mode 100644 src/renderer/src/components/status-bar/usage-error-copy.test.ts create mode 100644 src/shared/rate-limit-types.test.ts diff --git a/resources/minimax-icon.svg b/resources/minimax-icon.svg new file mode 100644 index 00000000000..9698ab27567 --- /dev/null +++ b/resources/minimax-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 51908be3253..0f6d968cc52 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -127,6 +127,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 13b39b6b0d7..69fed98ea8d 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -131,6 +131,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, diff --git a/src/main/index.ts b/src/main/index.ts index ff4b249f8dc..902a01a8d06 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -99,6 +99,7 @@ import { import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' import { RateLimitService } from './rate-limits/service' +import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { @@ -1685,6 +1686,14 @@ app.whenReady().then(async () => { workspaceIdOverride: settings.opencodeWorkspaceId } }) + rateLimits.setMiniMaxConfigResolver(() => { + const settings = store!.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts new file mode 100644 index 00000000000..b34d85ad176 --- /dev/null +++ b/src/main/ipc/minimax-credentials.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + ipcState.handleHandlers.set(channel, handler) + } + } +})) + +const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) +const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) +const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock, + clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock, + hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock +})) + +import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' +import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitState } from '../../shared/rate-limit-types' + +function makeRefreshMock(): { + refresh: ReturnType + service: Pick +} { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + return { refresh, service: { refresh } } +} + +async function invoke(channel: string, ...args: unknown[]): Promise { + const handler = ipcState.handleHandlers.get(channel) + if (!handler) { + throw new Error(`No handler registered for ${channel}`) + } + return (await handler({}, ...args)) as T +} + +describe('registerMiniMaxCredentialsHandlers', () => { + beforeEach(() => { + ipcState.handleHandlers.clear() + saveMiniMaxSessionCookieMock.mockReset() + clearMiniMaxSessionCookieMock.mockReset() + hasMiniMaxSessionCookieMock.mockReset() + hasMiniMaxSessionCookieMock.mockReturnValue(false) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers the three MiniMax credential channels', () => { + registerMiniMaxCredentialsHandlers(null) + expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true) + expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true) + expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true) + }) + + it('returns the configured state on getStatus from the cookie store', async () => { + hasMiniMaxSessionCookieMock.mockReturnValue(true) + registerMiniMaxCredentialsHandlers(null) + const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus') + expect(status).toEqual({ configured: true }) + }) + + it('persists the cookie and reports configured after saveCookie', async () => { + hasMiniMaxSessionCookieMock.mockReturnValueOnce(true) + registerMiniMaxCredentialsHandlers(null) + const status = await invoke<{ configured: boolean }>( + 'minimaxCredentials:saveCookie', + '_token=abc; minimax_group_id_v2=42' + ) + expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') + expect(status).toEqual({ configured: true }) + }) + + it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => { + const { refresh, service } = makeRefreshMock() + registerMiniMaxCredentialsHandlers(service as RateLimitService) + await invoke('minimaxCredentials:saveCookie', '_token=abc') + // Why: the save handler is fire-and-forget — wait a microtask cycle so + // the queued `void rateLimits?.refresh()` resolves before we assert. + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('does not throw when saveCookie runs without a rate-limit service', async () => { + registerMiniMaxCredentialsHandlers(null) + await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined() + }) + + it('clears the cookie and triggers a refresh on clearCookie', async () => { + const { refresh, service } = makeRefreshMock() + hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) + registerMiniMaxCredentialsHandlers(service as RateLimitService) + const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') + expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(status).toEqual({ configured: false }) + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('logs but does not throw when the post-save rate-limit refresh rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const refresh = vi.fn(() => Promise.reject(new Error('refresh boom'))) + registerMiniMaxCredentialsHandlers({ + refresh + } as Pick as RateLimitService) + await invoke('minimaxCredentials:saveCookie', '_token=abc') + await new Promise((resolve) => setImmediate(resolve)) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to trigger rate-limit refresh after save'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts new file mode 100644 index 00000000000..9f773762151 --- /dev/null +++ b/src/main/ipc/minimax-credentials.ts @@ -0,0 +1,33 @@ +import { ipcMain } from 'electron' +import { + clearMiniMaxSessionCookie, + hasMiniMaxSessionCookie, + saveMiniMaxSessionCookie +} from '../minimax/minimax-cookie-store' +import type { RateLimitService } from '../rate-limits/service' + +export type MiniMaxCredentialsStatus = { + configured: boolean +} + +function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { + return { configured: hasMiniMaxSessionCookie() } +} + +export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { + ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus()) + ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => { + saveMiniMaxSessionCookie(cookie) + void rateLimits?.refresh().catch((error: unknown) => { + console.error('[minimax] failed to trigger rate-limit refresh after save:', error) + }) + return getMiniMaxCredentialsStatus() + }) + ipcMain.handle('minimaxCredentials:clearCookie', () => { + clearMiniMaxSessionCookie() + void rateLimits?.refresh().catch((error: unknown) => { + console.error('[minimax] failed to trigger rate-limit refresh after clear:', error) + }) + return getMiniMaxCredentialsStatus() + }) +} diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts new file mode 100644 index 00000000000..3a299c5bda6 --- /dev/null +++ b/src/main/ipc/rate-limits.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcState = vi.hoisted(() => ({ + handleHandlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + ipcState.handleHandlers.set(channel, handler) + } + } +})) + +import { registerRateLimitHandlers } from './rate-limits' +import type { RateLimitService } from '../rate-limits/service' +import type { RateLimitState } from '../../shared/rate-limit-types' + +function makeService(): { service: RateLimitService; refresh: ReturnType } { + const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + const service = { + getState: vi.fn(() => ({}) as RateLimitState), + refresh, + refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), + refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), + consumeCodexRateLimitResetCredit: vi.fn(() => + Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) + ), + setPollingInterval: vi.fn(() => Promise.resolve()), + fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()), + fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve()) + } + return { service: service as unknown as RateLimitService, refresh } +} + +describe('registerRateLimitHandlers', () => { + beforeEach(() => { + ipcState.handleHandlers.clear() + }) + + it('registers a refreshMiniMax channel that delegates to refresh()', async () => { + const { service, refresh } = makeService() + registerRateLimitHandlers(service) + const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax') + expect(handler).toBeDefined() + await handler!({}) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('keeps the existing rate-limit channels registered', () => { + const { service } = makeService() + registerRateLimitHandlers(service) + expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true) + }) +}) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 3f657c0c160..a28b7810263 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () => rateLimits.fetchInactiveCodexAccountsOnOpen() ) + ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 76e8b16933b..5ce6342c351 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -34,6 +34,7 @@ const { registerAgentHookHandlersMock, registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, + registerMiniMaxCredentialsHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -89,6 +90,7 @@ const { registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), + registerMiniMaxCredentialsHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) +vi.mock('./minimax-credentials', () => ({ + registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock +})) + vi.mock('../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => { registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() + registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() registerUpdaterHandlersMock.mockReset() @@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => { expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) + expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 4baae415b87..50dbd0a8ff4 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' +import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' import { registerUpdaterHandlers } from '../window/attach-main-window-services' import { registerClipboardHandlers, @@ -117,6 +118,7 @@ export function registerCoreHandlers( registerAgentHookHandlers(runtime) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) + registerMiniMaxCredentialsHandlers(rateLimits) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts new file mode 100644 index 00000000000..436add5a854 --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as MiniMaxCookieStore from './minimax-cookie-store' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(value)), + decryptString: vi.fn((value: Buffer) => value.toString('utf8')) +})) + +const electronMock = vi.hoisted(() => ({ + safeStorage: safeStorageMock +})) + +vi.mock('electron', () => electronMock) + +const existsSyncMock = vi.fn() +const mkdirSyncMock = vi.fn() +const readFileSyncMock = vi.fn() +const rmSyncMock = vi.fn() +const writeFileSyncMock = vi.fn() +const homedirMock = vi.fn(() => '/home/test') + +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdirSync: mkdirSyncMock, + readFileSync: readFileSyncMock, + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock +})) + +vi.mock('node:os', () => ({ + homedir: homedirMock +})) + +vi.mock('node:path', () => ({ + join: (...parts: string[]) => parts.join('/') +})) + +const storePath = '/home/test/.orca/minimax-session-cookie.enc' + +async function loadStore(): Promise { + return await import('./minimax-cookie-store') +} + +describe('minimax-cookie-store', () => { + beforeEach(() => { + existsSyncMock.mockReset() + mkdirSyncMock.mockReset() + readFileSyncMock.mockReset() + rmSyncMock.mockReset() + writeFileSyncMock.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value)) + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8')) + }) + + afterEach(() => { + vi.resetModules() + }) + + it('returns false when no file exists yet', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(false) + }) + + it('writes the cookie using safeStorage when encryption is available', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42') + expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') + expect(writeFileSyncMock).toHaveBeenCalledWith( + storePath, + Buffer.from('_token=abc; minimax_group_id_v2=42'), + { mode: 0o600 } + ) + expect(mkdirSyncMock).toHaveBeenCalledWith('/home/test/.orca', { recursive: true }) + }) + + it('warns and writes plaintext when safeStorage is unavailable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + store.saveMiniMaxSessionCookie('_token=abc') + expect(writeFileSyncMock).toHaveBeenCalledWith(storePath, '_token=abc', { + encoding: 'utf8', + mode: 0o600 + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) + warn.mockRestore() + }) + + it('refuses empty cookies', async () => { + const store = await loadStore() + expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/) + }) + + it('reads decrypted cookie from disk and caches it', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9') + const store = await loadStore() + const first = store.readMiniMaxSessionCookie() + const second = store.readMiniMaxSessionCookie() + expect(first).toBe('_token=cached; minimax_group_id_v2=9') + expect(second).toBe(first) + expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1) + }) + + it('returns null when no file exists', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBeNull() + }) + + it('returns plaintext when safeStorage is unavailable and reads succeed', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=plaintext')) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext') + }) + + it('throws when decryption fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockImplementation(() => { + throw new Error('boom') + }) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('clears the cached cookie and removes the file', async () => { + existsSyncMock.mockReturnValueOnce(true) + readFileSyncMock.mockReturnValueOnce(Buffer.from('encrypted-payload')) + safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear') + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear') + store.clearMiniMaxSessionCookie() + expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) + expect(store.readMiniMaxSessionCookie()).toBeNull() + }) +}) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts new file mode 100644 index 00000000000..31102e4bbbe --- /dev/null +++ b/src/main/minimax/minimax-cookie-store.ts @@ -0,0 +1,66 @@ +import { safeStorage } from 'electron' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' +let cachedMiniMaxCookie: string | null = null + +function getOrcaDir(): string { + return join(homedir(), '.orca') +} + +function getMiniMaxCookiePath(): string { + return join(getOrcaDir(), MINIMAX_COOKIE_FILE) +} + +function ensureOrcaDir(): void { + const dir = getOrcaDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +export function hasMiniMaxSessionCookie(): boolean { + return existsSync(getMiniMaxCookiePath()) +} + +export function saveMiniMaxSessionCookie(cookie: string): void { + const trimmed = cookie.trim() + if (!trimmed) { + throw new Error('MiniMax session cookie is required') + } + ensureOrcaDir() + if (safeStorage.isEncryptionAvailable()) { + writeFileSync(getMiniMaxCookiePath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + cachedMiniMaxCookie = trimmed + return + } + console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext') + writeFileSync(getMiniMaxCookiePath(), trimmed, { encoding: 'utf8', mode: 0o600 }) + cachedMiniMaxCookie = trimmed +} + +export function readMiniMaxSessionCookie(): string | null { + if (cachedMiniMaxCookie !== null) { + return cachedMiniMaxCookie + } + const keyPath = getMiniMaxCookiePath() + if (!existsSync(keyPath)) { + return null + } + try { + const raw = readFileSync(keyPath) + cachedMiniMaxCookie = safeStorage.isEncryptionAvailable() + ? safeStorage.decryptString(raw) + : raw.toString('utf8') + return cachedMiniMaxCookie + } catch { + throw new Error('MiniMax session cookie could not be decrypted') + } +} + +export function clearMiniMaxSessionCookie(): void { + cachedMiniMaxCookie = null + rmSync(getMiniMaxCookiePath(), { force: true }) +} diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts new file mode 100644 index 00000000000..89c095de959 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted( + () => { + const netFetchMock = vi.fn() + const cookiesSetMock = vi.fn(() => Promise.resolve()) + const clearStorageDataMock = vi.fn(() => Promise.resolve()) + const sessionFromPartitionMock = vi.fn(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } + } +) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { + extractMiniMaxCookieValue, + fetchMiniMaxRateLimits, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-fetcher' + +const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' + +function makeResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response +} + +function makeOkPayload(remainingPercent: number): unknown { + const now = Date.now() + return { + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: remainingPercent, + start_time: now - 60_000, + end_time: now + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + } +} + +const FULL_COOKIE = + '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3' + +function getCookieJarSetNames(): string[] { + return cookiesSetMock.mock.calls.map((call) => { + const [details] = call as unknown as [{ name: string }] + return details.name + }) +} + +describe('fetchMiniMaxRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z')) + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() + netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('returns unavailable when cookie is empty', async () => { + const result = await fetchMiniMaxRateLimits({ cookie: '' }) + expect(result.status).toBe('unavailable') + expect(result.provider).toBe('minimax') + expect(result.session).toBeNull() + expect(result.weekly).toBeNull() + expect(result.error).toMatch(/not configured/i) + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('returns unavailable when cookie is only whitespace', async () => { + const result = await fetchMiniMaxRateLimits({ cookie: ' ' }) + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('returns error when cookie has no _token', async () => { + const result = await fetchMiniMaxRateLimits({ + cookie: '_twpid=tw.123; minimax_group_id_v2=12345' + }) + expect(result.status).toBe('error') + expect(result.error).toMatch(/MiniMax auth cookie not found/) + expect(netFetchMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('classifies 401 as stale-token', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 401)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + expect(result.error).toMatch(/session expired/i) + }) + + it('classifies 403 as stale-token', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 403)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + }) + + it('classifies 500 as server', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse({}, 500)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('server') + expect(result.error).toMatch(/500/) + }) + + it('returns ok with session window mapping remaining to usedPercent', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35))) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.provider).toBe('minimax') + expect(result.session?.usedPercent).toBe(65) + expect(result.session?.windowMinutes).toBe(300) + expect(result.weekly).toBeNull() + }) + + it('reports a fixed 5-hour session window regardless of API interval drift', async () => { + const startTime = 1_700_000_000_000 + const endTime = startTime + 295 * 60_000 + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 60, + start_time: startTime, + end_time: endTime, + remains_time: endTime - startTime + } + ] + }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.windowMinutes).toBe(300) + expect(result.session?.resetsAt).toBe(endTime) + }) + + it('does not derive the windowMinutes label from the raw start/end interval', async () => { + const startTime = 1_700_000_000_000 + const endTime = startTime + 4 * 60 * 60_000 + netFetchMock.mockResolvedValueOnce( + makeResponse({ + base_resp: { status_code: 0, status_msg: 'ok' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 63, + start_time: startTime, + end_time: endTime, + remains_time: endTime - startTime + } + ] + }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.windowMinutes).toBe(300) + expect(result.session?.usedPercent).toBe(37) + expect(result.session?.resetsAt).toBe(endTime) + }) + + it('sets the cookie jar and sends browser-like request headers', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledWith({ + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(getCookieJarSetNames()).toEqual([ + '_token', + '_twpid', + 'minimax_group_id_v2', + 'platform_cookie_consent' + ]) + expect(netFetchMock).toHaveBeenCalledTimes(1) + const [url, init] = netFetchMock.mock.calls[0] + expect(url).toBe(MINIMAX_URL) + expect(init.method).toBe('GET') + expect(init.headers.Cookie).toBeUndefined() + expect(init.headers['X-Group-Id']).toBe('12345') + expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage') + expect(init.headers.Accept).toMatch(/application\/json/) + expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9') + expect(init.headers['Sec-Fetch-Dest']).toBeUndefined() + expect(init.headers['Sec-Fetch-Mode']).toBeUndefined() + expect(init.headers['Sec-Fetch-Site']).toBeUndefined() + expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/) + expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage') + }) + + it('accepts quoted MiniMax cookie storage syntax', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ + cookie: '_token:"jwt-token" minimax_group_id_v2:"42"' + }) + const [, init] = netFetchMock.mock.calls[0] + expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2']) + expect(init.headers.Cookie).toBeUndefined() + expect(init.headers['X-Group-Id']).toBe('42') + }) + + it('preserves the complete browser Cookie header', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + const fullBrowserCookie = [ + 'platform_cookie_consent=3', + '_ga=analytics', + '_token=tok', + '_twpid=tw.1', + 'ak_bmsc=ak', + 'bm_sv=sv', + 'bm_sz=sz', + '_abck=ab', + 'minimax_group_id_v2=42', + 'sensorsdata2015jssdkcross=analytics' + ].join('; ') + await fetchMiniMaxRateLimits({ + cookie: fullBrowserCookie + }) + const [, init] = netFetchMock.mock.calls[0] + expect(getCookieJarSetNames()).toEqual([ + 'platform_cookie_consent', + '_ga', + '_token', + '_twpid', + 'ak_bmsc', + 'bm_sv', + 'bm_sz', + '_abck', + 'minimax_group_id_v2', + 'sensorsdata2015jssdkcross' + ]) + expect(init.headers.Cookie).toBeUndefined() + }) + + it('prefers explicit groupId over cookie value', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' }) + expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id') + }) + + it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80))) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' }) + expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345') + }) + + it('surfaces base_resp status_code != 0 as usage-unavailable', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('unauth') + }) + + it('returns error when model_remains is empty', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) + ) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toMatch(/configured model was not found/i) + }) + + it('returns error when configured model is not in response', async () => { + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) + ) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'unrelated-model' + }) + expect(result.status).toBe('error') + expect(result.error).toMatch(/configured model was not found/i) + }) + + it('falls back to the lone snapshot when no configured model matches', async () => { + netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40))) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'unrelated-model' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(60) + }) + + it('selects the first configured model when multiple are listed', async () => { + const payload = makeOkPayload(40) + ;(payload as { model_remains: unknown[] }).model_remains = [ + { + model_name: 'unrelated', + current_interval_remaining_percent: 10, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + }, + { + model_name: 'general', + current_interval_remaining_percent: 40, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: 'general' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(60) + }) + + it('redacts _token in any error path that includes payload text', async () => { + const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } } + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.error).not.toContain('eyJh') + expect(result.error).not.toContain('minimax_group_id_v2=12345') + expect(result.error).toContain('[REDACTED]') + }) + + it('logs MiniMax failures with cookie names but without cookie values', async () => { + const warn = vi.mocked(console.warn) + netFetchMock.mockResolvedValueOnce( + makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } }) + ) + await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + baseRespStatusMsg: expect.not.stringContaining('eyJh'), + cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent'] + }) + ) + }) +}) + +describe('normalizeMiniMaxCookieHeader', () => { + it('preserves all cookie pairs from a browser Cookie header', () => { + const normalized = normalizeMiniMaxCookieHeader( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + expect(normalized).toBe( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + }) + + it('normalizes quoted MiniMax cookie storage syntax', () => { + expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( + '_token=tok; minimax_group_id_v2=42' + ) + }) + + it('accepts a copied Cookie header line', () => { + expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( + 'session=abc; other=xyz' + ) + }) +}) + +describe('extractMiniMaxCookieValue', () => { + it('returns the value for a given cookie name', () => { + expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345') + }) + it('returns null when the name is absent', () => { + expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() + }) +}) + +describe('redactMiniMaxSecret', () => { + it('redacts _token values', () => { + expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]') + expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF') + }) + it('redacts minimax_group_id_v2 values', () => { + expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') + }) + it('redacts MiniMax anti-bot cookie values', () => { + const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"') + expect(redacted).not.toContain('secret') + expect(redacted).toContain('ak_bmsc=[REDACTED]') + expect(redacted).toContain('bm_sv:[REDACTED]') + }) + it('redacts Cookie: header lines', () => { + expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]') + }) +}) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts new file mode 100644 index 00000000000..a04ba89db55 --- /dev/null +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -0,0 +1,276 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + extractMiniMaxCookieValue, + fetchMiniMaxWithManualCookieHeader, + fetchMiniMaxWithSessionCookieJar, + getUniqueMiniMaxCookieNames, + logMiniMaxFetchFailure, + makeMiniMaxRequestHeaders, + MINIMAX_USAGE_ENDPOINT, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret, + type MiniMaxFetchResponse +} from './minimax-request-context' + +export { + extractMiniMaxCookieValue, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-request-context' + +const API_TIMEOUT_MS = 15_000 + +type MiniMaxUsageItem = { + model_name?: unknown + current_interval_remaining_percent?: unknown + start_time?: unknown + end_time?: unknown + remains_time?: unknown +} + +type MiniMaxUsageResponse = { + base_resp?: { + status_code?: unknown + status_msg?: unknown + } + model_remains?: MiniMaxUsageItem[] +} + +type MiniMaxUsageSnapshot = { + modelName: string + window: RateLimitWindow +} + +export type FetchMiniMaxRateLimitsOptions = { + cookie: string + groupId?: string | null + models?: string | readonly string[] | null + endpoint?: string +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))) +} + +function makeUnavailable(error: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'unavailable', + usageMetadata: { failureKind: 'missing-credentials', source: 'web' } + } +} + +function makeError( + error: string, + failureKind: NonNullable['failureKind'] +): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { failureKind, source: 'web' } + } +} + +function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] { + if (Array.isArray(models)) { + return models.map((model) => model.trim()).filter(Boolean) + } + if (typeof models === 'string') { + return models + .split(',') + .map((model) => model.trim()) + .filter(Boolean) + } + return ['general'] +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +// Why: MiniMax's API returns `end_time - start_time` that can drift below the +// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted +// session — a fixed 5-hour window — so the status bar reads "5h" regardless of +// what the API reports. Mirrors how Codex always reports 300/10080 minutes. +const MINIMAX_SESSION_WINDOW_MINUTES = 300 + +function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null { + const modelName = typeof item.model_name === 'string' ? item.model_name : null + const remainingPercent = asNumber(item.current_interval_remaining_percent) + const startTime = asNumber(item.start_time) + const endTime = asNumber(item.end_time) + if (!modelName || remainingPercent === null || startTime === null || endTime === null) { + return null + } + return { + modelName, + window: { + usedPercent: clampPercent(100 - remainingPercent), + windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES, + resetsAt: endTime, + resetDescription: null + } + } +} + +function selectSnapshot( + snapshots: MiniMaxUsageSnapshot[], + preferredModels: string[] +): MiniMaxUsageSnapshot | null { + for (const model of preferredModels) { + const match = snapshots.find((snapshot) => snapshot.modelName === model) + if (match) { + return match + } + } + return snapshots.length === 1 ? snapshots[0] : null +} + +async function fetchMiniMaxResponse(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + try { + return await fetchMiniMaxWithSessionCookieJar(args) + } catch (sessionFetchError) { + const message = + sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError) + console.warn( + '[minimax] session cookie jar fetch failed; falling back to manual Cookie header', + { + error: redactMiniMaxSecret(message), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId)) + } + ) + return await fetchMiniMaxWithManualCookieHeader(args) + } +} + +function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null { + const { response } = fetchResult + if (response.status === 401 || response.status === 403) { + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: response.status, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + return makeError( + 'MiniMax session expired. Replace the MiniMax cookie in Settings.', + 'stale-token' + ) + } + if (!response.ok) { + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: response.status, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + return makeError(`MiniMax usage fetch failed (${response.status})`, 'server') + } + return null +} + +function handleMiniMaxPayloadError( + fetchResult: MiniMaxFetchResponse, + payload: MiniMaxUsageResponse +): ProviderRateLimits | null { + const statusCode = payload.base_resp?.status_code + if (statusCode === undefined || statusCode === 0) { + return null + } + logMiniMaxFetchFailure({ + transport: fetchResult.transport, + responseStatus: fetchResult.response.status, + statusCode, + statusMsg: payload.base_resp?.status_msg, + cookieNames: fetchResult.cookieNames, + requestHeaderNames: fetchResult.requestHeaderNames + }) + const message = + typeof payload.base_resp?.status_msg === 'string' + ? payload.base_resp.status_msg + : 'MiniMax returned an error' + return makeError(redactMiniMaxSecret(message), 'usage-unavailable') +} + +export async function fetchMiniMaxRateLimits( + options: FetchMiniMaxRateLimitsOptions +): Promise { + const rawCookie = options.cookie.trim() + if (!rawCookie) { + return makeUnavailable('MiniMax session cookie not configured') + } + const cookie = normalizeMiniMaxCookieHeader(rawCookie) + if (!extractMiniMaxCookieValue(cookie, '_token')) { + return makeError( + 'MiniMax auth cookie not found — paste a Cookie header with _token', + 'missing-credentials' + ) + } + const groupId = + options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2') + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS) + try { + const fetchResult = await fetchMiniMaxResponse({ + cookie, + endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT, + groupId, + signal: controller.signal + }) + const httpError = handleMiniMaxHttpError(fetchResult) + if (httpError) { + return httpError + } + const payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + const payloadError = handleMiniMaxPayloadError(fetchResult, payload) + if (payloadError) { + return payloadError + } + const snapshots = (payload.model_remains ?? []) + .map(parseUsageItem) + .filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null) + const selected = selectSnapshot(snapshots, parseModels(options.models)) + if (!selected) { + return makeError( + 'MiniMax usage data for the configured model was not found', + 'usage-unavailable' + ) + } + return { + provider: 'minimax', + session: selected.window, + weekly: null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'web' } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error' + const failureKind = message.toLowerCase().includes('json') ? 'parse' : 'network' + return makeError(redactMiniMaxSecret(message), failureKind) + } finally { + clearTimeout(timeout) + } +} diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts new file mode 100644 index 00000000000..15caa55214a --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted( + () => { + const netFetchMock = vi.fn() + const cookiesSetMock = vi.fn(() => Promise.resolve()) + const clearStorageDataMock = vi.fn(() => Promise.resolve()) + const sessionFromPartitionMock = vi.fn(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } + } +) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { + extractMiniMaxCookieValue, + fetchMiniMaxWithManualCookieHeader, + fetchMiniMaxWithSessionCookieJar, + getUniqueMiniMaxCookieNames, + logMiniMaxFetchFailure, + makeMiniMaxRequestHeaders, + MINIMAX_USAGE_ENDPOINT, + normalizeMiniMaxCookieHeader, + redactMiniMaxSecret +} from './minimax-request-context' + +const FULL_COOKIE = + '_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3' + +describe('normalizeMiniMaxCookieHeader', () => { + it('preserves all cookie pairs from a browser Cookie header', () => { + const normalized = normalizeMiniMaxCookieHeader( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + expect(normalized).toBe( + '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' + ) + }) + + it('normalizes quoted MiniMax cookie storage syntax', () => { + expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( + '_token=tok; minimax_group_id_v2=42' + ) + }) + + it('accepts a copied Cookie header line with prefix', () => { + expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( + 'session=abc; other=xyz' + ) + }) + + it('merges header and quoted forms without dropping any pair', () => { + const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42' + const normalized = normalizeMiniMaxCookieHeader(mixed) + expect(normalized).toContain('_token=header-quoted') + expect(normalized).toContain('_twpid=q-1') + expect(normalized).toContain('minimax_group_id_v2=42') + }) +}) + +describe('extractMiniMaxCookieValue', () => { + it('returns the value for a known name', () => { + expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload') + }) + + it('returns null when the name is absent', () => { + expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() + }) + + it('handles Chromium quoted syntax', () => { + expect( + extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2') + ).toBe('42') + }) +}) + +describe('getUniqueMiniMaxCookieNames', () => { + it('deduplicates repeated names', () => { + const names = getUniqueMiniMaxCookieNames( + '_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99' + ) + expect(names).toEqual(['_token', 'minimax_group_id_v2']) + }) + + it('returns names from a quoted-only Cookie storage export', () => { + const names = getUniqueMiniMaxCookieNames( + '_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"' + ) + expect(names).toEqual( + expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent']) + ) + }) +}) + +describe('redactMiniMaxSecret', () => { + it('redacts _token values in header and quoted syntax', () => { + const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"') + expect(redacted).toContain('_token=[REDACTED]') + expect(redacted).not.toContain('eyJhABCDEF') + expect(redacted).not.toContain('x.y') + }) + + it('redacts minimax_group_id_v2', () => { + expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') + }) + + it('redacts MiniMax anti-bot cookie values', () => { + const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"') + expect(redacted).not.toContain('zzzsecret') + expect(redacted).not.toContain('yyyvalue') + expect(redacted).not.toContain('xxxdata') + expect(redacted).toContain('ak_bmsc=[REDACTED]') + expect(redacted).toContain('bm_sv:[REDACTED]') + expect(redacted).toContain('_abck=[REDACTED]') + }) + + it('redacts full Cookie: header lines', () => { + const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter') + expect(redacted).toContain('Cookie: [REDACTED]') + expect(redacted).not.toContain('Cookie: _token=secret') + }) +}) + +describe('makeMiniMaxRequestHeaders', () => { + it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => { + const headers = makeMiniMaxRequestHeaders(null) + expect(headers.Accept).toMatch(/application\/json/) + expect(headers['Accept-Language']).toBe('en-US,en;q=0.9') + expect(headers.Referer).toBe('https://platform.minimax.io/console/usage') + expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/) + expect(headers['User-Agent']).not.toContain('orca-minimax-usage') + }) + + it('omits X-Group-Id when groupId is null', () => { + const headers = makeMiniMaxRequestHeaders(null) + expect(headers['X-Group-Id']).toBeUndefined() + }) + + it('omits X-Group-Id when groupId is empty string', () => { + const headers = makeMiniMaxRequestHeaders('') + expect(headers['X-Group-Id']).toBeUndefined() + }) + + it('includes X-Group-Id when groupId is provided', () => { + const headers = makeMiniMaxRequestHeaders('2034972027806299092') + expect(headers['X-Group-Id']).toBe('2034972027806299092') + }) +}) + +describe('fetchMiniMaxWithSessionCookieJar', () => { + beforeEach(() => { + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() + netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses a dedicated MiniMax partition and clears cookies before fetching', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledWith({ + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + }) + + it('sets every cookie pair onto the session jar with secure + path /', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithSessionCookieJar({ + cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42', + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + expect(cookiesSetMock).toHaveBeenCalledTimes(3) + const setDetails = cookiesSetMock.mock.calls.map((call) => { + const [details] = call as unknown as [ + { name: string; value: string; secure: boolean; path: string } + ] + return details + }) + expect(setDetails).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }), + expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }), + expect.objectContaining({ + name: 'minimax_group_id_v2', + value: '42', + secure: true, + path: '/' + }) + ]) + ) + }) + + it('reports the transport name as session-cookie-jar on success', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + const result = await fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(result.transport).toBe('session-cookie-jar') + expect(result.cookieNames).toEqual([ + '_token', + '_twpid', + 'minimax_group_id_v2', + 'platform_cookie_consent' + ]) + expect(result.requestHeaderNames).toContain('X-Group-Id') + }) +}) + +describe('fetchMiniMaxWithManualCookieHeader', () => { + beforeEach(() => { + netFetchMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('attaches the full Cookie header and X-Group-Id via net.fetch', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + const result = await fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + expect(result.transport).toBe('manual-cookie-header') + expect(netFetchMock).toHaveBeenCalledTimes(1) + const [url, init] = netFetchMock.mock.calls[0] + expect(url).toBe(MINIMAX_USAGE_ENDPOINT) + expect(init.method).toBe('GET') + expect(init.headers.Cookie).toBe(FULL_COOKIE) + expect(init.headers['X-Group-Id']).toBe('12345') + }) + + it('omits X-Group-Id when groupId is null', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + const [, init] = netFetchMock.mock.calls[0] + expect(init.headers['X-Group-Id']).toBeUndefined() + }) +}) + +describe('logMiniMaxFetchFailure', () => { + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs structured fields without leaking cookie values', () => { + logMiniMaxFetchFailure({ + transport: 'session-cookie-jar', + responseStatus: 200, + statusCode: 7, + statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue', + cookieNames: ['_token', 'ak_bmsc'], + requestHeaderNames: ['Accept', 'X-Group-Id'] + }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + transport: 'session-cookie-jar', + responseStatus: 200, + baseRespStatusCode: 7, + baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'), + cookieNames: ['_token', 'ak_bmsc'], + requestHeaderNames: ['Accept', 'X-Group-Id'] + }) + ) + }) + + it('logs without redacting when statusMsg is not a string', () => { + logMiniMaxFetchFailure({ + transport: 'manual-cookie-header', + responseStatus: 401, + statusCode: undefined, + statusMsg: undefined, + cookieNames: ['_token'], + requestHeaderNames: ['Accept'] + }) + expect(warn).toHaveBeenCalledWith( + '[minimax] usage fetch failed', + expect.objectContaining({ + transport: 'manual-cookie-header', + responseStatus: 401, + baseRespStatusCode: undefined, + baseRespStatusMsg: undefined + }) + ) + }) +}) diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts new file mode 100644 index 00000000000..3953ab53e6f --- /dev/null +++ b/src/main/rate-limits/minimax-request-context.ts @@ -0,0 +1,178 @@ +import { net, session } from 'electron' + +export const MINIMAX_USAGE_ENDPOINT = + 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' + +const MINIMAX_ORIGIN = 'https://platform.minimax.io' +const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage' +const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch' +const SENSITIVE_COOKIE_NAMES = new Set([ + '_token', + '_twpid', + '_abck', + 'ak_bmsc', + 'bm_mi', + 'bm_sv', + 'bm_sz', + 'minimax_group_id_v2' +]) + +export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header' + +export type MiniMaxFetchResponse = { + response: Response + requestHeaderNames: string[] + cookieNames: string[] + transport: MiniMaxFetchTransport +} + +function getMiniMaxBrowserUserAgent(): string { + if (process.platform === 'win32') { + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0' + } + if (process.platform === 'darwin') { + return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0' + } + return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0' +} + +function parseCookiePairs(cookie: string): { name: string; value: string }[] { + const headerPairs = cookie + .split(';') + .map((part) => part.trim()) + .map((part) => { + const normalizedPart = part.replace(/^Cookie:\s*/i, '') + const eq = normalizedPart.indexOf('=') + if (eq < 0) { + return null + } + return { + name: normalizedPart.slice(0, eq).trim(), + value: normalizedPart.slice(eq + 1).trim() + } + }) + .filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value)) + // Why: Chromium cookie storage exports are often copied as `name:"value"`, + // not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps. + const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g + const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)] + .map((match) => { + const [, name = '', value = ''] = match + return { name: name.trim(), value: value.trim() } + }) + .filter((pair) => pair.name && pair.value) + return [...headerPairs, ...quotedPairs] +} + +export function extractMiniMaxCookieValue(cookie: string, name: string): string | null { + return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null +} + +export function normalizeMiniMaxCookieHeader(cookie: string): string { + return parseCookiePairs(cookie) + .map((pair) => `${pair.name}=${pair.value}`) + .join('; ') +} + +export function getUniqueMiniMaxCookieNames(cookie: string): string[] { + return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))] +} + +export function redactMiniMaxSecret(value: string): string { + let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]') + for (const name of SENSITIVE_COOKIE_NAMES) { + redacted = redacted + .replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`) + .replace(new RegExp(`${name}:["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) + } + return redacted +} + +export function makeMiniMaxRequestHeaders(groupId: string | null): Record { + const headers: Record = { + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: MINIMAX_REFERER, + 'User-Agent': getMiniMaxBrowserUserAgent() + } + if (groupId) { + headers['X-Group-Id'] = groupId + } + return headers +} + +export async function fetchMiniMaxWithSessionCookieJar(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) + const cookiePairs = parseCookiePairs(args.cookie) + await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) + await Promise.all( + cookiePairs.map((pair) => + miniMaxSession.cookies.set({ + url: MINIMAX_ORIGIN, + name: pair.name, + value: pair.value, + secure: true, + path: '/' + }) + ) + ) + const headers = makeMiniMaxRequestHeaders(args.groupId) + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'session-cookie-jar' + } +} + +export async function fetchMiniMaxWithManualCookieHeader(args: { + cookie: string + endpoint: string + groupId: string | null + signal: AbortSignal +}): Promise { + const headers = { + ...makeMiniMaxRequestHeaders(args.groupId), + Cookie: args.cookie + } + return { + response: await net.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'manual-cookie-header' + } +} + +export function logMiniMaxFetchFailure(details: { + transport: MiniMaxFetchTransport + responseStatus?: number + statusCode?: unknown + statusMsg?: unknown + cookieNames: string[] + requestHeaderNames: string[] +}): void { + console.warn('[minimax] usage fetch failed', { + transport: details.transport, + responseStatus: details.responseStatus, + baseRespStatusCode: details.statusCode, + baseRespStatusMsg: + typeof details.statusMsg === 'string' + ? redactMiniMaxSecret(details.statusMsg) + : details.statusMsg, + cookieNames: details.cookieNames, + requestHeaderNames: details.requestHeaderNames + }) +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 1b810859ce7..796d7d516ef 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -9,7 +9,9 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' vi.mock('./claude-fetcher', () => ({ fetchClaudeRateLimits: vi.fn(), @@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({ fetchOpenCodeGoRateLimits: vi.fn() })) +vi.mock('./minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + type Deferred = { promise: Promise resolve: (value: T) => void @@ -42,7 +52,7 @@ function deferred(): Deferred { } function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -62,7 +72,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', message: string ): ProviderRateLimits { return { @@ -116,6 +126,8 @@ describe('RateLimitService', () => { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) }) it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => { @@ -1056,4 +1068,77 @@ describe('RateLimitService', () => { } ]) }) + + it('fetches MiniMax alongside other providers when a config resolver is set', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc; minimax_group_id_v2=42', + groupId: '', + models: 'general' + })) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now())) + + await service.refresh() + + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) + expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({ + cookie: '_token=abc; minimax_group_id_v2=42', + groupId: '', + models: 'general' + }) + + const state = service.getState() + expect(state.minimax?.status).toBe('ok') + expect(state.minimax?.session?.usedPercent).toBe(50) + expect(state.minimaxCookieConfigured).toBe(true) + }) + + it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => { + const service = new RateLimitService() + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + expect(service.getState().minimaxCookieConfigured).toBe(true) + }) + + it('discards the previous MiniMax snapshot when its config hash changes', async () => { + const service = new RateLimitService() + let models = 'general' + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models + })) + vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true) + vi.mocked(fetchMiniMaxRateLimits) + .mockResolvedValueOnce(okProvider('minimax', 40, Date.now())) + .mockResolvedValueOnce(okProvider('minimax', 10, Date.now())) + + await service.refresh() + expect(service.getState().minimax?.session?.usedPercent).toBe(40) + + models = 'premium' + await service.refresh() + + const state = service.getState() + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2) + expect(state.minimax?.session?.usedPercent).toBe(10) + }) + + it('isolates MiniMax failures from other providers', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models: 'general' + })) + vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down')) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + const state = service.getState() + expect(state.minimax?.status).toBe('error') + expect(state.minimax?.error).toBe('minimax down') + expect(state.claude?.status).toBe('ok') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 840f4e4a788..7e2bf948148 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -21,6 +21,8 @@ import { } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' +import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { normalizeCodexAccountSelectionTarget, @@ -43,6 +45,12 @@ type OpenCodeGoRateLimitConfig = { workspaceIdOverride: string } +type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + type GeminiCliOAuthEnabledResolver = () => boolean // Why: Claude's subscription usage endpoint has a tight request budget. Quota @@ -64,6 +72,7 @@ type InternalRateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null } function normalizePollingInterval(ms: number): number { @@ -91,7 +100,8 @@ export class RateLimitService { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null } private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null @@ -108,7 +118,9 @@ export class RateLimitService { private codexFetchGeneration = 0 private claudeFetchGeneration = 0 private opencodeFetchGeneration = 0 + private minimaxFetchGeneration = 0 private lastOpencodeConfigHash = '' + private lastMiniMaxConfigHash = '' private codexHomePathResolver: CodexHomePathResolver | null = null private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { runtime: 'host', @@ -120,6 +132,7 @@ export class RateLimitService { wslDistro: null } private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null @@ -163,6 +176,10 @@ export class RateLimitService { this.openCodeGoConfigResolver = resolver } + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { this.geminiCliOAuthEnabledResolver = resolver } @@ -239,6 +256,10 @@ export class RateLimitService { this.pruneInactiveCodexState() return { ...this.state, + // Why: the cookie lives in the file system, not GlobalSettings. Surface + // its presence on the pushed state so the renderer keeps the MiniMax + // bar visible across reloads and between snapshot refreshes. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( @@ -949,7 +970,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' ): ProviderRateLimits { if (!current) { return { @@ -983,6 +1004,10 @@ export class RateLimitService { const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfig = this.miniMaxConfigResolver?.() + const miniMaxCookie = miniMaxConfig?.sessionCookie ?? '' + const miniMaxGroupId = miniMaxConfig?.groupId ?? '' + const miniMaxModels = miniMaxConfig?.models ?? 'general' const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Detect if configuration changed — if it did, we must discard any stale @@ -995,6 +1020,14 @@ export class RateLimitService { } const opencodeGeneration = this.opencodeFetchGeneration + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + // Mark all providers as fetching while keeping previous data visible. // Codex account changes clear Codex separately before this method is // called, so ordinary refreshes still preserve the current values. @@ -1006,13 +1039,16 @@ export class RateLimitService { opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi') + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax') }) const missingWslCodexHome = codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] = + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, @@ -1029,7 +1065,12 @@ export class RateLimitService { }), fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), - fetchKimiRateLimits() + fetchKimiRateLimits(), + fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) ]) if (signal.aborted) { @@ -1103,6 +1144,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { @@ -1117,6 +1173,7 @@ export class RateLimitService { claudeProvenance === latestClaudeProvenance && this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration // Why: account switches can race in-flight Codex fetches. Only apply a // Codex result if both the selected-account provenance and the request @@ -1136,7 +1193,12 @@ export class RateLimitService { ? opencodeGo : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi) + kimi: this.applyStalePolicy(kimi, previousState.kimi), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax }) this.lastFetchAt = Date.now() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0af29e96d02..cb9cd51aa20 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2796,8 +2796,14 @@ export type PreloadApi = { setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise + refreshMiniMax: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } + minimaxCredentials: { + getStatus: () => Promise<{ configured: boolean }> + saveCookie: (cookie: string) => Promise<{ configured: boolean }> + clearCookie: () => Promise<{ configured: boolean }> + } ssh: { listTargets: () => Promise addTarget: (args: { target: Omit }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 01a42bd43e7..680c8b8d88c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3799,6 +3799,7 @@ const api = { ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), fetchInactiveCodexAccounts: (): Promise => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -3806,6 +3807,15 @@ const api = { } }, + minimaxCredentials: { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') + }, + ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index d50003b5122..aac01de620f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -13,17 +13,37 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { + AlertTriangle, + ExternalLink, + HelpCircle, + Loader2, + Lock, + LockOpen, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + X +} from 'lucide-react' import { useAppStore } from '../../store' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' +import { + ClaudeIcon, + GeminiIcon, + MiniMaxIcon, + OpenAIIcon, + OpenCodeGoIcon +} from '../status-bar/icons' import { toast } from 'sonner' import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, + getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' @@ -41,10 +61,73 @@ import { } from '../ui/dialog' import { getCodexAccountAuthWarning } from './codex-account-auth-warning' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' export { getAccountsPaneSearchEntries } const EMPTY_WSL_DISTROS: string[] = [] +const MINIMAX_CONSOLE_URL = 'https://platform.minimax.io/console/usage' + +function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { + const diffMs = Math.max(0, now - updatedAt) + if (diffMs < 60_000) { + return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now') + } + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const minutes = Math.round(diffMs / 60_000) + if (minutes < 60) { + return formatter.format(-minutes, 'minute') + } + const hours = Math.round(minutes / 60) + if (hours < 24) { + return formatter.format(-hours, 'hour') + } + return formatter.format(-Math.round(hours / 24), 'day') +} + +function MiniMaxCookieHelpPopover(): React.JSX.Element { + const steps = [ + translate( + 'auto.components.settings.AccountsPane.f5d8d2a6a1', + 'Open platform.minimax.io/console/usage in your browser and sign in.' + ), + translate('auto.components.settings.AccountsPane.24560fe830', 'Open DevTools.'), + translate( + 'auto.components.settings.AccountsPane.4cab0fa42d', + 'Go to the Network tab and enable Preserve log.' + ), + translate('auto.components.settings.AccountsPane.bee4e63e1c', 'Reload the page.'), + translate( + 'auto.components.settings.AccountsPane.87f814af6f', + 'Filter for remains and select the coding_plan/remains request.' + ), + translate( + 'auto.components.settings.AccountsPane.435df0ee51', + 'Under Request Headers, copy the Cookie value.' + ), + translate('auto.components.settings.AccountsPane.7492fb3bba', 'Paste it here and click Save.') + ] + return ( +
+
+

+ {translate('auto.components.settings.AccountsPane.9fec52de4b', 'How to copy the cookie')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.4e32e030b2', + 'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.' + )} +

+
+
    + {steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ ) +} type AccountsPaneProps = { settings: GlobalSettings @@ -254,9 +337,14 @@ export function AccountsPane({ const searchQuery = useAppStore((s) => s.settingsSearchQuery) const codexRateLimits = useAppStore((s) => s.rateLimits.codex) const codexRateLimitTarget = useAppStore((s) => s.rateLimits.codexTarget) + const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') + const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) + const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) const accountRuntime = getSelectedAccountRuntime( settings, wslSupportedPlatform, @@ -319,6 +407,70 @@ export function AccountsPane({ recordFeatureInteraction('usage-tracking') } + const refreshMiniMaxCredentialStatus = async (): Promise => { + try { + const status = await window.api.minimaxCredentials.getStatus() + setMiniMaxConfigured(status.configured) + } catch (error) { + console.error('Failed to load MiniMax credential status:', error) + } + } + + const saveMiniMaxCookie = async (): Promise => { + if (!miniMaxCookieDraft.trim()) { + toast.error( + translate('auto.components.settings.AccountsPane.2f24f244a4', 'MiniMax cookie is required.') + ) + return + } + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft) + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + void refreshRateLimits() + toast.success( + translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.') + ) + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + const clearMiniMaxCookie = async (): Promise => { + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.clearCookie() + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + void refreshRateLimits() + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + useEffect(() => { + void refreshMiniMaxCredentialStatus() + }, []) + useEffect(() => { let stale = false @@ -1351,6 +1503,247 @@ export function AccountsPane({

+ ) : null, + matchesSettingsSearch(searchQuery, getAccountsMiniMaxSearchEntries()) ? ( +
+
+
+

+ + {translate('auto.components.settings.AccountsPane.5d63bbfbec', 'MiniMax')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.15e831350e', + 'Configure MiniMax usage tracking from platform.minimax.io.' + )} +

+
+ + {translate('auto.components.settings.AccountsPane.0d8e77bc40', 'Open console')} + + +
+ +
+ +
+

+ {translate( + 'auto.components.settings.AccountsPane.0b8c1c7e02', + miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set' + )} +

+

+ {translate( + 'auto.components.settings.AccountsPane.5e08b0fe57', + 'Sent only to platform.minimax.io. Never leaves Orca.' + )} +

+
+
+ + +
+
+ + + {miniMaxConfigured ? : } + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+ + + + + + + + +
+
+ setMiniMaxCookieDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.b8a4f21c3e', + 'Paste the Cookie header from DevTools' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {miniMaxConfigured ? ( + + ) : null} +
+

+ {translate( + 'auto.components.settings.AccountsPane.79418c782a', + 'Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).' + )} +

+ {miniMaxConfigured && + miniMaxRateLimits?.status === 'ok' && + miniMaxRateLimits.error === null ? ( +

+ {translate( + 'auto.components.settings.AccountsPane.53f7b8c7a2', + 'Last refresh: {{value0}}', + { value0: formatMiniMaxRelativeRefresh(miniMaxRateLimits.updatedAt, Date.now()) } + )} +

+ ) : null} +

+ {translate( + 'auto.components.settings.AccountsPane.31d24a4e87', + 'Cookie expires when you sign out in the browser.' + )} +

+
+ +
+
+
+

+ {translate('auto.components.settings.AccountsPane.9dd50d3f75', 'Advanced')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.174fb408f9', + 'Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.' + )} +

+
+ {miniMaxConfigured ? ( + + ) : null} +
+ + + + updateSettings({ minimaxGroupId: e.target.value })} + placeholder={translate( + 'auto.components.settings.AccountsPane.0747d6391a', + settings.minimaxGroupId ? 'From cookie' : 'Use group ID from cookie' + )} + spellCheck={false} + className="text-xs" + /> + + + + + updateSettings({ minimaxUsageModels: e.target.value })} + placeholder={translate('auto.components.settings.AccountsPane.3c92b0d31c', 'general')} + spellCheck={false} + className="text-xs" + /> + +
+
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts new file mode 100644 index 00000000000..7958045b8e1 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' + +describe('getAccountsMiniMaxSearchEntries', () => { + it('returns a single entry that targets the MiniMax session cookie flow', () => { + const entries = getAccountsMiniMaxSearchEntries() + expect(entries).toHaveLength(1) + const [entry] = entries + expect(entry.title).toBe('MiniMax Usage') + expect(entry.description).toContain('platform.minimax.io') + expect(entry.description.toLowerCase()).toContain('cookie') + }) + + it('exposes the keywords that drive the Settings search index', () => { + const [entry] = getAccountsMiniMaxSearchEntries() + // Why: the Settings search needs at least one of these tokens to + // surface the MiniMax section when the user types a related term. + expect(entry.keywords).toEqual( + expect.arrayContaining(['minimax', 'cookie', 'session', 'rate limit', 'status bar']) + ) + }) + + it('is included in the rolled-up pane search entries', () => { + const allEntries = getAccountsPaneSearchEntries() + const titles = allEntries.map((entry) => entry.title) + expect(titles).toContain('MiniMax Usage') + }) +}) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 1d4e1564553..7f0d68a8387 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -171,10 +171,31 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.733f9e2a93', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.accounts.search.f8374c3151', + 'Paste your platform.minimax.io session cookie for local rate-limit fetching.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.d16378a88f', 'minimax'), + ...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'), + ...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), - ...getAccountsOpencodeSearchEntries() + ...getAccountsOpencodeSearchEntries(), + ...getAccountsMiniMaxSearchEntries() ]) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d08ffca7a06..6fcbc68801d 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -53,7 +53,7 @@ import { formatResetCreditExpiry, getProviderUsageStatusLabel } from './tooltip' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' @@ -1689,7 +1689,9 @@ export function ProviderDetailsMenu({ ? 'O' : provider.provider === 'kimi' ? 'K' - : 'X'} + : provider.provider === 'minimax' + ? 'M' + : 'X'} ) : ( @@ -1831,17 +1833,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while // usage snapshots hydrate, fail, or temporarily report unavailable. // Detection-gating (see status-bar-agent-gating) additionally hides per-CLI // bars when the agent isn't installed on PATH. - const visibleClaude = getVisibleUsageProvider('claude', claude, settings) - const visibleCodex = getVisibleUsageProvider('codex', codex, settings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, settings) - const visibleKimi = getVisibleUsageProvider('kimi', kimi, settings) + // Why: thread the cookie durability flag from RateLimitState so the + // MiniMax bar stays visible after a reload and between snapshot refreshes. + const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured } + const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) + const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) + const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) + const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) + const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -1858,9 +1864,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. - const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, settings) + const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') const showResourceUsage = statusBarItems.includes('resource-usage') @@ -1868,12 +1875,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const showFloatingTerminalToggle = floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' const anyVisible = - showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage + showClaude || + showCodex || + showGemini || + showOpencodeGo || + showKimi || + showMiniMax || + showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. - const isEmptyUsageState = isUsageEmptyState({ claude, codex, gemini, opencodeGo, kimi }, settings) + const isEmptyUsageState = isUsageEmptyState( + { claude, codex, gemini, opencodeGo, kimi, minimax }, + usageSettings + ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it // hidden even after providers are disconnected again. const showEmptyUsageCta = isEmptyUsageState && !usageEmptyStateDismissed @@ -1882,7 +1898,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele codex?.status === 'fetching' || gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || - kimi?.status === 'fetching' + kimi?.status === 'fetching' || + minimax?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -1957,6 +1974,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showMiniMax && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2087,6 +2115,16 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')} )} + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('minimax') + }} + > + + {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + { diff --git a/src/renderer/src/components/status-bar/icons.test.tsx b/src/renderer/src/components/status-bar/icons.test.tsx new file mode 100644 index 00000000000..d46d9a65cce --- /dev/null +++ b/src/renderer/src/components/status-bar/icons.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { MiniMaxIcon } from './icons' + +describe('MiniMaxIcon', () => { + it('renders the official MiniMax mark as an image', () => { + const markup = renderToStaticMarkup() + expect(markup.startsWith(' { + const markup = renderToStaticMarkup() + expect(markup).toContain('width="20"') + expect(markup).toContain('height="20"') + }) + + it('does not render the legacy "M" placeholder text', () => { + const markup = renderToStaticMarkup() + expect(markup).not.toContain('>M<') + }) +}) diff --git a/src/renderer/src/components/status-bar/icons.tsx b/src/renderer/src/components/status-bar/icons.tsx index e6fb38bccca..480297b6412 100644 --- a/src/renderer/src/components/status-bar/icons.tsx +++ b/src/renderer/src/components/status-bar/icons.tsx @@ -1,4 +1,5 @@ import React from 'react' +import minimaxIconUrl from '../../../../../resources/minimax-icon.svg?url' export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element { return ( @@ -15,6 +16,24 @@ export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element ) } +export function MiniMaxIcon({ size = 14 }: { size?: number }): React.JSX.Element { + // Why: ship the official MiniMax wordmark mark so the icon is recognizable + // in both light and dark themes. The SVG carries its own pink→orange gradient + // that reads on muted backgrounds, so we keep the asset monochrome-agnostic + // and let CSS theme tokens drive the surrounding chrome instead. + return ( + + ) +} + // Why: each instance needs unique filter/mask IDs — reusing the same ID across // multiple SVGs on the same page causes the browser to resolve to the first one, // breaking all subsequent instances. diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 790a4dbfa82..23de9434c37 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, + minimaxCookieConfigured: false, ...overrides } } @@ -117,6 +118,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) }) it('does not treat empty or unloaded settings as configured', () => { @@ -147,6 +149,17 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false) }) + + it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => { + expect( + hasUsageProviderSettingsForProvider( + 'minimax', + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false) + }) }) describe('getVisibleUsageProvider', () => { @@ -207,6 +220,45 @@ describe('getVisibleUsageProvider', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) }) + + it('keeps MiniMax visible while the snapshot is pending when a cookie is configured', () => { + const visible = getVisibleUsageProvider( + 'minimax', + null, + usageSettings({ minimaxCookieConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'minimax', + status: 'fetching', + session: null, + weekly: null + }) + }) + + it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => { + const unavailable = provider('unavailable', { + provider: 'minimax', + error: 'MiniMax session expired. Replace the MiniMax cookie in Settings.' + }) + expect( + getVisibleUsageProvider( + 'minimax', + unavailable, + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(unavailable) + }) + + it('hides MiniMax when no cookie is configured and the snapshot is empty', () => { + expect(getVisibleUsageProvider('minimax', null, usageSettings())).toBe(null) + expect( + getVisibleUsageProvider( + 'minimax', + provider('unavailable', { provider: 'minimax' }), + usageSettings() + ) + ).toBe(null) + }) }) describe('isUsageEmptyState', () => { @@ -218,7 +270,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, usageSettings() ) @@ -233,7 +286,8 @@ describe('isUsageEmptyState', () => { codex: provider('fetching', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) @@ -248,7 +302,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings({ codexManagedAccounts: [ @@ -274,7 +329,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, null ) @@ -289,7 +345,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index b6e35c292fb..bbac8ba8a38 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -7,7 +7,13 @@ export type UsageProviderSettings = Pick< | 'claudeManagedAccounts' | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' -> +> & { + // Why: the MiniMax cookie lives in the file system, not GlobalSettings, so + // we can't derive durability from settings alone. The renderer threads the + // flag from RateLimitState (pushed by the main process) so the bar stays + // visible across reloads and between snapshot refreshes. + minimaxCookieConfigured: boolean +} type UsageProviderSnapshots = { claude: ProviderRateLimits | null @@ -15,6 +21,7 @@ type UsageProviderSnapshots = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -58,7 +65,8 @@ export function hasUsageProviderSettings( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || - Boolean(settings?.opencodeSessionCookie?.trim()) + Boolean(settings?.opencodeSessionCookie?.trim()) || + settings?.minimaxCookieConfigured === true ) } @@ -81,6 +89,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } + if (providerId === 'minimax') { + return settings.minimaxCookieConfigured === true + } return false } @@ -128,7 +139,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.codex) || isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || - isProviderSnapshotPending(providers.kimi) + isProviderSnapshotPending(providers.kimi) || + isProviderSnapshotPending(providers.minimax) ) { return false } @@ -138,6 +150,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.codex) && !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && - !isProviderConfigured(providers.kimi) + !isProviderConfigured(providers.kimi) && + !isProviderConfigured(providers.minimax) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index cf631423693..510e3eea66f 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -22,6 +22,7 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel, getWindowSections, + ProviderIcon, ProviderPanel } from './tooltip' @@ -402,4 +403,58 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('Fable') expect(markup).toContain('Resets in 6d 17h') }) + + it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + // Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the + // same source field so the two views stay consistent. + expect(markup).toContain('65%') + expect(markup).toContain('% left') + expect(markup).not.toContain('100% left') + }) + + it('clamps MiniMax session to 0% left when usedPercent reports 100', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + expect(markup).toContain('0% left') + }) +}) + +describe('ProviderIcon', () => { + it('renders the official MiniMax icon asset for the minimax provider', () => { + // Why: the icon must travel to the status bar / tooltip unchanged so the + // user recognises the brand. We pin it to an with a non-empty + // resource URL and aria-hidden so the icon stays purely decorative. + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + expect(markup.startsWith(' } + if (provider === 'minimax') { + return + } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts new file mode 100644 index 00000000000..3fc29780adc --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getProviderDisplayName } from './usage-error-copy' + +describe('getProviderDisplayName', () => { + it('returns the MiniMax brand name', () => { + expect(getProviderDisplayName('minimax')).toBe('MiniMax') + }) + + it('returns the existing provider brand names', () => { + expect(getProviderDisplayName('claude')).toBe('Claude') + expect(getProviderDisplayName('codex')).toBe('Codex') + expect(getProviderDisplayName('gemini')).toBe('Gemini') + expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') + expect(getProviderDisplayName('kimi')).toBe('Kimi') + }) + + it('falls back to the raw provider id when no mapping exists', () => { + // Why: provider id is a closed union, but TypeScript may not enforce + // exhaustiveness on dynamic callers. Fallback keeps logging safe. + expect(getProviderDisplayName('unknown-provider' as never)).toBe('unknown-provider') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 60c0b99d452..3d33fe763f6 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'minimax') { + return 'MiniMax' + } return provider } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1080958f7c2..3226d39b6f1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Reset Codex limits?", "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "c0e972d726": "Cancel", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -4634,7 +4636,46 @@ "75ca9b718e": "Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "Use your current {{value0}} Claude login." + "e05d0ff737": "Use your current {{value0}} Claude login.", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).", + "9dd50d3f75": "Advanced", + "174fb408f9": "Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.", + "bf160bb6c0": "Group ID override", + "b1e2743313": "Optional. Leave blank to use minimax_group_id_v2 from the cookie.", + "0747d6391a": "Use group ID from cookie", + "4ff2af7524": "Usage model names", + "5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.", + "3c92b0d31c": "general", + "0d8e77bc40": "Open console", + "0b8c1c7e02": "Encrypted locally", + "5e08b0fe57": "Sent only to platform.minimax.io. Never leaves Orca.", + "43d7a45b97": "How to copy", + "b8a4f21c3e": "Paste the Cookie header from DevTools", + "53f7b8c7a2": "Last refresh: {{value0}}", + "31d24a4e87": "Cookie expires when you sign out in the browser.", + "42c2cb21cf": "Forget cookie", + "3a30aaf526": "just now", + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "24560fe830": "Open DevTools.", + "4cab0fa42d": "Go to the Network tab and enable Preserve log.", + "bee4e63e1c": "Reload the page.", + "87f814af6f": "Filter for remains and select the coding_plan/remains request.", + "435df0ee51": "Under Request Headers, copy the Cookie value.", + "7492fb3bba": "Paste it here and click Save.", + "9fec52de4b": "How to copy the cookie", + "4e32e030b2": "The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -6802,7 +6843,9 @@ "bdbd1e668e": "windows", "593720c17f": "location", "b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.", - "d09fb5ca92": "Account Location" + "d09fb5ca92": "Account Location", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b7a29585352..b7bccb7e426 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2935,7 +2935,9 @@ "972a1ff497": "¿Restablecer límites de Codex?", "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", - "c0e972d726": "Cancelar" + "c0e972d726": "Cancelar", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4634,7 +4636,46 @@ "75ca9b718e": "Codex informó que la cuenta activa requiere un inicio de sesión nuevo. Reautentícala antes de iniciar nuevas sesiones de Codex.", "b11078a9c2": "wsl", "350b2a1aa7": "Usa tu actual", - "e05d0ff737": "Usa tu login actual de Claude de {{value0}}." + "e05d0ff737": "Usa tu login actual de Claude de {{value0}}.", + "2f24f244a4": "La cookie de MiniMax es obligatoria.", + "8d61637a77": "Cookie de MiniMax guardada.", + "b43e761fe5": "No se pudo actualizar la cookie de MiniMax.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configura el seguimiento de uso de MiniMax desde platform.minimax.io.", + "21d6eb141e": "Cookie de sesión de MiniMax", + "33bba5ad83": "Pega tu cookie de sesión de MiniMax para obtener límites de uso locales.", + "73ea15f24b": "Guardado", + "23afe8f226": "No guardado", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Reemplazar", + "590a3130f9": "Guardar", + "79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y copia el encabezado Cookie de DevTools (Red → cualquier solicitud a remains → Cookie).", + "9dd50d3f75": "Avanzado", + "174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al workspace o modelo equivocado.", + "bf160bb6c0": "Anulación de Group ID", + "b1e2743313": "Opcional. Déjalo en blanco para usar minimax_group_id_v2 de la cookie.", + "0747d6391a": "Usar el Group ID de la cookie", + "4ff2af7524": "Nombres de modelos de uso", + "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", + "3c92b0d31c": "general", + "0d8e77bc40": "Abrir consola", + "0b8c1c7e02": "Cifrado localmente", + "5e08b0fe57": "Solo se envía a platform.minimax.io. Nunca sale de Orca.", + "43d7a45b97": "Cómo copiar", + "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", + "53f7b8c7a2": "Última actualización: {{value0}}", + "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", + "42c2cb21cf": "Olvidar cookie", + "3a30aaf526": "ahora mismo", + "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", + "24560fe830": "Abre DevTools.", + "4cab0fa42d": "Ve a la pestaña Red y activa Preserve log.", + "bee4e63e1c": "Recarga la página.", + "87f814af6f": "Filtra por remains y selecciona la solicitud coding_plan/remains.", + "435df0ee51": "En Request Headers, copia el valor de Cookie.", + "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", + "9fec52de4b": "Cómo copiar la cookie", + "4e32e030b2": "La cookie permanece en este dispositivo. Orca solo la envía a platform.minimax.io para actualizar el uso." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", @@ -6765,7 +6806,9 @@ "bdbd1e668e": "Windows", "593720c17f": "ubicación", "b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.", - "d09fb5ca92": "Ubicación de cuentas" + "d09fb5ca92": "Ubicación de cuentas", + "733f9e2a93": "Uso de MiniMax", + "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e6ff4fc77f5..2496debcd8c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Codex の制限をリセットしますか?", "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", "f077f586db": "今後表示しない", - "c0e972d726": "キャンセル" + "c0e972d726": "キャンセル", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex は、アクティブなアカウントには新たにサインインする必要があると報告しました。新規 Codex セッションを開始する前に、再認証してください。", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。" + "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。", + "9dd50d3f75": "詳細設定", + "174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。", + "bf160bb6c0": "グループ ID の上書き", + "b1e2743313": "任意。空白にすると Cookie の minimax_group_id_v2 が使われます。", + "0747d6391a": "Cookie からグループ ID を使用", + "4ff2af7524": "使用モデルの名前", + "5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。", + "3c92b0d31c": "general", + "0d8e77bc40": "コンソールを開く", + "0b8c1c7e02": "ローカルで暗号化", + "5e08b0fe57": "platform.minimax.io のみに送信されます。Orca の外部に出ることはありません。", + "43d7a45b97": "コピー方法", + "b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け", + "53f7b8c7a2": "最終更新: {{value0}}", + "31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。", + "42c2cb21cf": "Cookie を削除", + "3a30aaf526": "たった今", + "f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。", + "24560fe830": "DevTools を開きます。", + "4cab0fa42d": "ネットワークタブを開き、Preserve log を有効にします。", + "bee4e63e1c": "ページを再読み込みします。", + "87f814af6f": "remains でフィルタリングし、coding_plan/remains のリクエストを選択します。", + "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", + "7492fb3bba": "ここに貼り付けて保存をクリックします。", + "9fec52de4b": "Cookie のコピー方法", + "4e32e030b2": "Cookie はこのデバイスにのみ保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -6787,7 +6828,9 @@ "bdbd1e668e": "窓", "593720c17f": "場所", "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", - "d09fb5ca92": "アカウントの場所" + "d09fb5ca92": "アカウントの場所", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 705815c7739..e774013d063 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2935,7 +2935,9 @@ "972a1ff497": "Codex 한도를 재설정할까요?", "6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.", "f077f586db": "다시 묻지 않기", - "c0e972d726": "취소" + "c0e972d726": "취소", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex는 활성 계정에 새로 로그인해야 한다고 보고했습니다. 새 Codex 세션을 시작하기 전에 다시 인증하세요.", "b11078a9c2": "wsl", "350b2a1aa7": "현재 사용 중인", - "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요." + "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.", + "9dd50d3f75": "고급", + "174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.", + "bf160bb6c0": "Group ID 재정의", + "b1e2743313": "선택 항목입니다. 비워두면 Cookie의 minimax_group_id_v2가 사용됩니다.", + "0747d6391a": "Cookie의 Group ID 사용", + "4ff2af7524": "사용 모델 이름", + "5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.", + "3c92b0d31c": "general", + "0d8e77bc40": "콘솔 열기", + "0b8c1c7e02": "로컬에서 암호화됨", + "5e08b0fe57": "platform.minimax.io에만 전송됩니다. Orca 외부로 나가지 않습니다.", + "43d7a45b97": "복사 방법", + "b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기", + "53f7b8c7a2": "마지막 새로 고침: {{value0}}", + "31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.", + "42c2cb21cf": "Cookie 삭제", + "3a30aaf526": "방금 전", + "f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.", + "24560fe830": "DevTools를 엽니다.", + "4cab0fa42d": "네트워크 탭으로 이동하여 Preserve log를 활성화합니다.", + "bee4e63e1c": "페이지를 새로 고침합니다.", + "87f814af6f": "remains로 필터링하고 coding_plan/remains 요청을 선택합니다.", + "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", + "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", + "9fec52de4b": "Cookie 복사 방법", + "4e32e030b2": "Cookie는 이 기기에만 보관됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -6750,7 +6791,9 @@ "bdbd1e668e": "Windows", "593720c17f": "위치", "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", - "d09fb5ca92": "계정 위치" + "d09fb5ca92": "계정 위치", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index db284162150..29d853b248b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2935,7 +2935,9 @@ "972a1ff497": "重置 Codex 限额?", "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", "f077f586db": "不再询问", - "c0e972d726": "取消" + "c0e972d726": "取消", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -4619,7 +4621,46 @@ "75ca9b718e": "Codex 报告活动账户需要重新登录。在开始新的 Codex 会话之前重新对其进行身份验证。", "b11078a9c2": "wsl", "350b2a1aa7": "用你已有的", - "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。" + "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。", + "2f24f244a4": "MiniMax cookie is required.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", + "9dd50d3f75": "高级", + "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", + "bf160bb6c0": "Group ID 覆盖", + "b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。", + "0747d6391a": "使用 Cookie 中的 Group ID", + "4ff2af7524": "使用模型名称", + "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", + "3c92b0d31c": "general", + "0d8e77bc40": "打开控制台", + "0b8c1c7e02": "本地加密", + "5e08b0fe57": "仅发送到 platform.minimax.io。不会离开 Orca。", + "43d7a45b97": "如何复制", + "b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头", + "53f7b8c7a2": "上次刷新: {{value0}}", + "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", + "42c2cb21cf": "忘记 Cookie", + "3a30aaf526": "刚刚", + "f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。", + "24560fe830": "打开 DevTools。", + "4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。", + "bee4e63e1c": "重新加载页面。", + "87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。", + "435df0ee51": "在请求头中,复制 Cookie 的值。", + "7492fb3bba": "在此粘贴并点击保存。", + "9fec52de4b": "如何复制 Cookie", + "4e32e030b2": "Cookie 仅保留在此设备上。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -6750,7 +6791,9 @@ "bdbd1e668e": "视窗", "593720c17f": "位置", "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", - "d09fb5ca92": "账户位置" + "d09fb5ca92": "账户位置", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 730afbbd5ad..01050c93cc7 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -37,4 +37,21 @@ describe('formatWindowLabel', () => { it('returns "3d" for 4320 minutes (3 days)', () => { expect(formatWindowLabel(4320)).toBe('3d') }) + + it('documents the formatter contract: 295 minutes stays raw ("295m")', () => { + // Why: the formatter does NOT snap — the snap lives in the MiniMax + // fetcher (snapMiniMaxWindowMinutes), where raw drift of a few minutes + // rounds to the canonical 300-minute bucket before reaching this label. + // This test pins the contract so a future change to the formatter + // does not silently regress the snap's "5h" output. + expect(formatWindowLabel(295)).toBe('295m') + expect(formatWindowLabel(300)).toBe('5h') + }) + + it('falls back to per-minute labels outside canonical buckets', () => { + // Why: when the window length lands between buckets (e.g. 2h30m), we + // render the raw minute count rather than guess at a half-bucket label. + expect(formatWindowLabel(75)).toBe('75m') + expect(formatWindowLabel(150)).toBe('150m') + }) }) diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index fa4d41b9b03..4513119ab69 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -21,6 +21,8 @@ export const createRateLimitSlice: StateCreator['rateLimits']> { gemini: null, opencodeGo: null, kimi: null, + minimax: null, + minimaxCookieConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -2469,6 +2471,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 26db028766d..76afe5caab4 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -125,3 +125,15 @@ describe('getDefaultPrimarySelectionMiddleClickPaste', () => { expect(getDefaultPrimarySelectionMiddleClickPaste('win32')).toBe(false) }) }) + +describe('MiniMax defaults', () => { + it('starts MiniMax with empty group id and the canonical default model', () => { + const settings = getDefaultSettings('/tmp') + // Why: the fetcher reads these defaults on first launch. An empty + // group id is the signal that the fetcher must pull the value from + // the cookie itself, and "general" matches the model name the + // MiniMax usage endpoint exposes by default. + expect(settings.minimaxGroupId).toBe('') + expect(settings.minimaxUsageModels).toBe('general') + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4eebbaa9383..cc9f8ce9603 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -320,6 +320,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts new file mode 100644 index 00000000000..dfc8c1dad31 --- /dev/null +++ b/src/shared/rate-limit-types.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { RateLimitState } from './rate-limit-types' + +describe('RateLimitState', () => { + it('documents the MiniMax surface used by the AccountsPane settings UI', () => { + // Why: the AccountsPane and the status bar both read these fields + // from RateLimitState. The shape must stay stable so that the + // visibility check (status-bar-provider-visibility) keeps working + // across refactors. + const state: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + minimax: null, + minimaxCookieConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + + expect(state.minimax).toBeNull() + expect(state.minimaxCookieConfigured).toBe(false) + }) +}) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 59a897f51e1..6ed2c696f1b 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -101,6 +101,14 @@ export type RateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null + /** + * True when a MiniMax session cookie is persisted on disk. The cookie lives + * outside GlobalSettings, so this flag is the durable signal that the + * status bar uses to keep the MiniMax provider visible across reloads and + * between snapshot refreshes. + */ + minimaxCookieConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 7e673849589..5e9939c6461 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'kimi', + 'minimax', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/types.ts b/src/shared/types.ts index 57c2d61c188..8d3268035aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2763,6 +2763,10 @@ export type GlobalSettings = { /** Optional workspace ID override for OpenCode Go. When set, skips the * workspaces lookup and fetches usage directly for this workspace. */ opencodeWorkspaceId: string + /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ + minimaxGroupId: string + /** Comma-separated MiniMax model names to show in the status bar usage window. */ + minimaxUsageModels: string /** Whether to extract OAuth credentials from the local Gemini CLI installation * for rate-limit fetching. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean @@ -3109,6 +3113,7 @@ export type StatusBarItem = | 'gemini' | 'opencode-go' | 'kimi' + | 'minimax' | 'ssh' | 'resource-usage' | 'ports' From a169b7d03d6d58d3d4959c27817eaf9b9740f6be Mon Sep 17 00:00:00 2001 From: Wolfie Date: Sat, 4 Jul 2026 23:52:24 -0700 Subject: [PATCH 220/233] fix: focus new floating-workspace agent tab on launch (#6250) * fix: focus new floating-workspace agent tab on launch The default-agent button in the floating workspace titlebar created its tab with { activate: false } and only called setActiveTabForWorktree, which writes activeTabIdByWorktree. The floating panel selects its visible tab from the unified group's activeTabId, so the new agent tab was appended but never selected or focused. Add activateTab(tab.id), matching the empty-state tab creators, so launching a new agent switches to and focuses its tab. * test: use top-level react type import in floating controls test * test: lock queue-before-activate order and state updates for agent launch Strengthen the FloatingTerminalWindowControls regression test per review: - make the createTab mock append the new tab to the worktree (realistic store) - seed an existing tab so append/order behavior is observable - assert startup command is queued before activateTab (invocationCallOrder) - assert setActiveTabForWorktree and tab bar order placement --------- Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> --- .../FloatingTerminalWindowControls.test.tsx | 233 ++++++++++++++++++ .../FloatingTerminalWindowControls.tsx | 8 +- 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx new file mode 100644 index 00000000000..a4be125a2f9 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' + +type ReactElementLike = { + type: unknown + props: Record +} + +const storeBox = vi.hoisted(() => ({ + state: null as unknown +})) + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + createTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabBarOrder: vi.fn(), + queueTabStartupCommand: vi.fn(), + focusTerminalTabSurface: vi.fn(), + buildAgentStartupPlan: vi.fn() +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(storeBox.state), { + getState: () => storeBox.state + }) +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: mocks.focusTerminalTabSurface +})) + +vi.mock('@/lib/tui-agent-startup', () => ({ + buildAgentStartupPlan: mocks.buildAgentStartupPlan +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [{ id: 'claude', label: 'Claude' }], + AgentIcon: function AgentIcon() { + return null + } +})) + +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'darwin' +})) + +vi.mock('@/lib/telemetry', () => ({ + tuiAgentToAgentKind: () => 'claude' +})) + +vi.mock('../../../../shared/tui-agent-selection', () => ({ + isTuiAgentEnabled: () => true +})) + +vi.mock('../../../../shared/tui-agent-launch-defaults', () => ({ + resolveTuiAgentLaunchArgs: () => [], + resolveTuiAgentLaunchEnv: () => ({}) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, vars?: Record) => + vars ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars[name] ?? '') : fallback +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: () => null +})) + +vi.mock('@/components/ui/button', () => ({ + Button: function Button(props: { children?: unknown }) { + return props.children ?? null + } +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: function Tooltip(props: { children?: unknown }) { + return props.children + }, + TooltipContent: function TooltipContent(props: { children?: unknown }) { + return props.children + }, + TooltipTrigger: function TooltipTrigger(props: { children?: unknown }) { + return props.children + } +})) + +vi.mock('lucide-react', () => ({ + Maximize2: function Maximize2() { + return null + }, + Minimize2: function Minimize2() { + return null + }, + Minus: function Minus() { + return null + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + if (!element.props) { + return + } + cb(element) + visit(element.props.children, cb) +} + +function findOnClickByAriaLabel(node: unknown, ariaLabel: string): () => void { + let found: (() => void) | null = null + visit(node, (entry) => { + if (entry.props['aria-label'] === ariaLabel && typeof entry.props.onClick === 'function') { + found = entry.props.onClick as () => void + } + }) + if (!found) { + throw new Error(`onClick for aria-label "${ariaLabel}" not found`) + } + return found +} + +const NEW_AGENT_TAB_ID = 'floating-agent-tab' +const EXISTING_TAB_ID = 'floating-existing-tab' + +beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset() + } + mocks.createTab.mockImplementation(() => { + const tab = { id: NEW_AGENT_TAB_ID } + const state = storeBox.state as { tabsByWorktree: Record } + const existing = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [...existing, tab] + return tab + }) + mocks.buildAgentStartupPlan.mockReturnValue({ + launchCommand: 'claude', + launchConfig: {}, + env: undefined, + startupCommandDelivery: undefined + }) + storeBox.state = { + settings: { + defaultTuiAgent: 'claude', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }, + createTab: mocks.createTab, + activateTab: mocks.activateTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabBarOrder: mocks.setTabBarOrder, + queueTabStartupCommand: mocks.queueTabStartupCommand, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] }, + tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [EXISTING_TAB_ID] } + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('FloatingTerminalWindowControls default-agent launch', () => { + it('activates the new agent tab so the floating panel selects and focuses it', () => { + const element = FloatingTerminalWindowControls({ + maximized: false, + onToggleMaximized: vi.fn(), + onMinimize: vi.fn() + }) + + const launch = findOnClickByAriaLabel(element, 'Open Claude in floating workspace') + launch() + + expect(mocks.createTab).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + undefined, + { activate: false } + ) + // Why: TerminalPane consumes any pending startup command on first render, so + // the launch command must be queued before activation can mount the surface - + // otherwise the new tab can come up as a bare shell. + expect(mocks.queueTabStartupCommand).toHaveBeenCalledWith( + NEW_AGENT_TAB_ID, + expect.objectContaining({ + command: 'claude', + launchAgent: 'claude' + }) + ) + expect(mocks.queueTabStartupCommand.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTab.mock.invocationCallOrder[0] + ) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId, which only activateTab writes. setActiveTabForWorktree updates + // the complementary legacy per-worktree map. Without activateTab the new agent + // tab would be appended but never selected/focused. + expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + NEW_AGENT_TAB_ID + ) + expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) + // Why: createTab appends the new tab to the worktree; the order reconciliation + // must keep the pre-existing tab and place the new agent tab last. + expect(mocks.setTabBarOrder).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, [ + EXISTING_TAB_ID, + NEW_AGENT_TAB_ID + ]) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 514402a0f36..03213e9820e 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -43,6 +43,7 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const activateTab = useAppStore((s) => s.activateTab) const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') @@ -100,7 +101,12 @@ export function FloatingTerminalWindowControls({ request_kind: 'new' } }) + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so + // the new agent tab would be appended but never selected/focused. activateTab + // selects it within the group, matching the empty-state tab creators. setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) + activateTab(tab.id) const fresh = useAppStore.getState() const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] @@ -114,7 +120,7 @@ export function FloatingTerminalWindowControls({ order.push(tab.id) fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order) focusTerminalTabSurface(tab.id) - }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) + }, [activateTab, createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) return (
From 6677de1807d524c992827e89f1fd804f1dbc5343 Mon Sep 17 00:00:00 2001 From: leter <117361093@qq.com> Date: Sun, 5 Jul 2026 14:54:26 +0800 Subject: [PATCH 221/233] Improve SSH Node.js prerequisite guidance Improve SSH remote Node.js prerequisite failures by adding package-manager-specific POSIX install guidance, Windows Node 18+ candidate validation, fnm XDG probing, and regression coverage while preserving SSH abort and session-limit behavior. --- .../ssh/ssh-remote-node-install-guidance.ts | 93 +++++++++ .../ssh/ssh-remote-node-resolution.test.ts | 84 ++++++++- src/main/ssh/ssh-remote-node-resolution.ts | 177 +++++++++++++----- 3 files changed, 304 insertions(+), 50 deletions(-) create mode 100644 src/main/ssh/ssh-remote-node-install-guidance.ts diff --git a/src/main/ssh/ssh-remote-node-install-guidance.ts b/src/main/ssh/ssh-remote-node-install-guidance.ts new file mode 100644 index 00000000000..e4ca3a0d3c3 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-install-guidance.ts @@ -0,0 +1,93 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' + +export type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + +const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000 + +const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [ + { bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' }, + { bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' }, + { bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' }, + { bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' }, + { bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' }, + { bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' }, + { bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' } +] + +export async function buildPosixNodeInstallGuidance( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + const detectedBin = await detectPackageManager(conn, options) + return formatNodeInstallHints(detectedBin) +} + +async function detectPackageManager( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + try { + const output = await execCommand( + conn, + `for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`, + commandOptions(options) + ) + const detected = output.trim().split('\n')[0] + return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + return null + } +} + +function formatNodeInstallHints(detectedBin: string | null): string { + const tailored = detectedBin + ? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin) + : null + const lines = [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ with npm on the remote host, then reconnect:' + ] + if (tailored) { + lines.push(` ${tailored.label}: ${tailored.install}`) + } else { + for (const hint of NODE_PACKAGE_MANAGER_HINTS) { + lines.push(` ${hint.label}: ${hint.install}`) + } + } + lines.push( + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.' + ) + return lines.join('\n') +} + +function commandOptions(options?: RemoteNodeResolutionOptions): { + timeoutMs: number + signal?: AbortSignal +} { + const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS } + return options?.signal ? { ...base, signal: options.signal } : base +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } +} diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b3cad3c0ecc..84d9b9b48e7 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution') const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + if (!match) { + throw new Error(`No encoded PowerShell command found in: ${command}`) + } + return Buffer.from(match[1]!, 'base64').toString('utf16le') +} + describe('resolveRemoteNodePath', () => { beforeEach(() => { execCommandMock.mockReset() @@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node') expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') }) + it('probes fnm XDG data directory installs', async () => { + execCommandMock + .mockResolvedValueOnce( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n' + ) + .mockResolvedValueOnce('v24.18.0\n') + + await expect(resolveRemoteNodePath(conn)).resolves.toBe( + '/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node' + ) + }) + it('does not depend on GNU sort when probing version-manager directories', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') @@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('\n') // path probe: empty .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockResolvedValueOnce('apt-get\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/) }) it('throws when the path-probe exec fails and the login shell finds nothing', async () => { @@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => { .mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors .mockResolvedValueOnce('/bin/zsh') // $SHELL .mockResolvedValueOnce('\n') // command -v node: empty + .mockRejectedValueOnce(new Error('package manager probe failed')) - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/) }) it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { @@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('\n') + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => { ).rejects.toBe(sessionLimitError) }) + it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => { // Why: in concurrent bootstrap a sibling probe aborts this resolution via // the shared signal. Each strategy catch swallows the injected AbortError, @@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => { .mockResolvedValueOnce('/bin/bash') // $SHELL .mockResolvedValueOnce('/old/node2\n') // login shell .mockResolvedValueOnce('v6.17.0\n') // too old + .mockResolvedValueOnce('dnf\n') // package manager hint probe - await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/) + }) + + it('rejects a Windows node.exe below the minimum version', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths + .mockResolvedValueOnce('v16.20.2\n') // version check: too old + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow( + /winget install OpenJS\.NodeJS\.LTS/ + ) + }) + + it('does not stop Windows node discovery after the first existing candidate', async () => { + execCommandMock + .mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n') + .mockResolvedValueOnce('v16.20.2\n') + .mockResolvedValueOnce('v20.11.0\n') + + await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe( + 'C:/Program Files/nodejs/node.exe' + ) + + const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string) + expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/) }) }) diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index e4bb3181b79..52baa610d3f 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' -import { powerShellCommand } from './ssh-remote-powershell' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + buildPosixNodeInstallGuidance, + type RemoteNodeResolutionOptions +} from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' import { isSshSessionLimitError } from './ssh-session-limit-error' @@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 -type RemoteNodeResolutionOptions = { - rethrowSessionLimitErrors?: boolean - signal?: AbortSignal -} - export async function resolveRemoteNodePath( conn: SshConnection, host?: RemoteHostPlatform, @@ -47,7 +46,7 @@ export async function resolveRemoteNodePath( return loginShellPath } - throwNodeNotFound(options) + return throwNodeNotFound(conn, options) } // Probe the on-disk install directories of every common Node version manager @@ -91,6 +90,7 @@ for candidate in \\ "$HOME/.local/bin/node" \\ "$HOME/.fnm/aliases/default/bin/node" \\ "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ "$HOME/.local/share/mise/shims/node" \\ "$HOME/.local/share/mise/installs/node"/*/bin/node \\ "$HOME/.asdf/shims/node" \\ @@ -104,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script, { signal: options?.signal }) + const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -138,20 +138,21 @@ async function tryResolveViaLoginShell( // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). - const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const shellResult = await execCommand( + conn, + 'echo "${SHELL:-/bin/sh}"', + commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const shell = shellResult.trim().split('\n')[0] if (!shell) { return null } - const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), { - wrapCommand: false, - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, - signal: options?.signal - }) + const nodePath = await execCommand( + conn, + buildCommandInShell(shell, 'command -v node'), + commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options) + ) const candidate = nodePath.trim().split('\n')[0] if (!candidate) { return null @@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement( options?: RemoteNodeResolutionOptions ): Promise { try { - const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false, - signal: options?.signal - }) - const match = versionOutput.trim().match(/^v?(\d+)/) - if (!match) { - return false - } - const major = Number.parseInt(match[1]!, 10) - return major >= MIN_NODE_MAJOR + const versionOutput = await execCommand( + conn, + `${shellEscape(nodePath)} --version`, + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath( 'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }', 'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }', 'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }', + '$found = $false', 'foreach ($path in $paths) {', ' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {', ' Write-Output $path', - ' exit 0', + ' $found = $true', ' }', '}', + 'if ($found) { exit 0 }', "Write-Error 'Node.js not found'", 'exit 1' ].join('\n') try { - const result = await execCommand(conn, powerShellCommand(script), { - wrapCommand: false, - signal: options?.signal - }) - const nodePath = result.trim().split('\n')[0] - if (nodePath) { + const result = await execCommand( + conn, + powerShellCommand(script), + commandOptions({ wrapCommand: false }, options) + ) + for (const line of result.split('\n')) { + const nodePath = line.trim() + if (!nodePath) { + continue + } const normalized = normalizeWindowsRemotePath(nodePath) - console.log(`[ssh-relay] Found Windows node at: ${normalized}`) - return normalized + if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) { + console.log(`[ssh-relay] Found Windows node at: ${normalized}`) + return normalized + } } } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { @@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath( // Fall through to the shared error below. } - throwNodeNotFound(options) + throwWindowsNodeNotFound(options) } -function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never { - // Why: when a sibling bootstrap probe aborts this resolution via the shared - // signal, the strategy catch-blocks swallow the injected AbortError and fall - // through to here. Surfacing "Node.js not found" would launder a cancellation - // into a fatal non-session failure, defeating the concurrent-bootstrap - // sequential fallback. Re-raise the abort so callers classify it correctly. +async function windowsNodeMeetsVersionRequirement( + conn: SshConnection, + nodePath: string, + options?: RemoteNodeResolutionOptions +): Promise { + try { + const versionOutput = await execCommand( + conn, + powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`), + commandOptions({ wrapCommand: false }, options) + ) + return nodeVersionMeetsRequirement(versionOutput) + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + return false + } +} + +function nodeVersionMeetsRequirement(versionOutput: string): boolean { + const match = versionOutput.trim().match(/^v?(\d+)/) + if (!match) { + return false + } + const major = Number.parseInt(match[1]!, 10) + return major >= MIN_NODE_MAJOR +} + +async function throwNodeNotFound( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { + throwIfAborted(options) + const guidance = await buildPosixNodeInstallGuidance(conn, options) + throwIfAborted(options) + throw new Error(guidance) +} + +function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never { + throwIfAborted(options) + throw new Error( + [ + 'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.', + '', + 'Install Node.js 18+ on the remote host, then reconnect:', + ' winget install OpenJS.NodeJS.LTS', + ' choco install nodejs-lts', + '', + 'Verify the remote runtime before reconnecting:', + ' node --version # must be v18 or newer', + ' npm --version', + '', + 'If those package managers are unavailable, install an LTS release from https://nodejs.org/.' + ].join('\n') + ) +} + +function throwIfAborted(options?: RemoteNodeResolutionOptions): void { + // Why: strategy fallbacks intentionally swallow probe failures, but a shared + // bootstrap abort must stay an AbortError so callers can continue fallback. if (options?.signal?.aborted) { throw createSshOperationAbortError() } - throw new Error( - 'Node.js not found on remote host. Orca relay requires Node.js 18+. ' + - 'Install Node.js on the remote and try again.' - ) +} + +function signalOnlyOptions( + options?: RemoteNodeResolutionOptions +): { signal: AbortSignal } | undefined { + return options?.signal ? { signal: options.signal } : undefined +} + +type RemoteExecOptions = { + wrapCommand?: boolean + timeoutMs?: number + signal?: AbortSignal +} + +function commandOptions( + base: RemoteExecOptions, + options?: RemoteNodeResolutionOptions +): RemoteExecOptions { + return options?.signal ? { ...base, signal: options.signal } : base +} + +async function execCommandWithOptionalOptions( + conn: SshConnection, + command: string, + options?: { signal: AbortSignal } +): Promise { + return options ? execCommand(conn, command, options) : execCommand(conn, command) } From d6c96e77d2db1c6c0acc019bd526250ad580051b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:00:25 -0700 Subject: [PATCH 222/233] Surface detailed commit failure summaries in Create PR flow (#7401) Instead of showing a generic commit failure message during the Create PR intent flow, leverage `summarizeCommitFailure` to extract the underlying reason (such as pre-commit hook or lint failures) and present it clearly. Also introduce `commitErrorsRef` to reliably track and retrieve the most up-to-date commit errors across renders without stale closure issues. --- .../right-sidebar/SourceControl.tsx | 45 +++++++++++++------ ...urce-control-create-pr-intent-flow.test.ts | 20 +++++++++ .../source-control-create-pr-intent-flow.ts | 21 +++++++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 8 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 11803eee85e..85080732d4c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -254,6 +254,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, @@ -1027,6 +1028,7 @@ function SourceControlInner(): React.JSX.Element { loadSessionCommitDrafts() ) const commitDraftsRef = useRef(commitDrafts) + const commitErrorsRef = useRef>({}) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< Record @@ -1155,6 +1157,13 @@ function SourceControlInner(): React.JSX.Element { }, [] ) + const setCommitErrorForWorktree = useCallback( + (worktreeId: string, message: string | null): void => { + commitErrorsRef.current = { ...commitErrorsRef.current, [worktreeId]: message } + setCommitErrors((prev) => ({ ...prev, [worktreeId]: message })) + }, + [] + ) const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null @@ -1917,6 +1926,7 @@ function SourceControlInner(): React.JSX.Element { return changed ? next : prev } updateCommitDrafts((prev) => pruneRecord(prev)) + commitErrorsRef.current = pruneRecord(commitErrorsRef.current) setCommitErrors((prev) => pruneRecord(prev)) setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) @@ -2037,7 +2047,7 @@ function SourceControlInner(): React.JSX.Element { commitInFlightRef.current[target.worktreeId] = true setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) try { const commitResult = await commitRuntimeGit( { @@ -2050,10 +2060,7 @@ function SourceControlInner(): React.JSX.Element { message ) if (!commitResult.success) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: commitResult.error ?? 'Commit failed' - })) + setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed') return false } @@ -2071,7 +2078,7 @@ function SourceControlInner(): React.JSX.Element { } return writeCommitDraftForWorktree(prev, target.worktreeId, '') }) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) if (!options?.target) { void refreshActiveGitStatusAfterMutation() } @@ -2103,10 +2110,10 @@ function SourceControlInner(): React.JSX.Element { } return true } catch (error) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: error instanceof Error ? error.message : 'Commit failed' - })) + setCommitErrorForWorktree( + target.worktreeId, + error instanceof Error ? error.message : 'Commit failed' + ) return false } finally { setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false })) @@ -2122,6 +2129,7 @@ function SourceControlInner(): React.JSX.Element { compareBaseRef, grouped.staged.length, refreshActiveGitStatusAfterMutation, + setCommitErrorForWorktree, updateCommitDrafts, unresolvedConflicts.length, worktreePath @@ -3822,12 +3830,21 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } + const commitFailure = commitErrorsRef.current[token.worktreeId] ?? null setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', - 'Could not commit changes. Fix the issue, then retry Create PR.' - ) + message: getCreatePrIntentCommitFailureNoticeMessage(commitFailure, { + fallback: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', + 'Could not commit changes. Fix the issue, then retry Create PR.' + ), + withSummary: (summary) => + translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitBlockedSummary', + 'Commit blocked: {{value0}} Fix the issue, then retry Create PR.', + { value0: summary } + ) + }) }) return } diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 07bff95171e..74853dd4147 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -5,6 +5,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep @@ -241,4 +242,23 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('blocked') }) + + it('surfaces the commit failure summary in the Create PR intent notice', () => { + expect( + getCreatePrIntentCommitFailureNoticeMessage( + 'husky - pre-commit hook\neslint found 2 errors\nfull output' + ) + ).toBe('Commit blocked: Lint failed during commit. Fix the issue, then retry Create PR.') + + expect(getCreatePrIntentCommitFailureNoticeMessage(null)).toBe( + 'Could not commit changes. Fix the issue, then retry Create PR.' + ) + + expect( + getCreatePrIntentCommitFailureNoticeMessage('pre-commit hook failed', { + fallback: 'fallback', + withSummary: (summary) => `localized ${summary}` + }) + ).toBe('localized Pre-commit hook failed.') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 009315fb197..7e183122ec5 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -5,6 +5,7 @@ import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none' @@ -145,3 +146,23 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } + +export function getCreatePrIntentCommitFailureNoticeMessage( + commitError: string | null | undefined, + copy: { + fallback: string + withSummary: (summary: string) => string + } = { + fallback: 'Could not commit changes. Fix the issue, then retry Create PR.', + withSummary: (summary: string) => + `Commit blocked: ${summary} Fix the issue, then retry Create PR.` + } +): string { + const summary = commitError ? summarizeCommitFailure(commitError) : null + + if (!summary) { + return copy.fallback + } + + return copy.withSummary(summary) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3226d39b6f1..d07151569b4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value0}} commits ahead of {{value1}}", "c1a8f3e204": "1 commit behind {{value0}}", "d2b9g4f315": "{{value0}} commits behind {{value1}}", - "e9c2a5d416": "Even with {{value0}}" + "e9c2a5d416": "Even with {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b7bccb7e426..51f3c236b5d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value0}} commits por delante de {{value1}}", "c1a8f3e204": "1 commit por detrás de {{value0}}", "d2b9g4f315": "{{value0}} commits por detrás de {{value1}}", - "e9c2a5d416": "A la par con {{value0}}" + "e9c2a5d416": "A la par con {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2496debcd8c..be351905992 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value1}}より{{value0}}commits 進んでいます", "c1a8f3e204": "{{value0}}より1commit 遅れています", "d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています", - "e9c2a5d416": "{{value0}}と同じ状態" + "e9c2a5d416": "{{value0}}と同じ状態", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e774013d063..ef3d48a60bb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9232,7 +9232,8 @@ "b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음", "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", - "e9c2a5d416": "{{value0}}와 동일한 상태" + "e9c2a5d416": "{{value0}}와 동일한 상태", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 29d853b248b..5a539073c54 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9232,7 +9232,8 @@ "b715ef615b": "领先 {{value1}} {{value0}} 个提交", "c1a8f3e204": "落后 {{value0}} 1 个提交", "d2b9g4f315": "落后 {{value1}} {{value0}} 个提交", - "e9c2a5d416": "与 {{value0}} 同步" + "e9c2a5d416": "与 {{value0}} 同步", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。", From ec9efe4638d110ec3aa791ee29ba8cc508ac694e Mon Sep 17 00:00:00 2001 From: jeganathan I Date: Sun, 5 Jul 2026 12:31:30 +0530 Subject: [PATCH 223/233] fix: distinguish duplicate project names in picker (#6272) * fix: distinguish duplicate project names in picker * review: disambiguate duplicate project picker details Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../new-workspace/ProjectCombobox.test.tsx | 30 +++ src/renderer/src/hooks/useComposerState.ts | 5 +- ...new-workspace-duplicate-project-details.ts | 147 ++++++++++ .../lib/new-workspace-project-options.test.ts | 251 ++++++++++++++++++ .../src/lib/new-workspace-project-options.ts | 28 +- 5 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/lib/new-workspace-duplicate-project-details.ts diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 63e88f9e0d1..cb0309d3e6e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -138,4 +138,34 @@ describe('ProjectCombobox', () => { expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group') }) + + it('renders directory details for duplicate project names', () => { + const duplicateProjects: NewWorkspaceProjectOption[] = [ + { + kind: 'project', + id: 'project:merchant-a', + projectId: 'project:merchant-a', + displayName: 'merchant', + badgeColor: '#111111', + detail: '/workspace/storefront/merchant' + }, + { + kind: 'project', + id: 'project:merchant-b', + projectId: 'project:merchant-b', + displayName: 'merchant', + badgeColor: '#222222', + detail: '/workspace/admin/merchant' + } + ] + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('/workspace/storefront/merchant') + expect(container.textContent).toContain('/workspace/admin/merchant') + }) }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 33049241411..dc6e1aa1957 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -825,9 +825,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, eligibleRepos, - projectGroups + projectGroups, + hosts: hostOptions }), - [eligibleRepos, projectGroups, projectHostSetups, projects] + [eligibleRepos, hostOptions, projectGroups, projectHostSetups, projects] ) const selectedRepoSettings = useMemo(() => { if (!settings) { diff --git a/src/renderer/src/lib/new-workspace-duplicate-project-details.ts b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts new file mode 100644 index 00000000000..127b8b99812 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts @@ -0,0 +1,147 @@ +import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host' +import type { NewWorkspaceProjectOption } from './new-workspace-project-options' + +export type ProjectSetupDirectory = { + path: string + hostId: ExecutionHostId +} + +export type ProjectOptionDraft = Extract & { + detailSource: 'provider' | 'generic' +} + +type ProjectDirectoryDetailMode = 'path' | 'host-label' | 'host-id' + +function getRepeatedValues(values: readonly string[]): Set { + const seen = new Set() + const repeated = new Set() + for (const value of values) { + if (seen.has(value)) { + repeated.add(value) + continue + } + seen.add(value) + } + return repeated +} + +function getProjectDirectoryDetail( + directories: readonly ProjectSetupDirectory[], + hostLabelById: ReadonlyMap, + mode: ProjectDirectoryDetailMode +): string | null { + const detailByKey = new Map() + for (const directory of directories) { + const path = directory.path.trim() + if (!path) { + continue + } + const hostLabel = + hostLabelById.get(directory.hostId)?.trim() || getExecutionHostLabel(directory.hostId) + const detail = + mode === 'path' + ? path + : mode === 'host-id' + ? `${hostLabel} (${directory.hostId}) · ${path}` + : `${hostLabel} · ${path}` + const key = mode === 'path' ? path : `${directory.hostId}\0${path}` + detailByKey.set(key, detail) + } + + const distinctDetails = [...detailByKey.values()].sort() + if (distinctDetails.length === 0) { + return null + } + + const [firstDetail] = distinctDetails + return distinctDetails.length === 1 + ? firstDetail + : `${firstDetail} (+${distinctDetails.length - 1} more)` +} + +export function getDuplicateProjectDetailsById( + options: readonly ProjectOptionDraft[], + setupDirectoriesByProjectId: ReadonlyMap, + hostLabelById: ReadonlyMap +): Map { + const optionsByName = new Map() + for (const option of options) { + optionsByName.set(option.displayName, [ + ...(optionsByName.get(option.displayName) ?? []), + option + ]) + } + + const detailsById = new Map() + for (const sameNameOptions of optionsByName.values()) { + if (sameNameOptions.length < 2) { + continue + } + + const repeatedProviderDetails = getRepeatedValues( + sameNameOptions + .filter((option) => option.detailSource === 'provider') + .map((option) => option.detail) + ) + const ambiguousOptions = sameNameOptions.filter( + (option) => option.detailSource === 'generic' || repeatedProviderDetails.has(option.detail) + ) + if (ambiguousOptions.length === 0) { + continue + } + + const pathOnlyDetailsById = new Map() + for (const option of ambiguousOptions) { + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'path' + ) + if (detail) { + pathOnlyDetailsById.set(option.id, detail) + } + } + + const repeatedPathOnlyDetails = getRepeatedValues([...pathOnlyDetailsById.values()]) + const hostLabelDetailsById = new Map() + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail || !repeatedPathOnlyDetails.has(pathOnlyDetail)) { + continue + } + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-label' + ) + if (detail) { + hostLabelDetailsById.set(option.id, detail) + } + } + + const repeatedHostLabelDetails = getRepeatedValues([...hostLabelDetailsById.values()]) + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail) { + continue + } + let detail = pathOnlyDetail + if (repeatedPathOnlyDetails.has(pathOnlyDetail)) { + detail = hostLabelDetailsById.get(option.id) ?? pathOnlyDetail + if (repeatedHostLabelDetails.has(detail)) { + // Why: user-renamed hosts can share a label, so fall back to the + // stable host id only when the final visible detail still repeats. + detail = + getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-id' + ) ?? detail + } + } + detailsById.set(option.id, detail) + } + } + + return detailsById +} diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index d96cc14ba4a..67c25ab2598 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -108,6 +108,257 @@ describe('buildNewWorkspaceProjectOptions', () => { expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) }) + it('shows configured directories when project names are duplicated', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-a-setup', + projectId: 'project:merchant-a', + repoId: 'merchant-a-repo', + path: '/workspace/storefront/merchant' + }), + setup({ + id: 'merchant-b-setup', + projectId: 'project:merchant-b', + repoId: 'merchant-b-repo', + path: '/workspace/admin/merchant' + }) + ], + eligibleRepos: [repo('merchant-a-repo'), repo('merchant-b-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ + id: 'project:merchant-b', + detail: '/workspace/admin/merchant' + }), + expect.objectContaining({ + id: 'project:merchant-a', + detail: '/workspace/storefront/merchant' + }) + ]) + }) + + it('keeps provider details when duplicate project names are already distinguishable', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'github:contoso/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'contoso', repo: 'merchant' } + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'contoso-setup', + projectId: 'github:contoso/merchant', + repoId: 'contoso-repo', + path: '/workspace/contoso/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('contoso-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'acme/merchant', + 'contoso/merchant' + ]) + }) + + it('shows directory details for non-provider duplicates with different setup counts', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-single', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-multi', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-single-setup', + projectId: 'project:merchant-single', + repoId: 'merchant-single-repo', + path: '/workspace/single/merchant' + }), + setup({ + id: 'merchant-multi-local-setup', + projectId: 'project:merchant-multi', + repoId: 'merchant-multi-local-repo', + path: '/workspace/multi/local/merchant' + }), + setup({ + id: 'merchant-multi-remote-setup', + projectId: 'project:merchant-multi', + hostId: 'ssh:builder', + repoId: 'merchant-multi-remote-repo', + path: '/workspace/multi/remote/merchant' + }) + ], + eligibleRepos: [ + repo('merchant-single-repo'), + repo('merchant-multi-local-repo'), + repo('merchant-multi-remote-repo') + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/multi/local/merchant (+1 more)', + '/workspace/single/merchant' + ]) + }) + + it('keeps provider details while disambiguating non-provider duplicate names', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + repoId: 'merchant-local-repo', + path: '/workspace/local/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('merchant-local-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/local/merchant', + 'acme/merchant' + ]) + }) + + it('adds host labels when duplicate project directory details collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-remote', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + hostId: 'local', + repoId: 'merchant-local-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-remote-setup', + projectId: 'project:merchant-remote', + hostId: 'ssh:builder', + repoId: 'merchant-remote-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-local-repo'), repo('merchant-remote-repo')], + hosts: [ + { id: 'local', label: 'Local Mac' }, + { id: 'ssh:builder', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder · /workspace/merchant', + 'Local Mac · /workspace/merchant' + ]) + }) + + it('adds host ids when duplicate project host labels still collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-builder-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-builder-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-builder-a-setup', + projectId: 'project:merchant-builder-a', + hostId: 'ssh:builder-a', + repoId: 'merchant-builder-a-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-builder-b-setup', + projectId: 'project:merchant-builder-b', + hostId: 'ssh:builder-b', + repoId: 'merchant-builder-b-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-builder-a-repo'), repo('merchant-builder-b-repo')], + hosts: [ + { id: 'ssh:builder-a', label: 'Builder' }, + { id: 'ssh:builder-b', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder (ssh:builder-a) · /workspace/merchant', + 'Builder (ssh:builder-b) · /workspace/merchant' + ]) + }) + it('filters project options by display name and detail', () => { const options: NewWorkspaceProjectOption[] = [ { diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index cabbb4f4cf6..bfa47e2a63b 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -1,6 +1,11 @@ import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + getDuplicateProjectDetailsById, + type ProjectSetupDirectory +} from './new-workspace-duplicate-project-details' export const NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX = 'project-group:' export const NEW_WORKSPACE_FOLDER_SOURCE_OPTION_PREFIX = 'folder-source:' @@ -45,6 +50,7 @@ type BuildNewWorkspaceProjectOptionsInput = { projects: readonly Project[] projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] + hosts?: readonly Pick[] } type BuildNewWorkspaceCreateTargetOptionsInput = BuildNewWorkspaceProjectOptionsInput & { @@ -85,7 +91,9 @@ export function buildNewWorkspaceProjectOptions( const { eligibleRepos } = input const { projects, projectHostSetups } = getProjectModel(input) const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) const readySetupCountsByProjectId = new Map() + const setupDirectoriesByProjectId = new Map() for (const setup of projectHostSetups) { if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { @@ -95,9 +103,12 @@ export function buildNewWorkspaceProjectOptions( setup.projectId, (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 ) + const directories = setupDirectoriesByProjectId.get(setup.projectId) ?? [] + directories.push({ path: setup.path, hostId: setup.hostId }) + setupDirectoriesByProjectId.set(setup.projectId, directories) } - return projects + const options = projects .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) .map((project) => ({ kind: 'project' as const, @@ -105,8 +116,21 @@ export function buildNewWorkspaceProjectOptions( projectId: project.id, displayName: project.displayName, badgeColor: project.badgeColor, - detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0), + detailSource: project.providerIdentity ? ('provider' as const) : ('generic' as const) })) + + const duplicateProjectDetailsById = getDuplicateProjectDetailsById( + options, + setupDirectoriesByProjectId, + hostLabelById + ) + + return options + .map(({ detailSource: _detailSource, ...option }) => { + const directoryDetail = duplicateProjectDetailsById.get(option.id) + return directoryDetail ? { ...option, detail: directoryDetail } : option + }) .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) } From 85d555f3348532f1d95e1163cbeafc460c3dd8cf Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:04:24 +0200 Subject: [PATCH 224/233] fix(terminal): launch prior/default agent when a woken terminal can't resume (#6277) * fix(terminal): launch prior/default agent when a woken terminal can't resume Waking a sleeping worktree (or restoring terminals after an app restart) left an agent terminal as a BLANK shell while still showing the agent's icon: `buildColdRestoreAgentResumeStartup` returned null whenever the provider session couldn't be resumed, and the caller then spawned a bare shell. Now, when resume isn't possible, an agent terminal comes back as a FRESH session of the agent it previously ran (live status -> sleeping record -> persisted tab `launchAgent`); if that agent is unknown but the terminal was an agent terminal, the configured default agent is used. A genuine plain shell stays blank, and the existing resume path, SSH/remote, and WSL behavior are unchanged. Fixes #4557 Co-Authored-By: Claude Opus 4.8 (1M context) * test(terminal): assert default-agent cold restore is a fresh (non-resume) launch Addresses CodeRabbit review on #6277. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix cold restore agent fallback Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 281 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 165 ++++++++-- 2 files changed, 418 insertions(+), 28 deletions(-) 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 383d5d141f4..c8e90d3d6f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -136,7 +136,7 @@ type StoreState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }[] - sshConnectionStates: Map + sshConnectionStates: Map cacheTimerByKey: Record settings: { theme?: 'system' | 'dark' | 'light' @@ -5612,6 +5612,285 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + // #4557: an agent terminal that can't be resumed should come back as a fresh + // agent (prior agent, else default) instead of a blank shell. + const findReattachConnect = (transport: ReturnType) => { + const call = transport.connect.mock.calls.find( + (args) => (args[0] as { sessionId?: string })?.sessionId === 'lost-pty' + ) + return call?.[0] as + | { + command?: string + launchAgent?: string + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + env?: Record + } + | undefined + } + + const mountColdRestore = async ( + transport: ReturnType, + depsOverrides: Record = {} + ) => { + const { connectPanePty } = await import('./pty-connection') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: 'fresh-pty', coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }, + ...depsOverrides + }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + return deps + } + + it('cold-restores a fresh agent from the sleeping record when its session is unresumable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + // Agent known, but no resumable provider session id. + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(new RegExp(`^${UUID_RE}$`)) + }) + + it('cold-restores a fresh agent with the sleeping record launch config when resume is unavailable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: { codex: 'current-codex' }, + agentDefaultArgs: { codex: '--current-args' } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBe('sleeping-codex --stored-args') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.launchConfig).toMatchObject({ + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + }) + expect(reattach?.env).toMatchObject({ + CODEX_HOME: '/tmp/orca-stored-codex', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + + it('uses the known SSH remote platform when fresh-launching an unresumable agent', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude-agent-teams' }] + }, + repos: [{ id: 'repo1', connectionId: 'ssh-win' }], + sshConnectionStates: new Map([['ssh-win', { status: 'connected', remotePlatform: 'win32' }]]), + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^orca\.cmd claude-teams/) + expect(reattach?.command).not.toContain('orca-ide') + expect(reattach?.launchAgent).toBe('claude-agent-teams') + }) + + it('cold-restores the prior agent from the persisted tab launchAgent after a restart', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + // After a restart there is no live status nor sleeping record — only the + // persisted tab.launchAgent tells us this terminal ran an agent. + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain('claude') + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('claude') + }) + + it('cold-restores the default agent when an agent terminal has no recoverable agent', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: {}, + // A record exists (it was an agent terminal) but carries no usable agent id. + sleepingAgentSessionsByPaneKey: { + [paneKey]: { paneKey, tabId: 'tab-1', worktreeId: 'wt-1', capturedAt: 1, updatedAt: 1 } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('does not cold-restore a fresh agent from only a completed live status row', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'done task', + state: 'done', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + + it('ignores malformed sleeping launch config when a live resumable status exists', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'live task', + providerSession: { key: 'session_id', id: 'live-session' }, + state: 'working', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'malformed sleeping task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'malformed-sleeping-codex', + agentArgs: '', + agentEnv: {} + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain("'resume' 'live-session'") + expect(reattach?.command).not.toBe('malformed-sleeping-codex') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('leaves a plain shell blank on cold restore (no prior agent, no default)', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dffa30c47d8..8d009829be7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -148,16 +148,17 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { isResumableTuiAgent, normalizeAgentProviderSession, - type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { @@ -370,7 +371,9 @@ type FreshSpawnOptions = { } type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - agent: ResumableTuiAgent + // TuiAgent (not just ResumableTuiAgent): the fresh-launch fallback can spawn any + // agent the terminal previously ran, including ones without resume support. + agent: TuiAgent launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -1120,32 +1123,34 @@ export function connectPanePty( ) } ) + const legacyProviderSessionKeys = legacyMatches.map(([, record]) => { + const providerSession = normalizeAgentProviderSession(record.providerSession) + return providerSession + ? [record.worktreeId, record.agent, providerSession.key, providerSession.id].join('\0') + : null + }) const exactLegacyMatch = legacyMatches.find(([paneKey]) => { const legacy = parseLegacyNumericPaneKey(paneKey) return legacy?.numericPaneId === String(pane.id) }) const providerSessionKeys = new Set( - legacyMatches.map(([, record]) => - [ - record.worktreeId, - record.agent, - record.providerSession.key, - record.providerSession.id - ].join('\0') - ) + legacyProviderSessionKeys.filter((key): key is string => key !== null) ) const oldestLegacyMatch = legacyMatches .slice() .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] // Why: duplicate legacy aliases can point at one provider session; consume // the oldest capture as canonical and clear its aliases after resume. + const allLegacyMatchesHaveProviderSession = legacyProviderSessionKeys.every( + (key) => key !== null + ) const selectedLegacyMatch = exactLegacyMatch ?? - (providerSessionKeys.size === 1 - ? legacyMatches.length === 1 - ? legacyMatches[0] - : oldestLegacyMatch - : null) + (legacyMatches.length === 1 + ? legacyMatches[0] + : allLegacyMatchesHaveProviderSession && providerSessionKeys.size === 1 + ? oldestLegacyMatch + : null) if (!selectedLegacyMatch) { return null } @@ -1157,13 +1162,19 @@ export function connectPanePty( consumed: { paneKey: string; record: SleepingAgentSessionRecord } ): void => { state.clearSleepingAgentSession(consumed.paneKey) + const consumedSession = normalizeAgentProviderSession(consumed.record.providerSession) + // No provider session (e.g. a fresh-launch fallback record) means there is no + // shared session to alias, so there is nothing else to clear. + if (!consumedSession) { + return + } for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { if ( paneKey !== consumed.paneKey && record.worktreeId === consumed.record.worktreeId && record.agent === consumed.record.agent && - record.providerSession.key === consumed.record.providerSession.key && - record.providerSession.id === consumed.record.providerSession.id + record.providerSession?.key === consumedSession.key && + record.providerSession?.id === consumedSession.id ) { // Why: legacy pane aliases can leave multiple sleeping rows for one // provider session; once this pane resumes it, every alias is stale. @@ -3387,6 +3398,10 @@ export function connectPanePty( if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { return 'linux' } + const sshRemotePlatform = getTerminalPasteSshRemotePlatform(connectionId) + if (sshRemotePlatform) { + return sshRemotePlatform + } if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } @@ -3400,27 +3415,123 @@ export function connectPanePty( const entry = state.agentStatusByPaneKey[cacheKey] const sleepingRecordEntry = getSleepingRecordForPane(state) const sleepingRecord = sleepingRecordEntry?.record - const useLiveEntry = entry && entry.state !== 'done' - const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + const liveEntry = entry && entry.state !== 'done' ? entry : null + const useLiveEntry = Boolean(liveEntry) + + // When the provider session can't be resumed, an agent terminal should still + // come back as its agent (or the default), not a blank shell (#4557). A plain + // shell - no agent ever ran here - stays blank (buildFreshFallback returns null). + const buildFreshFallback = (): ColdRestoreAgentResumeStartup | null => { + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const liveAgent = + liveEntry && isTuiAgent(liveEntry.agentType) ? liveEntry.agentType : undefined + const priorAgent = + liveAgent ?? sleepingRecord?.agent ?? tab?.launchAgent ?? paneStartup?.launchAgent + const defaultPref = state.settings?.defaultTuiAgent + const defaultAgent = + defaultPref && + defaultPref !== 'blank' && + isTuiAgentEnabled(defaultPref, state.settings?.disabledTuiAgents) + ? defaultPref + : undefined + // Fall back to the default agent only when this was an active/sleeping + // agent terminal whose specific agent is unrecoverable - never for a + // genuine plain shell or a stale completed live row. + const fallbackAgent = priorAgent ?? (liveEntry || sleepingRecord ? defaultAgent : undefined) + if (!fallbackAgent) { + return null + } + const liveLaunchConfig = + liveEntry && liveAgent === fallbackAgent + ? state.getAgentLaunchConfigForStatusEntry(liveEntry) + : undefined + const fallbackLaunchConfig = + liveLaunchConfig ?? + (sleepingRecord?.agent === fallbackAgent ? sleepingRecord.launchConfig : undefined) ?? + (paneStartup?.launchAgent === fallbackAgent ? paneStartup.launchConfig : undefined) + if (fallbackLaunchConfig?.agentCommand?.trim()) { + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: fallbackLaunchConfig.agentCommand.trim(), + env: { + ...fallbackLaunchConfig.agentEnv, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: { + agentCommand: fallbackLaunchConfig.agentCommand.trim(), + agentArgs: fallbackLaunchConfig.agentArgs, + agentEnv: { ...fallbackLaunchConfig.agentEnv } + }, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared + // after the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + const freshPlan = buildAgentStartupPlan({ + agent: fallbackAgent, + prompt: '', + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentArgs + : resolveTuiAgentLaunchArgs(fallbackAgent, state.settings?.agentDefaultArgs), + agentEnv: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentEnv + : resolveTuiAgentLaunchEnv(fallbackAgent, state.settings?.agentDefaultEnv), + platform: getColdRestoreAgentResumePlatform(), + allowEmptyPromptLaunch: true + }) + if (!freshPlan) { + return null + } + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: freshPlan.launchCommand, + env: { + ...freshPlan.env, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: freshPlan.launchConfig, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared after + // the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + + const agent = liveEntry ? liveEntry.agentType : sleepingRecord?.agent if (!agent || !isResumableTuiAgent(agent)) { - return null + return buildFreshFallback() } const providerSession = normalizeAgentProviderSession( - useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + liveEntry ? liveEntry.providerSession : sleepingRecord?.providerSession ) if (!providerSession) { - return null + return buildFreshFallback() } + const sleepingProviderSession = normalizeAgentProviderSession(sleepingRecord?.providerSession) const matchingSleepingLaunchConfig = sleepingRecord?.launchConfig && (!useLiveEntry || (sleepingRecord.agent === agent && - sleepingRecord.providerSession.key === providerSession.key && - sleepingRecord.providerSession.id === providerSession.id)) + sleepingProviderSession?.key === providerSession.key && + sleepingProviderSession?.id === providerSession.id)) ? sleepingRecord.launchConfig : undefined const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? + (liveEntry ? state.getAgentLaunchConfigForStatusEntry(liveEntry) : undefined) ?? matchingSleepingLaunchConfig const resumePlatform = getColdRestoreAgentResumePlatform() const startupPlan = buildAgentResumeStartupPlan({ @@ -3439,7 +3550,7 @@ export function connectPanePty( platform: resumePlatform }) if (!startupPlan) { - return null + return buildFreshFallback() } const coldRestoreLaunchToken = createBrowserUuid() // Why: cold restore means the PTY process is gone but the agent provider From 9886eb507ad8ab42f75a0f728a502b288825f5e5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:15:30 -0700 Subject: [PATCH 225/233] Improve Chinese and Spanish translations (#7408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refine Spanish and Chinese locale translations - Update Spanish strings for developer tools, notifications, and session history scopes. - Standardize Chinese terminology by converting "令牌" to "token" and correcting color translations (e.g., "橙子" to "橙色"). - Adjust Chinese UI phrasing, such as mapping issue states from "进行中" to "开放" and "资源管���器" to "文件管理器". * test: update MR Open state filter expectation to 开放 The zh locale intentionally remapped the GitLab MR 'Open' filter from 进行中 to 开放; align the test expectation with the translation. Co-authored-by: Orca * Improve Chinese and Spanish translations - Translate "Current workspace" to Spanish. - Correct the translation of "Atlassian" and expand "PR" to "拉取请求" in Chinese locale. - Fix spacing around English terms like "token" in Chinese text in Chinese. --------- Co-authored-by: Orca --- .../smart-workspace-localized-options.test.ts | 2 +- src/renderer/src/i18n/locales/es.json | 70 +++---- src/renderer/src/i18n/locales/zh.json | 178 +++++++++--------- 3 files changed, 125 insertions(+), 125 deletions(-) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts index a30a0344bbb..601b838f649 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts @@ -52,7 +52,7 @@ describe('smart-workspace-localized-options', () => { await i18n.changeLanguage('zh') expect(getMrStateFilters().map((filter) => filter.label)).toEqual([ - '进行中', + '开放', '合并', '已关闭', '全部' diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 51f3c236b5d..07a1201658d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -651,8 +651,8 @@ "dev": "Dev Tools", "devDescription": "Herramientas solo para dev para probar estados de UI.", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlayground": "Panel de prueba de notificaciones", + "devSearchNotificationPlaygroundDescription": "Activa estados representativos de notificaciones y toasts en la interfaz.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -8673,34 +8673,34 @@ "advanced": "Avanzado" }, "DevToolsPane": { - "nativeActionLayoutCheck": "Native action layout check", - "secondary": "Secondary", - "secondaryActionClicked": "Secondary action clicked", - "primaryAction": "Primary action", - "primaryActionClicked": "Primary action clicked", + "nativeActionLayoutCheck": "Comprobación de diseño de acción nativa", + "secondary": "Secundario", + "secondaryActionClicked": "Acción secundaria pulsada", + "primaryAction": "Acción principal", + "primaryActionClicked": "Acción principal pulsada", "branchHasChanges": "la rama tiene cambios", - "viewChangesClicked": "View Changes clicked", - "forceDeleteClicked": "Force Delete clicked", - "devOnlyCallback": "Dev-only callback.", - "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", - "localCanaryBehind": "Local canary is behind origin/canary", - "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", - "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", - "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", - "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", - "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", - "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", - "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", - "notificationPlayground": "Notification playground", - "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "viewChangesClicked": "Ver cambios pulsado", + "forceDeleteClicked": "Forzar eliminación pulsado", + "devOnlyCallback": "Callback solo para desarrolladores.", + "infoToast": "Toast informativo", + "infoToastDescription": "Texto informativo largo sin acción explícita.", + "localCanaryBehind": "La rama canary local está detrás de origin/canary", + "successToast": "Toast de éxito", + "successToastDescription": "Estado de confirmación breve.", + "settingsSaved": "Ajustes guardados", + "shortSuccessCopy": "Texto de éxito breve.", + "errorToast": "Toast de error", + "errorToastDescription": "Texto de error largo sin acciones de recuperación.", + "failedToSyncWorkspaceMetadata": "Error al sincronizar metadatos del espacio de trabajo", + "nativeActionToast": "Toast de acción nativa", + "nativeActionToastDescription": "Utiliza las ranuras de acción y cancelación de Sonner.", + "deleteFailureToast": "Toast de error al eliminar", + "deleteFailureToastDescription": "Pie personalizado con Ver y Forzar eliminación.", + "behindBaseRefToast": "Toast de retraso respecto a la rama base", + "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", + "notificationPlayground": "Área de prueba de notificaciones", + "notificationPlaygroundDescription": "Disparadores solo para desarrolladores para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", + "devOnly": "Solo desarrollo" }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -9622,7 +9622,7 @@ "refreshSessionHistory": "Actualizar historial de sesiones", "searchSessions": "Buscar sesiones", "clearSearch": "Limpiar búsqueda", - "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", + "remoteBrowseLocalHistory": "Los espacios de trabajo remotos pueden explorar el historial local. Las acciones de reanudación se ejecutan desde espacios de trabajo locales.", "transcriptsSkipped": "{{count}} transcripciones omitidas", "noAgentSessionsFound": "No se encontraron sesiones de agentes", "noSessionsMatchFilters": "Ninguna sesión coincide con los filtros actuales", @@ -9642,13 +9642,13 @@ "scanningSessions": "Buscando sesiones", "scopeAriaLabel": "Ámbito del historial de sesiones: {{value0}}", "currentWorkspaceLower": "workspace actual", - "currentWorktreeLower": "current worktree", + "currentWorktreeLower": "worktree actual", "allSessionsLower": "todas las sesiones", - "thisScope": "This", + "thisScope": "Este", "allScope": "Todo", - "scope": "Scope", - "currentWorkspace": "Current workspace", - "allSessions": "All sessions", + "scope": "Ámbito", + "currentWorkspace": "Espacio de trabajo actual", + "allSessions": "Todas las sesiones", "viewOptionsAriaLabel": "Opciones de vista del historial de sesiones", "viewOptions": "Opciones de vista", "agents": "Agentes", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5a539073c54..75667d4483e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20,10 +20,10 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "显示 Claude 令牌和成本使用情况。", - "codexToggleDescription": "显示 Codex 令牌和成本使用情况。", - "geminiToggleDescription": "显示 Gemini 令牌和成本使用情况。", - "opencodeGoToggleDescription": "显示 OpenCode Go 令牌和成本使用情况。", + "claudeToggleDescription": "显示当前工作区的 Claude token和成本使用情况。", + "codexToggleDescription": "显示当前工作区的 Codex token和成本使用情况。", + "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", + "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", @@ -46,7 +46,7 @@ "showStatusBar": "显示状态栏", "showTasksButton": "显示任务按钮", "showAutomationsButton": "显示自动化按钮", - "showMobileButton": "显示 Orca 手机端 按钮", + "showMobileButton": "显示 Orca 手机端按钮", "showTitlebarAppName": "显示标题栏应用名称", "view": "查看", "reload": "重新加载", @@ -141,7 +141,7 @@ "api": { "31bfe8ae1a": "在网络客户端中不可用。", "67ec964791": "Web 客户端不支持 Cookie 导入。", - "275a776357": "抓取元素在 Web 客户端中不可用。", + "275a776357": "悬停提取在 Web 客户端中不可用。", "8dfcb7a351": "选择屏幕截图在 Web 客户端中不可用。", "31bea294d5": "抓取模式在 Web 客户端中不可用。", "b8a1618172": "PR详细信息生成在 Web 客户端中不可用。", @@ -1395,13 +1395,13 @@ "TaskPage": { "513cddfa7a": "正在验证...", "ff69a30681": "取消", - "2abe22ef76": "您的令牌通过操作系统钥匙串加密并存储在本地。", + "2abe22ef76": "您的token通过操作系统钥匙串加密并存储在本地。", "246c2b3dd3": "Atlassian 账户设置", - "59c14d34a2": "创建一个令牌", - "b95623e93f": "Atlassian API 令牌", + "59c14d34a2": "创建一个token", + "b95623e93f": "Atlassian API token", "68df347677": "你@example.com", "163df31e0e": "https://example.atlassian.net", - "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API 令牌来浏览议题。", + "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API token来浏览议题。", "60f806ce99": "连接 Jira 站点", "8ff6fdc368": "创建中…", "fc0d8a1fa4": "提交。", @@ -1458,7 +1458,7 @@ "be8cf68d9f": "查看议题", "67662ade50": "项目议题", "6244a02f46": "在 Linear 中打开", - "606a85c774": "进行中", + "606a85c774": "开放", "5e8061b088": "从 {{value0}} {{value1}} 开始工作区", "592a55611b": "尝试选择更多的团队或者刷新;团队筛选条件适用于当前获取的议题集。", "618107fab3": "没有获取到与所选团队匹配的议题", @@ -1659,7 +1659,7 @@ "e104fa3d3d": "从议题开始工作区", "2193a99ec1": "打开议题所附的工作区", "7deb9e59a5": "更多 PR 操作", - "7753652524": "恢复", + "7753652524": "继续", "e4b29c5bcf": "从 PR 启动工作区", "67d881244c": "恢复关联 PR 的工作区", "6430594b18": "作者不详", @@ -1767,7 +1767,7 @@ "ac037cfac2": "手机", "75499e01d9": "关闭", "66b5a67bee": "Esc键", - "45def60329": "进行中", + "45def60329": "开放", "f65d992a11": "进入", "c5081f2814": "当前工作树", "52404f8096": "当前选项卡", @@ -1943,7 +1943,7 @@ "71fb69926c": "刷新", "a8fa0d2bf5": "刷新中", "fd15491034": "在 GitHub 中打开视图", - "22df63c393": "您的令牌无法获取子议题数据。", + "22df63c393": "您的token无法获取子议题数据。", "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", "1aa7c952b9": "项目视图", @@ -2040,7 +2040,7 @@ "e0002f1eba": "已选择", "2b2f019091": "任意状态", "0fd3249e2e": "已关闭", - "d78b60b5c2": "进行中", + "d78b60b5c2": "开放", "bd162b7d5a": "合并", "2e639b84fa": "评审人", "712c5abdbf": "标签", @@ -2502,7 +2502,7 @@ "88e34d00eb": "顶级会话分支在新工作区中打开", "fd3d12a1e1": "无法创建 fork 工作区。", "38e41edc6e": "该工作区无法分叉为 git 工作树。", - "f867385bb5": "找不到此分支的源工作区。", + "f867385bb5": "找不到此 fork 的源工作区。", "046e8d853c": "没有要 fork 的终端上下文", "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。", "f62b40e2c7": "没有可复制的终端上下文", @@ -2658,11 +2658,11 @@ "845576bed1": "青色", "be905e9b0a": "绿色的", "69682e2ce4": "黄色的", - "a47629b3cf": "橙子", + "a47629b3cf": "橙色", "620aec6729": "红色的", "03cf6dab1a": "粉色的", "c2d8b0991f": "紫色的", - "cb3eadefd2": "Blue", + "cb3eadefd2": "蓝色", "20baa43c05": "没有任何", "60f958ec75": "引脚标签", "417722e9c2": "取消固定标签", @@ -3109,7 +3109,7 @@ "61c58f8976": "缓存读取", "7d2efeff5e": "输出", "d7fb787e6b": "输入", - "a7902d3c1d": "令牌", + "a7902d3c1d": "token", "059945f71d": "按天汇总的输入、输出、缓存读取和缓存写入。", "c9f7cd30e9": "每日使用量" }, @@ -3129,15 +3129,15 @@ "7dc9e5613b": "按项目", "c3fdbc5474": "常用模型:", "0f394c24e3": "按模型", - "51ae85fa00": "缓存复用率 = 缓存读取令牌 /(输入令牌 + 缓存读取令牌)。", + "51ae85fa00": "缓存复用率 = 缓存读取token /(输入token + 缓存读取token)。", "b26d4ddb58": "预估 API 等效成本", "0f3e696ca9": "会话/轮次", "8cc23be4a3": "零缓存读取轮次", "1634c4f404": "缓存复用率", "b786fb4a70": "缓存写入", "268cf0af51": "缓存读取", - "2b8a2f14aa": "输出令牌", - "ea71fae8fc": "输入令牌", + "2b8a2f14aa": "输出token", + "ea71fae8fc": "输入token", "7dde9331fd": "此范围内尚无本地 Claude 使用记录。", "424cd50412": "启用 Claude 使用量分析", "8d18bbb771": "刷新", @@ -3147,7 +3147,7 @@ "dd29209b21": "筛选", "e9bf9fce0e": "Claude 使用量选项", "6afacbee37": "Claude 使用量跟踪", - "0cb1a36d7d": "读取本地 Claude 使用日志,显示令牌、模型和会话统计。", + "0cb1a36d7d": "读取本地 Claude 使用日志,显示token、模型和会话统计。", "5ce4842c2c": "所有本地 Claude 使用情况", "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", @@ -3163,7 +3163,7 @@ "c646e1783c": "缓存输入", "7b596a88b2": "输出", "99a91d3143": "输入", - "e4bdcf0071": "令牌", + "e4bdcf0071": "token", "c756cda6a8": "按天汇总的输入、缓存输入、输出和推理。", "609aa96e8b": "每日使用量" }, @@ -3183,13 +3183,13 @@ "b98718aaab": "按项目", "95d2d89285": "常用模型:", "5a0d1d69cd": "按模型", - "94ac1f1ee7": "显示推理令牌便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", + "94ac1f1ee7": "显示推理token便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", "1a18fbd56b": "预估 API 等效成本", "907b31865f": "会话/事件", "6e18146e9b": "推理输出", "a9ac0f423a": "缓存输入", - "5d8eba87bd": "输出令牌", - "e365eaa6fd": "输入令牌", + "5d8eba87bd": "输出token", + "e365eaa6fd": "输入token", "4c865393b4": "此范围内尚无本地 Codex 使用记录。", "f7c1affbd5": "启用 Codex 使用量分析", "3022cda443": "刷新", @@ -3199,12 +3199,12 @@ "1af1a39b2f": "筛选", "70b5b8581f": "Codex 使用量选项", "408210470c": "Codex 使用量跟踪", - "13badcd8f2": "读取本地 Codex 使用日志,显示令牌、模型和会话统计。", + "13badcd8f2": "读取本地 Codex 使用日志,显示token、模型和会话统计。", "4fe8820098": "所有本地 Codex 使用情况", "201766b754": "仅 Orca 工作树", "bf6cf2d4dd": "未知", "ae255c3dba": "不适用", - "247c93ca92": "• 推断定价", + "247c93ca92": "• 推理定价", "8a6655f7a2": "• 上次扫描错误:{{value0}}", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", @@ -3232,8 +3232,8 @@ "7e9433469a": "会话/活动", "5a65d68b77": "推理输出", "603504ee3b": "缓存输入", - "7aa4d8ce35": "输出令牌", - "d637a892ed": "输入令牌", + "7aa4d8ce35": "输出token", + "d637a892ed": "输入token", "bb6363e08c": "尚未找到此范围的本地 OpenCode 用法。", "f04131b3be": "启用 OpenCode 使用情况分析", "603cd138dc": "刷新", @@ -3243,7 +3243,7 @@ "01583b30aa": "筛选条件", "230d6de108": "OpenCode 使用选项", "bea80ceae0": "OpenCode 使用情况跟踪", - "b8b3522436": "读取本地 OpenCode 使用日志以显示令牌、模型和会话统计信息。", + "b8b3522436": "读取本地 OpenCode 使用日志以显示token、模型和会话统计信息。", "144a6050e9": "所有本地 OpenCode 使用情况", "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", @@ -3263,10 +3263,10 @@ }, "ShareUsageCard": { "4a4c6c79a3": "会话·", - "66c83284cf": "每日令牌", - "b760c0b622": "顶级模特", - "2d9eb39264": "令牌总数", - "beb6f24f37": "预计。成本", + "66c83284cf": "每日token", + "b760c0b622": "顶级模型", + "2d9eb39264": "token总数", + "beb6f24f37": "预计成本", "da62578d9d": "用法", "0eb31e79ee": "Orca IDE", "960324e9b8": "事件", @@ -3292,16 +3292,16 @@ "444585cb41": "有数据", "ecb0cd8a4c": "已启用 -", "33f7b043d2": "供应商", - "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入令牌日志后填充。", + "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入token日志后填充。", "70f36452d4": "缓存共享", "327603fe8b": "活跃天数", - "0eaf937335": "预计。成本", - "3887b94ce5": "令牌总数", + "0eaf937335": "预计成本", + "3887b94ce5": "token总数", "2d13e57f72": "启用OpenCode", "2f1ee2878b": "启用 Codex", "0ea0cae435": "启用 Claude", - "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的令牌账本。", - "49405ccc8d": "开始跟踪令牌", + "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的token账本。", + "49405ccc8d": "开始跟踪token", "ca6bc5fded": "刷新", "e06d1baf5c": "刷新使用概览", "c760c481c5": "使用概述", @@ -3349,23 +3349,23 @@ }, "sections": { "9564a3b21b": "会话 -", - "32330a6e66": "{{value0}}:{{value1}} 令牌", + "32330a6e66": "{{value0}}:{{value1}} token", "57d1448ef8": "启用", "f6df0d7d6d": "更多的", "1dd166c920": "较少的", - "52d9221dc0": "近期令牌活动热图", + "52d9221dc0": "近期token活动热图", "c424eb3f8e": "最好的:", - "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合令牌活动。", + "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合token活动。", "69e2b50427": "每日强度", - "3a795542fa": "综合令牌分布", + "3a795542fa": "综合token分布", "e65084cb4b": "推理", - "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存令牌。", - "4ff104da47": "令牌分布", + "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存token。", + "4ff104da47": "token分布", "0015facc1f": "缓存", "7f270458af": "输出", "9365b14a4e": "新输入", "3de9bf87fc": "还没有型号", - "6762f6a682": "令牌", + "6762f6a682": "token", "a7f937fb29": "{{value0}} 个会话 - {{value1}} {{value2}}", "c8f3a2d1e0b4": "轮次", "d9a4b3e2f1c5": "事件" @@ -3374,7 +3374,7 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• 推断价格" + "247c93ca92": "• 推理价格" }, "UsageSessionsTable": { "1afc25eb06": "轮次", @@ -3880,7 +3880,7 @@ "4afcca1d24": "重新连接", "11552bf786": "SSH 已断开", "cb5938ae79": "正在重新连接...", - "disconnected": "This remote repository is not connected.", + "disconnected": "此 SSH 主机未连接。", "reconnecting": "正在重新连接远程主机...", "reconnectionFailed": "重新连接到远程主机失败。", "authFailed": "远程主机身份验证失败。" @@ -3942,7 +3942,7 @@ "01f45d3d8a": "侧边栏", "93aebe4529": "文件夹", "0d224eff10": "主工作树", - "ca74db7550": "通过 SSH 远程项目", + "ca74db7550": "SSH 主机上的项目", "021538e1d1": "SSH 已断开连接", "runtimeHostDisconnected": "服务器已断开连接", "runtimeHostProject": "Orca 服务器上的项目", @@ -4197,7 +4197,7 @@ "669e12dd97": "本地文件夹和 Git 存储库", "ffc769ca29": "删除文件夹以添加项目", "740e8d0d46": "使用“添加项目”作为服务器路径", - "e344666fb8": "服务器运行时处于活动状态", + "e344666fb8": "服务器运行时处于激活状态", "d0f8943f8b": "准备项目添加流程", "18d3cf40e9": "检查文件夹" } @@ -4564,7 +4564,7 @@ "8951c5309f": "auth=Fe26.2**…", "338820326a": ")或完整 cookie 请求头(例如", "922b51e02d": "Fe26.2**…", - "0023cc336e": "粘贴原始令牌值(例如", + "0023cc336e": "粘贴原始token值(例如", "a7e38affcd": "Fe26.2**… token 或 auth=Fe26.2**… header", "67e3c33670": "OpenCode Go 会话 cookie", "b2b1aa936d": "粘贴您的 opencode.ai 会话 cookie 以进行速率限制获取。", @@ -4752,8 +4752,8 @@ "0cd9b8228f": "选择 Dock 和窗口切换器中显示的应用程序图标。", "ca1590d42f": "应用程序图标", "61d842eca0": "在侧边栏中显示 Orca 手机端 快捷方式。它仍然可以从工具箱中获取。", - "9da1020447": "显示 Orca 手机端 按钮", - "5db6ba961f": "在左侧边栏顶部显示 Orca 手机端 按钮。", + "9da1020447": "显示 Orca 手机端按钮", + "5db6ba961f": "在左侧边栏顶部显示 Orca 手机端按钮。", "fa882a3e6b": "在左侧边栏顶部显示“自动化”按钮。", "511f270ebb": "显示自动化按钮", "661942ab7f": "在左侧边栏顶部显示“任务”按钮。", @@ -5056,7 +5056,7 @@ "ComputerUsePane": { "1735461723": "使智能体能够检查和操作本地桌面应用程序。", "93255aaf18": "计算机控制技能", - "45f8e22c2e": "进行中", + "45f8e22c2e": "开放", "d95d1cfab8": "刷新", "0c29da5805": "就绪", "3383ea1aab": "无法重置计算机使用权限", @@ -5362,14 +5362,14 @@ "264a9b6128": "Linear", "4831ba1083": "重新检查", "01f6c7582e": "了解更多", - "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "5a1f86225a": "仅当 Orca 无法从远程获取 API URL 时。", "6193444689": "ORCA_GITEA_API_BASE_URL", "2c0330ec3e": "对于私有存储库,并设置", "e678d89e8c": "ORCA_GITEA_TOKEN", "d9467ab026": "公共仓库通过其 git 远程检测。设置", "4ab9b96925": "Gitea", - "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6f317f5132": "仅当 Orca 无法从 git 远程派生 API 基本 URL 时。", "ae6b7f5f40": "ORCA_AZURE_DEVOPS_API_BASE_URL", "67a9f26a80": "。设置", @@ -5378,7 +5378,7 @@ "5ee6ef6405": "ORCA_AZURE_DEVOPS_TOKEN", "4ee74d1470": "设置", "5efce6953d": "Azure DevOps", - "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查令牌和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查 token 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6e0ff3403e": "ORCA_BITBUCKET_ACCESS_TOKEN", "44cde4aa01": "ORCA_BITBUCKET_API_TOKEN", "a6c2816115": "和", @@ -5415,11 +5415,11 @@ "6355fe585e": "已检测的存储库的PR和 commit 状态", "1fac9b4910": "{{value0}} · PR和 commit 状态", "f92fbf11aa": "未配置", - "6791d7af95": "通过 Azure DevOps REST API 令牌PR和构建状态。", + "6791d7af95": "通过 Azure DevOps REST API token 获取拉取请求和构建状态。", "e3d5a24979": "已检测的 Azure Repos 的PR和构建状态", "277fc23929": "{{value0}} · PR和构建状态", "295154e54e": "已连接", - "0879860c58": "通过 Bitbucket Cloud API 令牌PR和构建状态。", + "0879860c58": "通过 Bitbucket Cloud API token 获取拉取请求和构建状态。", "9707523939": "PR和构建状态", "a565377c38": "未安装", "15cf990798": "未经过验证", @@ -5481,7 +5481,7 @@ }, "McpConfigFileRow": { "b145eb6009": "环境:", - "e720c139cd": "进行中", + "e720c139cd": "开放", "845ae248e8": "有效的" }, "McpConfigSection": { @@ -5568,7 +5568,7 @@ "254a6d09e4": "配对", "d7ce676270": "配对设备", "e778ecb209": "或者将此代码粘贴到手机应用程序中:", - "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备令牌。", + "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备token。", "870e1b5ca5": "撤销设备失败", "2e3dd0bc29": "设备已撤销", "711231348f": "复制配对码失败", @@ -5590,8 +5590,8 @@ "c8491c17ef": "通过扫描二维码从手机控制 Orca。 Beta/早期预览 - 预计会出现错误和重大更改。从以下位置获取 iOS 应用程序", "e7a3ae8c4e": "移动端", "174f4a3c6d": "通过手机控制终端和智能体。", - "1de96ec8a6": "显示 Orca 手机端 按钮", - "682293cadf": "在左侧边栏顶部显示 Orca 手机端 按钮。", + "1de96ec8a6": "显示 Orca 手机端按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", "d4f2b65f30": "在边栏中显示 Orca 手机端 快捷入口。" }, "NotificationsPane": { @@ -6878,7 +6878,7 @@ "appearance": { "search": { "468448bba4": "水彩", - "f586abfa35": "Blue", + "f586abfa35": "蓝色", "651f35b2c6": "切换器", "e5bc35d59e": "窗户", "d18b54ca90": "码头", @@ -6893,8 +6893,8 @@ "5bff6a2ef0": "侧边栏", "5e5b8878bf": "手机", "74618577c7": "移动端", - "682293cadf": "在左侧边栏顶部显示 Orca 手机端 按钮。", - "1de96ec8a6": "显示 Orca 手机端 按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", + "1de96ec8a6": "显示 Orca 手机端按钮", "4c920ab2d1": "日程", "58f4e22fa2": "自动化", "b186f3cefb": "自动化", @@ -7545,23 +7545,23 @@ "16a486a49d": "连接 Linear 以浏览和链接议题。", "b027b4b318": "Linear 集成", "20540996ef": "凭据", - "2ec2bd328c": "API 令牌", - "7345b7c3e6": "阿特拉斯语", + "2ec2bd328c": "API token", + "7345b7c3e6": "Atlassian", "e1263dd748": "Jira", - "76f6af7c57": "连接 Jira Cloud 或更新 Jira API 令牌凭据。", + "76f6af7c57": "连接 Jira Cloud 或更新 Jira API token 凭据。", "617603509b": "Jira 集成", "8c568d761c": "PR", "33180e8c10": "自托管", "129fc59aa8": "Gitea", - "d0d019dc29": "通过 API 令牌环境变量进行 Gitea 身份验证。", + "d0d019dc29": "通过 API token环境变量进行 Gitea 身份验证。", "aab86d64e5": "Gitea 集成", "03a7b275be": "ADO", "ed63380247": "天蓝色 repos", "b38b5d27f1": "天蓝色 devops", - "7b1f3984bb": "通过令牌环境变量进行 Azure DevOps Repos 身份验证。", + "7b1f3984bb": "通过token环境变量进行 Azure DevOps Repos 身份验证。", "af6611fa6e": "Azure DevOps 集成", "50d20817f7": "Bitbucket", - "c97d58a0f3": "通过 API 令牌环境变量进行 Bitbucket Cloud 身份验证。", + "c97d58a0f3": "通过 API token环境变量进行 Bitbucket Cloud 身份验证。", "67a2a0e868": "Bitbucket 集成", "371ee914d2": "合并请求", "581844769a": "MR", @@ -7578,15 +7578,15 @@ "jira": { "integration": { "card": { - "8ff73fef62": "Jira 令牌由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API 令牌。", + "8ff73fef62": "Jira token由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API token。", "9046a20d4c": "断开 {{value0}}", "eaffa454e9": "更新", "cec06a0f79": "测试...", "ab350991b8": "已验证", "d914d7ab70": "正在验证...", "5936977fcd": "取消", - "1666f8d562": "创建 Atlassian API 令牌", - "1ab7f551f3": "Atlassian API 令牌", + "1666f8d562": "创建 Atlassian API token", + "1ab7f551f3": "Atlassian API token", "09d310e42d": "你@example.com", "27dae4ab60": "https://example.atlassian.net", "a28f417220": "连接 Jira", @@ -8872,7 +8872,7 @@ "f61af83316": "新建文件夹", "37c875d827": "新文件", "b3e288bf41": "无法下载“{{value0}}”。", - "1a3df04ae1": "进行中", + "1a3df04ae1": "开放", "bce4d4e44f": "下载“{{value0}}”", "e26010014a": "被 .gitignore 忽略", "a06551beee": "进入", @@ -8890,8 +8890,8 @@ "FileExplorerToolbar": { "d238264654": "显示 Git 忽略的文件", "78f133232c": "显示点文件", - "31b4c3195d": "更多资源管理器操作", - "d95e30fe28": "刷新资源管理器", + "31b4c3195d": "更多文件管理器操作", + "d95e30fe28": "刷新文件管理器", "6026b16950": "全部折叠", "693cbeadd0": "搜索", "c1f3f3ec70": "搜索文件内容" @@ -10080,7 +10080,7 @@ "26824f60dd": "全部", "6fad211c66": "已关闭", "2319d87718": "合并", - "622864b52a": "进行中", + "622864b52a": "开放", "fda67f0b61": "当前项目", "3e8bb1176a": "在设置中连接 Linear 以搜索议题。", "69ce292138": "Linear", @@ -10579,7 +10579,7 @@ "TasksAnimatedVisual": { "efba6f77eb": "阅读议题 #", "b68c92fbdc": "启动工作区", - "4331c4d0f8": "进行中", + "4331c4d0f8": "开放", "b13375617e": "工作树选择器截断名称", "72f9e516a3": "紧迫", "fe47c9c9e8": "工作区准备就绪", @@ -11514,7 +11514,7 @@ "109a791e7b": "保存", "bb0a55f856": "保存…", "0203bed775": "取消", - "6978871a3d": "进行中", + "6978871a3d": "开放", "cce596969e": "删除注释", "cad3384faa": "编辑备注", "508ee678a5": "在浏览器中打开" @@ -11835,7 +11835,7 @@ "AutomationDetail": { "007c8ad874": "提示词", "a1d52c2189": "使用范围", - "449fc83bf7": "令牌", + "449fc83bf7": "token", "401f40ae79": "预计。花费", "a7c312430d": "最后一次运行", "2df8970cd5": "智能体", @@ -11915,7 +11915,7 @@ "AutomationRunHistory": { "402651bfb6": "还没有运行。", "9974a2b429": "状态", - "13988187b3": "令牌", + "13988187b3": "token", "86a248187e": "花费", "149c0b49c7": "工作区", "8faaa00726": "跑步", @@ -12192,14 +12192,14 @@ "4a2ab52781": "正在验证…", "79e7aaed39": "取消", "fdd26d81cc": "Atlassian 账户设置", - "8090504a3e": "在此创建令牌:", - "7b3967c12f": "Atlassian API 令牌", - "3d81bf3ab3": "API 令牌", + "8090504a3e": "在此创建 token:", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", - "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API 令牌来浏览议题。", + "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点" } } From 8dbe6bf372da2fa98844e6192c8d3b3f9b1534bc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:25:10 -0700 Subject: [PATCH 226/233] Improve agent history indentation and layout (#7412) * Hide redundant current worktree info in workspace vault scope When the AI Vault is scoped to the current workspace, showing the "Current worktree" status line or badge is redundant since the sessions are already filtered to this workspace. - Add helper to hide the worktree line and status badge when the vault is in 'workspace' scope and status is 'current'. - Improve alignment and metadata layout in session rows using CSS grid. - Update tests to verify worktree line and badge visibility rules. * Indent AI vault session worktree line Add left padding to the session worktree line to improve its visual indentation inside the AI vault session row. --- src/main/ipc/repos-remote.test.ts | 27 ++-- .../components/right-sidebar/AiVaultPanel.tsx | 1 + .../right-sidebar/AiVaultSessionDetails.tsx | 15 ++- .../right-sidebar/AiVaultSessionRow.tsx | 116 +++-------------- .../AiVaultSessionVirtualList.tsx | 11 +- .../ai-vault-session-row-display.tsx | 115 ++++++++++++++++ .../ai-vault-session-worktree-affordances.ts | 123 ++++++++++++++++++ .../ai-vault-session-worktree.test.ts | 22 ++++ .../ai-vault-session-worktree.ts | 107 ++------------- 9 files changed, 321 insertions(+), 216 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 2a9a66df94d..bd2541c7ebd 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => { // independent of which Promise in the Promise.all resolves first. type ExecResponse = { stdout: string; stderr: string } type ExecRule = { - match: (argv: string[]) => boolean + matches: (argv: string[]) => boolean respond: () => Promise } const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { return (argv: string[]) => { for (const rule of rules) { - if (rule.match(argv)) { + if (rule.matches(argv)) { return rule.respond() } } @@ -2438,17 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) } ]) @@ -2474,17 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.reject(new Error('relay exec failed')) } ]) @@ -2512,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ // symbolic-ref rejects (no origin/HEAD on the remote) - { match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, + { matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, // probe 1: refs/remotes/origin/main — rejects { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.reject(new Error('missing')) }, // probe 2: refs/remotes/origin/master — succeeds { - match: isRevParseFor('refs/remotes/origin/master'), + matches: isRevParseFor('refs/remotes/origin/master'), respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' }) }, - { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) } + { + matches: isRemoteList, + respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) + } ]) ) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 3fd7ae88809..8442e73f54e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -355,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} + vaultScope={scope} buildResumeStartup={buildResumeStartup} getSessionResumeState={getSessionResumeState} getSessionResumeActions={getSessionResumeActions} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index d5a538b44fd..85f5b2a5759 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { sessionDetailConversationTurns } from './ai-vault-session-display' import { aiVaultWorktreeCompactPath, aiVaultWorktreeStatusLabel, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -17,6 +18,7 @@ export function SessionInlineDetails({ id, session, worktreeInfo, + vaultScope, resumeActions, onResumeInWorktree, onResumeInNewTab, @@ -25,6 +27,7 @@ export function SessionInlineDetails({ id: string session: AiVaultSession worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope resumeActions: { worktree: { worktreeId: string | null; disabled: boolean } newTab: { worktreeId: string | null; disabled: boolean } @@ -78,7 +81,7 @@ export function SessionInlineDetails({ )} - {worktreeDisplay ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( } label={translate( @@ -86,7 +89,7 @@ export function SessionInlineDetails({ 'Worktree' )} > - + ) : null}
@@ -202,9 +205,11 @@ function ConversationTurnCard({ } function WorktreeMetadataLines({ - worktreeInfo + worktreeInfo, + vaultScope }: { worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope }): React.JSX.Element { const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) const pathLine = @@ -214,7 +219,7 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 0826eed9509..b0d88016327 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,37 +1,36 @@ import { useCallback } from 'react' import type React from 'react' import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' -import { Badge } from '@/components/ui/badge' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import { useRepoById } from '@/store/selectors' -import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' +import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { - aiVaultWorktreeStatusLabel, - shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + conversationRoleLabel, + getSessionDetailsId, + SessionMetadata, + SessionWorktreeLine +} from './ai-vault-session-row-display' export function VaultSessionRow({ session, resumeStartup, worktreeInfo, + vaultScope, detailsExpanded, resumeDisabled, onToggleDetails, @@ -53,6 +52,7 @@ export function VaultSessionRow({ session: AiVaultSession resumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void @@ -154,9 +154,9 @@ export function VaultSessionRow({ onOpenCwd={onOpenCwd} />
- {detailsExpanded && worktreeInfo ? ( + {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
- +
) : null} {!detailsExpanded ? ( @@ -180,6 +180,7 @@ export function VaultSessionRow({ session={session} updatedAt={updatedAt} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} /> ) : null} @@ -188,6 +189,7 @@ export function VaultSessionRow({ id={detailsId} session={session} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} @@ -216,91 +218,3 @@ export function VaultSessionRow({ ) } - -function getSessionDetailsId(sessionId: string): string { - return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` -} - -function SessionMetadata({ - session, - updatedAt, - worktreeInfo -}: { - session: AiVaultSession - updatedAt: string - worktreeInfo: AiVaultSessionWorktreeInfo | null -}) { - return ( -
-
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - -
- {worktreeInfo ? : null} -
- ) -} - -function SessionWorktreeLine({ - worktreeInfo -}: { - worktreeInfo: AiVaultSessionWorktreeInfo -}): React.JSX.Element { - const repoId = worktreeInfo.worktreeId - ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) - : null - const repo = useRepoById(repoId) - - return ( -
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( - - {worktreeStatusLabel(worktreeInfo.status)} - - ) : null} - - - -
- ) -} - -function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { - return aiVaultWorktreeStatusLabel(status) -} - -function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { - if (role === 'user') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') - } - if (role === 'assistant') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') - } - if (role === 'tool') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') - } - if (role === 'system') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') - } - return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 3d2bdde0384..a8de4d84313 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,6 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -41,6 +41,7 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -63,6 +64,7 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -185,6 +187,7 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} + vaultScope={vaultScope} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getWorktreeInfo={getWorktreeInfo} @@ -217,6 +220,7 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -241,6 +245,7 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -266,8 +271,7 @@ function AiVaultVirtualRow({ const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op the "Current worktree" badge - // already conveys. + // worktree on screen — jumping there is a no-op. const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) const worktreeJumpId = showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) @@ -300,6 +304,7 @@ function AiVaultVirtualRow({ session={row.session} resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} detailsExpanded={expandedSessionIds.has(row.session.id)} resumeDisabled={resumeState?.blocked ?? true} resumeLabel={resumeLabel} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx new file mode 100644 index 00000000000..1d3972a6fac --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -0,0 +1,115 @@ +import type React from 'react' +import { Badge } from '@/components/ui/badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { AgentIcon } from '@/lib/agent-catalog' +import { useRepoById } from '@/store/selectors' +import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { SessionTime } from './AiVaultSessionDetails' +import { agentLabel } from './ai-vault-session-filters' +import { + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +export function getSessionDetailsId(sessionId: string): string { + return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` +} + +export function SessionMetadata({ + session, + updatedAt, + worktreeInfo, + vaultScope +}: { + session: AiVaultSession + updatedAt: string + worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope +}) { + return ( +
+ + + +
+ {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( +
+ +
+ ) : null} +
+ ) +} + +export function SessionWorktreeLine({ + worktreeInfo, + vaultScope +}: { + worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { + return aiVaultWorktreeStatusLabel(status) +} + +export function conversationRoleLabel( + role: AiVaultSession['previewMessages'][number]['role'] +): string { + if (role === 'user') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') + } + if (role === 'assistant') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') + } + if (role === 'tool') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') + } + if (role === 'system') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') + } + return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts new file mode 100644 index 00000000000..6551d961bd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts @@ -0,0 +1,123 @@ +import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultScope } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import type { + AiVaultSessionWorktreeInfo, + AiVaultSessionWorktreeStatus +} from './ai-vault-session-worktree' + +export function canJumpToAiVaultSessionWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return Boolean( + worktreeInfo?.worktreeId && + worktreeInfo.status !== 'archived' && + worktreeInfo.status !== 'unavailable' + ) +} + +// Why: a session in the worktree you're already viewing has nowhere to jump, +// so we hide the affordance rather than offering a self-jump (the "Current +// worktree" badge already signals where it lives). +export function isAiVaultSessionInCurrentWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return worktreeInfo?.status === 'current' +} + +export function aiVaultWorktreeJumpTooltip( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): string { + if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', + 'Jump to Worktree' + ) + } + if (!worktreeInfo) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', + 'No worktree was recorded for this session.' + ) + } + if (worktreeInfo.status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', + 'This session is in an archived worktree.' + ) + } + if (worktreeInfo.status === 'unavailable') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', + 'No active worktree matches this session.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', + 'No active worktree is available.' + ) +} + +export function aiVaultWorktreeCompactPath(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function shouldShowAiVaultSessionWorktreeLine( + worktreeInfo: AiVaultSessionWorktreeInfo | null, + options?: { vaultScope?: AiVaultScope } +): worktreeInfo is AiVaultSessionWorktreeInfo { + if (!worktreeInfo) { + return false + } + // Why: workspace scope already limits history to the active workspace; the + // worktree row adds no value when the session lives in the worktree on screen. + if (options?.vaultScope === 'workspace' && worktreeInfo.status === 'current') { + return false + } + return true +} + +export function shouldShowAiVaultWorktreeStatusBadge( + status: AiVaultSessionWorktreeStatus, + options?: { vaultScope?: AiVaultScope } +): boolean { + // Why: "active" repeats the branch label without adding scan value in dense rows. + if (status === 'active') { + return false + } + // Why: workspace scope already filters to the active workspace, so "Current + // worktree" is redundant in the default history view. + if (status === 'current' && options?.vaultScope === 'workspace') { + return false + } + return true +} + +export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { + if (status === 'current') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', + 'Current worktree' + ) + } + if (status === 'active') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', + 'Active worktree' + ) + } + if (status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', + 'Archived worktree' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', + 'Unavailable worktree' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 58687977a33..56ba2f246f7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -10,6 +10,7 @@ import { resolveAiVaultSessionWorktreeDisplay, resolveAiVaultSessionWorktreeInfo, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -269,6 +270,21 @@ describe('aiVaultWorktreeCompactPath', () => { }) }) +describe('shouldShowAiVaultSessionWorktreeLine', () => { + it('hides the worktree row for the current worktree in workspace scope', () => { + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'workspace' }) + ).toBe(false) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'all' }) + ).toBe(true) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('active'), { vaultScope: 'workspace' }) + ).toBe(true) + expect(shouldShowAiVaultSessionWorktreeLine(null, { vaultScope: 'workspace' })).toBe(false) + }) +}) + describe('shouldShowAiVaultWorktreeStatusBadge', () => { it('hides the generic active badge but keeps meaningful states', () => { expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false) @@ -276,6 +292,12 @@ describe('shouldShowAiVaultWorktreeStatusBadge', () => { expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true) expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true) }) + + it('hides the current badge in workspace scope', () => { + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'workspace' })).toBe(false) + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'all' })).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('archived', { vaultScope: 'workspace' })).toBe(true) + }) }) describe('aiVaultWorktreeJumpTooltip', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index 31222bec1b5..670e2deecc1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -10,12 +10,21 @@ import { import { isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo, Worktree } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import { aiVaultWorktreeCompactPath } from './ai-vault-session-worktree-affordances' + +export { + aiVaultWorktreeCompactPath, + aiVaultWorktreeJumpTooltip, + aiVaultWorktreeStatusLabel, + canJumpToAiVaultSessionWorktree, + isAiVaultSessionInCurrentWorktree, + shouldShowAiVaultSessionWorktreeLine, + shouldShowAiVaultWorktreeStatusBadge +} from './ai-vault-session-worktree-affordances' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -155,58 +164,6 @@ export function useAiVaultSessionWorktreeMap({ ) } -export function canJumpToAiVaultSessionWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return Boolean( - worktreeInfo?.worktreeId && - worktreeInfo.status !== 'archived' && - worktreeInfo.status !== 'unavailable' - ) -} - -// Why: a session in the worktree you're already viewing has nowhere to jump, -// so we hide the affordance rather than offering a self-jump (the "Current -// worktree" badge already signals where it lives). -export function isAiVaultSessionInCurrentWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return worktreeInfo?.status === 'current' -} - -export function aiVaultWorktreeJumpTooltip( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): string { - if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', - 'Jump to Worktree' - ) - } - if (!worktreeInfo) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', - 'No worktree was recorded for this session.' - ) - } - if (worktreeInfo.status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', - 'This session is in an archived worktree.' - ) - } - if (worktreeInfo.status === 'unavailable') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', - 'No active worktree matches this session.' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', - 'No active worktree is available.' - ) -} - function buildWorktreeCandidates( worktrees: readonly Worktree[], repos: readonly Pick[] @@ -270,21 +227,6 @@ function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandi return left.source === 'current-path' ? -1 : 1 } -export function aiVaultWorktreeCompactPath(pathValue: string): string { - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - -export function shouldShowAiVaultWorktreeStatusBadge( - status: AiVaultSessionWorktreeStatus -): boolean { - // Why: "active" repeats the branch label without adding scan value in dense rows. - return status !== 'active' -} - function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo { return { status: 'unavailable', @@ -296,28 +238,3 @@ function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo function compactPathLabel(pathValue: string): string { return aiVaultWorktreeCompactPath(pathValue) } - -export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { - if (status === 'current') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', - 'Current worktree' - ) - } - if (status === 'active') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', - 'Active worktree' - ) - } - if (status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', - 'Archived worktree' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', - 'Unavailable worktree' - ) -} From a5cdb7711d5de7eb2d9a7f99e7087eaab3c4743b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:15:16 -0700 Subject: [PATCH 227/233] Add MiniMax rate-limit tracking and secure cookie storage (#7411) * Add MiniMax rate-limit tracking and secure cookie storage * Securely store MiniMax session cookies using an encrypted envelope format and local file hardening. * Fetch rate limits in an isolated session partition and clear the cookie jar before and after requests to prevent leakage. * Add a default-on "minimax" status bar item to display subscription usage. * Expose minimax configuration settings (group ID and models) in settings panes and sync them via the runtime client. * Isolate MiniMax config resolver and decryption failures from affecting other rate-limit providers. * Redact MiniMax secrets with whitespace around colons Update redactMiniMaxSecret to allow and match optional whitespace surrounding the colon when redacting quoted cookie values. This matches the spacing tolerance used during parsing. * Address PR review: harden cookie read, validate IPC, add tests Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/minimax-credentials.test.ts | 52 +++++++- src/main/ipc/minimax-credentials.ts | 33 ++++- src/main/minimax/minimax-cookie-store.test.ts | 100 +++++++++++--- src/main/minimax/minimax-cookie-store.ts | 125 ++++++++++++++++-- src/main/rate-limits/minimax-fetcher.test.ts | 77 +++++++++++ src/main/rate-limits/minimax-fetcher.ts | 17 ++- .../minimax-request-context.test.ts | 104 ++++++++++++++- .../rate-limits/minimax-request-context.ts | 97 +++++++++----- src/main/rate-limits/service.test.ts | 52 ++++++++ src/main/rate-limits/service.ts | 77 +++++++++-- src/main/runtime/orca-runtime.test.ts | 35 +++-- src/main/runtime/orca-runtime.ts | 12 +- .../runtime/rpc/methods/client-ui.test.ts | 16 ++- src/main/runtime/rpc/methods/client-ui.ts | 17 ++- .../src/components/settings/AccountsPane.tsx | 39 +++--- .../settings/AppearancePane.test.tsx | 29 +++- .../AppearanceWindowSidebarSection.tsx | 9 +- .../appearance-status-bar-search.test.ts | 33 +++++ .../settings/appearance-status-bar-search.ts | 35 +++++ .../src/components/status-bar/StatusBar.tsx | 2 + src/renderer/src/i18n/locales/en.json | 16 ++- src/renderer/src/i18n/locales/es.json | 20 ++- src/renderer/src/i18n/locales/ja.json | 44 +++--- src/renderer/src/i18n/locales/ko.json | 44 +++--- src/renderer/src/i18n/locales/zh.json | 54 ++++---- src/renderer/src/store/slices/ui.test.ts | 16 ++- src/renderer/src/store/slices/ui.ts | 16 ++- src/renderer/src/web/web-preload-api.test.ts | 112 ++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 23 ++++ src/shared/types.ts | 2 + 30 files changed, 1093 insertions(+), 215 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-status-bar-search.test.ts diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts index b34d85ad176..77e6f592de0 100644 --- a/src/main/ipc/minimax-credentials.test.ts +++ b/src/main/ipc/minimax-credentials.test.ts @@ -15,6 +15,7 @@ vi.mock('electron', () => ({ const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn()) const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false)) +const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve())) vi.mock('../minimax/minimax-cookie-store', () => ({ saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock, @@ -22,16 +23,26 @@ vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock })) +vi.mock('../rate-limits/minimax-request-context', () => ({ + clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock +})) + import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' import type { RateLimitService } from '../rate-limits/service' import type { RateLimitState } from '../../shared/rate-limit-types' function makeRefreshMock(): { refresh: ReturnType - service: Pick + invalidateMiniMaxCredentialState: ReturnType + service: Pick } { const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) - return { refresh, service: { refresh } } + const invalidateMiniMaxCredentialState = vi.fn() + return { + refresh, + invalidateMiniMaxCredentialState, + service: { refresh, invalidateMiniMaxCredentialState } + } } async function invoke(channel: string, ...args: unknown[]): Promise { @@ -47,6 +58,8 @@ describe('registerMiniMaxCredentialsHandlers', () => { ipcState.handleHandlers.clear() saveMiniMaxSessionCookieMock.mockReset() clearMiniMaxSessionCookieMock.mockReset() + clearMiniMaxSessionCookieJarMock.mockReset() + clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined) hasMiniMaxSessionCookieMock.mockReset() hasMiniMaxSessionCookieMock.mockReturnValue(false) }) @@ -81,12 +94,13 @@ describe('registerMiniMaxCredentialsHandlers', () => { }) it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => { - const { refresh, service } = makeRefreshMock() + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() registerMiniMaxCredentialsHandlers(service as RateLimitService) await invoke('minimaxCredentials:saveCookie', '_token=abc') // Why: the save handler is fire-and-forget — wait a microtask cycle so // the queued `void rateLimits?.refresh()` resolves before we assert. await new Promise((resolve) => setImmediate(resolve)) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) expect(refresh).toHaveBeenCalledTimes(1) }) @@ -96,24 +110,50 @@ describe('registerMiniMaxCredentialsHandlers', () => { }) it('clears the cookie and triggers a refresh on clearCookie', async () => { - const { refresh, service } = makeRefreshMock() + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) registerMiniMaxCredentialsHandlers(service as RateLimitService) const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) + expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1) expect(status).toEqual({ configured: false }) await new Promise((resolve) => setImmediate(resolve)) expect(refresh).toHaveBeenCalledTimes(1) }) + it('still refreshes and reports cleared when session jar cleanup rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock() + clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom')) + hasMiniMaxSessionCookieMock.mockReturnValueOnce(false) + registerMiniMaxCredentialsHandlers(service as RateLimitService) + + const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie') + + expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) + expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1) + expect(status).toEqual({ configured: false }) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to clear session cookie jar after credential clear'), + expect.any(Error) + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(refresh).toHaveBeenCalledTimes(1) + }) + it('logs but does not throw when the post-save rate-limit refresh rejects', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const refresh = vi.fn(() => Promise.reject(new Error('refresh boom'))) + const invalidateMiniMaxCredentialState = vi.fn() registerMiniMaxCredentialsHandlers({ - refresh - } as Pick as RateLimitService) + refresh, + invalidateMiniMaxCredentialState + } as Pick as RateLimitService) await invoke('minimaxCredentials:saveCookie', '_token=abc') await new Promise((resolve) => setImmediate(resolve)) + expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('failed to trigger rate-limit refresh after save'), expect.any(Error) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts index 9f773762151..97eefcd7116 100644 --- a/src/main/ipc/minimax-credentials.ts +++ b/src/main/ipc/minimax-credentials.ts @@ -4,6 +4,7 @@ import { hasMiniMaxSessionCookie, saveMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context' import type { RateLimitService } from '../rate-limits/service' export type MiniMaxCredentialsStatus = { @@ -14,20 +15,38 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { return { configured: hasMiniMaxSessionCookie() } } +// Why: fire-and-forget — callers get the persisted cookie status immediately; +// the rate-limit refresh runs in the background and only logs on failure. +function refreshAfterMiniMaxCredentialChange( + rateLimits: RateLimitService | null, + action: 'save' | 'clear' +): void { + rateLimits?.invalidateMiniMaxCredentialState() + void rateLimits?.refresh().catch((error: unknown) => { + console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error) + }) +} + export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus()) ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => { + // Validate the IPC argument in the main process; the renderer-declared type + // is compile-time only and the value arrives as unknown over IPC. + if (typeof cookie !== 'string') { + throw new Error('MiniMax session cookie must be a string') + } saveMiniMaxSessionCookie(cookie) - void rateLimits?.refresh().catch((error: unknown) => { - console.error('[minimax] failed to trigger rate-limit refresh after save:', error) - }) + refreshAfterMiniMaxCredentialChange(rateLimits, 'save') return getMiniMaxCredentialsStatus() }) - ipcMain.handle('minimaxCredentials:clearCookie', () => { + ipcMain.handle('minimaxCredentials:clearCookie', async () => { clearMiniMaxSessionCookie() - void rateLimits?.refresh().catch((error: unknown) => { - console.error('[minimax] failed to trigger rate-limit refresh after clear:', error) - }) + try { + await clearMiniMaxSessionCookieJar() + } catch (error) { + console.error('[minimax] failed to clear session cookie jar after credential clear:', error) + } + refreshAfterMiniMaxCredentialChange(rateLimits, 'clear') return getMiniMaxCredentialsStatus() }) } diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts index 436add5a854..4362dd2df93 100644 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -14,18 +14,16 @@ const electronMock = vi.hoisted(() => ({ vi.mock('electron', () => electronMock) const existsSyncMock = vi.fn() -const mkdirSyncMock = vi.fn() const readFileSyncMock = vi.fn() const rmSyncMock = vi.fn() -const writeFileSyncMock = vi.fn() +const hardenExistingSecureFileMock = vi.fn() +const writeSecureFileMock = vi.fn() const homedirMock = vi.fn(() => '/home/test') vi.mock('node:fs', () => ({ existsSync: existsSyncMock, - mkdirSync: mkdirSyncMock, readFileSync: readFileSyncMock, - rmSync: rmSyncMock, - writeFileSync: writeFileSyncMock + rmSync: rmSyncMock })) vi.mock('node:os', () => ({ @@ -36,7 +34,14 @@ vi.mock('node:path', () => ({ join: (...parts: string[]) => parts.join('/') })) +vi.mock('../../shared/secure-file', () => ({ + hardenExistingSecureFile: hardenExistingSecureFileMock, + writeSecureFile: writeSecureFileMock +})) + const storePath = '/home/test/.orca/minimax-session-cookie.enc' +const envelope = (kind: 'encrypted' | 'plaintext', value: string): string => + `orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}` async function loadStore(): Promise { return await import('./minimax-cookie-store') @@ -45,10 +50,10 @@ async function loadStore(): Promise { describe('minimax-cookie-store', () => { beforeEach(() => { existsSyncMock.mockReset() - mkdirSyncMock.mockReset() readFileSyncMock.mockReset() rmSyncMock.mockReset() - writeFileSyncMock.mockReset() + hardenExistingSecureFileMock.mockReset() + writeSecureFileMock.mockReset() safeStorageMock.isEncryptionAvailable.mockReset() safeStorageMock.encryptString.mockReset() safeStorageMock.decryptString.mockReset() @@ -65,6 +70,29 @@ describe('minimax-cookie-store', () => { existsSyncMock.mockReturnValue(false) const store = await loadStore() expect(store.hasMiniMaxSessionCookie()).toBe(false) + expect(hardenExistingSecureFileMock).not.toHaveBeenCalled() + }) + + it('hardens the cookie file when checking status for an existing cookie', async () => { + existsSyncMock.mockReturnValue(true) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(true) + expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath) + }) + + it('still reports an existing cookie when status-path hardening fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + existsSyncMock.mockReturnValue(true) + hardenExistingSecureFileMock.mockImplementation(() => { + throw new Error('permission denied') + }) + const store = await loadStore() + expect(store.hasMiniMaxSessionCookie()).toBe(true) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to harden MiniMax cookie file'), + expect.any(Error) + ) + warn.mockRestore() }) it('writes the cookie using safeStorage when encryption is available', async () => { @@ -72,12 +100,10 @@ describe('minimax-cookie-store', () => { const store = await loadStore() store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42') expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42') - expect(writeFileSyncMock).toHaveBeenCalledWith( + expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - Buffer.from('_token=abc; minimax_group_id_v2=42'), - { mode: 0o600 } + envelope('encrypted', '_token=abc; minimax_group_id_v2=42') ) - expect(mkdirSyncMock).toHaveBeenCalledWith('/home/test/.orca', { recursive: true }) }) it('warns and writes plaintext when safeStorage is unavailable', async () => { @@ -86,10 +112,7 @@ describe('minimax-cookie-store', () => { existsSyncMock.mockReturnValue(false) const store = await loadStore() store.saveMiniMaxSessionCookie('_token=abc') - expect(writeFileSyncMock).toHaveBeenCalledWith(storePath, '_token=abc', { - encoding: 'utf8', - mode: 0o600 - }) + expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) warn.mockRestore() }) @@ -101,14 +124,17 @@ describe('minimax-cookie-store', () => { it('reads decrypted cookie from disk and caches it', async () => { existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9') const store = await loadStore() const first = store.readMiniMaxSessionCookie() const second = store.readMiniMaxSessionCookie() expect(first).toBe('_token=cached; minimax_group_id_v2=9') expect(second).toBe(first) + expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1) + expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath) expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1) + expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload')) }) it('returns null when no file exists', async () => { @@ -117,17 +143,51 @@ describe('minimax-cookie-store', () => { expect(store.readMiniMaxSessionCookie()).toBeNull() }) - it('returns plaintext when safeStorage is unavailable and reads succeed', async () => { + it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) - readFileSyncMock.mockReturnValue(Buffer.from('_token=plaintext')) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext'))) const store = await loadStore() expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext') }) - it('throws when decryption fails', async () => { + it('reads legacy plaintext cookies when decrypting is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy')) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy') + }) + + it('reads legacy plaintext cookies when decrypting fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy')) + safeStorageMock.decryptString.mockImplementation(() => { + throw new Error('boom') + }) + const store = await loadStore() + expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy') + }) + + it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) existsSyncMock.mockReturnValue(true) readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload')) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('throws for encrypted envelopes when safeStorage is unavailable', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) + const store = await loadStore() + expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/) + }) + + it('throws when decryption fails', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockImplementation(() => { throw new Error('boom') }) @@ -137,7 +197,7 @@ describe('minimax-cookie-store', () => { it('clears the cached cookie and removes the file', async () => { existsSyncMock.mockReturnValueOnce(true) - readFileSyncMock.mockReturnValueOnce(Buffer.from('encrypted-payload')) + readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear') const store = await loadStore() expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear') diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 31102e4bbbe..3864f892bfb 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -1,10 +1,18 @@ import { safeStorage } from 'electron' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' +const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:' let cachedMiniMaxCookie: string | null = null +let warnedMiniMaxCookieStatusHardenFailure = false + +type MiniMaxCookieEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer +} function getOrcaDir(): string { return join(homedir(), '.orca') @@ -14,15 +22,94 @@ function getMiniMaxCookiePath(): string { return join(getOrcaDir(), MINIMAX_COOKIE_FILE) } -function ensureOrcaDir(): void { - const dir = getOrcaDir() - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) +function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string { + return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` +} + +function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null { + const text = raw.toString('utf8') + if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) { + return null + } + const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length) + const separator = rest.indexOf(':') + if (separator < 0) { + throw new Error('MiniMax session cookie could not be decrypted') + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error('MiniMax session cookie could not be decrypted') + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') } } +// Why: migrates cookies saved before the envelope format existed. Older files +// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content +// to tell the two apart rather than removing this as seemingly dead code. +function looksLikeCookieHeader(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index) + if (code < 32 || code === 127) { + return false + } + } + return ( + /^Cookie:\s*\S+/i.test(trimmed) || + /(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) || + /(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed) + ) +} + +function readEnvelope(envelope: MiniMaxCookieEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('MiniMax session cookie could not be decrypted') + } + return safeStorage.decryptString(envelope.payload) +} + +function readLegacyCookie(raw: Buffer): string { + if (safeStorage.isEncryptionAvailable()) { + try { + return safeStorage.decryptString(raw) + } catch { + const plaintext = raw.toString('utf8') + if (looksLikeCookieHeader(plaintext)) { + return plaintext + } + throw new Error('MiniMax session cookie could not be decrypted') + } + } + const plaintext = raw.toString('utf8') + if (looksLikeCookieHeader(plaintext)) { + return plaintext + } + throw new Error('MiniMax session cookie could not be decrypted') +} + export function hasMiniMaxSessionCookie(): boolean { - return existsSync(getMiniMaxCookiePath()) + const keyPath = getMiniMaxCookiePath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedMiniMaxCookieStatusHardenFailure) { + warnedMiniMaxCookieStatusHardenFailure = true + console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error) + } + } + return true } export function saveMiniMaxSessionCookie(cookie: string): void { @@ -30,14 +117,19 @@ export function saveMiniMaxSessionCookie(cookie: string): void { if (!trimmed) { throw new Error('MiniMax session cookie is required') } - ensureOrcaDir() if (safeStorage.isEncryptionAvailable()) { - writeFileSync(getMiniMaxCookiePath(), safeStorage.encryptString(trimmed), { mode: 0o600 }) + writeSecureFile( + getMiniMaxCookiePath(), + encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed)) + ) cachedMiniMaxCookie = trimmed return } console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext') - writeFileSync(getMiniMaxCookiePath(), trimmed, { encoding: 'utf8', mode: 0o600 }) + writeSecureFile( + getMiniMaxCookiePath(), + encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) + ) cachedMiniMaxCookie = trimmed } @@ -49,13 +141,20 @@ export function readMiniMaxSessionCookie(): string | null { if (!existsSync(keyPath)) { return null } + // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL + // failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie). + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error) + } try { const raw = readFileSync(keyPath) - cachedMiniMaxCookie = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) - : raw.toString('utf8') + const envelope = decodeCookieEnvelope(raw) + cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw) return cachedMiniMaxCookie - } catch { + } catch (error) { + console.error('[minimax] failed to decode/decrypt session cookie', error) throw new Error('MiniMax session cookie could not be decrypted') } } diff --git a/src/main/rate-limits/minimax-fetcher.test.ts b/src/main/rate-limits/minimax-fetcher.test.ts index 89c095de959..18221fd750c 100644 --- a/src/main/rate-limits/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax-fetcher.test.ts @@ -134,6 +134,42 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.error).toMatch(/500/) }) + it('aborts a hung fetch after the timeout and classifies it as network', async () => { + netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const abort = (): void => { + const error = new Error('The operation was aborted') + error.name = 'AbortError' + reject(error) + } + // The manual-cookie-header fallback receives the already-aborted signal. + if (init.signal.aborted) { + abort() + return + } + init.signal.addEventListener('abort', abort) + }) + }) + const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + await vi.advanceTimersByTimeAsync(15_000) + const result = await promise + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('network') + }) + + it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => { + netFetchMock + .mockRejectedValueOnce(new Error('cookie jar transport boom')) + .mockResolvedValueOnce(makeResponse(makeOkPayload(70))) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(netFetchMock).toHaveBeenCalledTimes(2) + // First (session-cookie-jar) transport sends no Cookie header; the fallback does. + expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined() + expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=') + }) + it('returns ok with session window mapping remaining to usedPercent', async () => { netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35))) const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) @@ -287,6 +323,20 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.error).toContain('unauth') }) + it('classifies malformed MiniMax JSON responses as parse failures', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token <') + } + } as unknown as Response) + const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE }) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toContain('Unexpected token') + }) + it('returns error when model_remains is empty', async () => { netFetchMock.mockResolvedValueOnce( makeResponse({ base_resp: { status_code: 0 }, model_remains: [] }) @@ -346,6 +396,33 @@ describe('fetchMiniMaxRateLimits', () => { expect(result.session?.usedPercent).toBe(60) }) + it('treats a blank model list as the default general model', async () => { + const payload = makeOkPayload(25) + ;(payload as { model_remains: unknown[] }).model_remains = [ + { + model_name: 'unrelated', + current_interval_remaining_percent: 10, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + }, + { + model_name: 'general', + current_interval_remaining_percent: 25, + start_time: Date.now() - 60_000, + end_time: Date.now() + 5 * 60 * 60 * 1000, + remains_time: 5 * 60 * 60 * 1000 + } + ] + netFetchMock.mockResolvedValueOnce(makeResponse(payload)) + const result = await fetchMiniMaxRateLimits({ + cookie: FULL_COOKIE, + models: ' ' + }) + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(75) + }) + it('redacts _token in any error path that includes payload text', async () => { const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } } netFetchMock.mockResolvedValueOnce(makeResponse(payload)) diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts index a04ba89db55..5f1c1445707 100644 --- a/src/main/rate-limits/minimax-fetcher.ts +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -81,13 +81,15 @@ function makeError( function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] { if (Array.isArray(models)) { - return models.map((model) => model.trim()).filter(Boolean) + const parsed = models.map((model) => model.trim()).filter(Boolean) + return parsed.length > 0 ? parsed : ['general'] } if (typeof models === 'string') { - return models + const parsed = models .split(',') .map((model) => model.trim()) .filter(Boolean) + return parsed.length > 0 ? parsed : ['general'] } return ['general'] } @@ -242,7 +244,13 @@ export async function fetchMiniMaxRateLimits( if (httpError) { return httpError } - const payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + let payload: MiniMaxUsageResponse + try { + payload = (await fetchResult.response.json()) as MiniMaxUsageResponse + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response' + return makeError(redactMiniMaxSecret(message), 'parse') + } const payloadError = handleMiniMaxPayloadError(fetchResult, payload) if (payloadError) { return payloadError @@ -268,8 +276,7 @@ export async function fetchMiniMaxRateLimits( } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error' - const failureKind = message.toLowerCase().includes('json') ? 'parse' : 'network' - return makeError(redactMiniMaxSecret(message), failureKind) + return makeError(redactMiniMaxSecret(message), 'network') } finally { clearTimeout(timeout) } diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts index 15caa55214a..9b01b4ff013 100644 --- a/src/main/rate-limits/minimax-request-context.test.ts +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + clearMiniMaxSessionCookieJar, extractMiniMaxCookieValue, fetchMiniMaxWithManualCookieHeader, fetchMiniMaxWithSessionCookieJar, @@ -107,6 +108,12 @@ describe('redactMiniMaxSecret', () => { expect(redacted).not.toContain('x.y') }) + it('redacts quoted values with whitespace around the colon', () => { + const redacted = redactMiniMaxSecret('_token : "spaced.secret"') + expect(redacted).not.toContain('spaced.secret') + expect(redacted).toContain('_token:[REDACTED]') + }) + it('redacts minimax_group_id_v2', () => { expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') }) @@ -171,7 +178,7 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { vi.restoreAllMocks() }) - it('uses a dedicated MiniMax partition and clears cookies before fetching', async () => { + it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -185,6 +192,50 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { signal: controller.signal }) expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + }) + + it('attempts the final session cleanup when the pre-fetch clear rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + clearStorageDataMock + .mockRejectedValueOnce(new Error('pre-clear boom')) + .mockResolvedValueOnce(undefined) + const controller = new AbortController() + + await expect( + fetchMiniMaxWithSessionCookieJar({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + ).rejects.toThrow('pre-clear boom') + + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, { + origin: 'https://platform.minimax.io', + storages: ['cookies'] + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(netFetchMock).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + }) + + it('clears the dedicated MiniMax partition on demand', async () => { + await clearMiniMaxSessionCookieJar() + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') expect(clearStorageDataMock).toHaveBeenCalledWith({ origin: 'https://platform.minimax.io', storages: ['cookies'] @@ -251,14 +302,22 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { describe('fetchMiniMaxWithManualCookieHeader', () => { beforeEach(() => { + clearStorageDataMock.mockClear() + cookiesSetMock.mockClear() netFetchMock.mockReset() + sessionFromPartitionMock.mockClear() + sessionFromPartitionMock.mockImplementation(() => ({ + clearStorageData: clearStorageDataMock, + cookies: { set: cookiesSetMock }, + fetch: netFetchMock + })) }) afterEach(() => { vi.restoreAllMocks() }) - it('attaches the full Cookie header and X-Group-Id via net.fetch', async () => { + it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -272,6 +331,9 @@ describe('fetchMiniMaxWithManualCookieHeader', () => { signal: controller.signal }) expect(result.transport).toBe('manual-cookie-header') + expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(cookiesSetMock).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledTimes(1) const [url, init] = netFetchMock.mock.calls[0] expect(url).toBe(MINIMAX_USAGE_ENDPOINT) @@ -296,6 +358,44 @@ describe('fetchMiniMaxWithManualCookieHeader', () => { const [, init] = netFetchMock.mock.calls[0] expect(init.headers['X-Group-Id']).toBeUndefined() }) + + it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => { + netFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] }) + }) + const controller = new AbortController() + await fetchMiniMaxWithManualCookieHeader({ + cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"', + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: null, + signal: controller.signal + }) + const [, init] = netFetchMock.mock.calls[0] + expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw') + }) + + it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + clearStorageDataMock + .mockRejectedValueOnce(new Error('manual pre-clear boom')) + .mockResolvedValueOnce(undefined) + const controller = new AbortController() + + await expect( + fetchMiniMaxWithManualCookieHeader({ + cookie: FULL_COOKIE, + endpoint: MINIMAX_USAGE_ENDPOINT, + groupId: '12345', + signal: controller.signal + }) + ).rejects.toThrow('manual pre-clear boom') + + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + expect(netFetchMock).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + }) }) describe('logMiniMaxFetchFailure', () => { diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts index 3953ab53e6f..34dcd47130b 100644 --- a/src/main/rate-limits/minimax-request-context.ts +++ b/src/main/rate-limits/minimax-request-context.ts @@ -1,4 +1,4 @@ -import { net, session } from 'electron' +import { session, type Session } from 'electron' export const MINIMAX_USAGE_ENDPOINT = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -26,6 +26,8 @@ export type MiniMaxFetchResponse = { transport: MiniMaxFetchTransport } +// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real +// per-platform Firefox UA instead of a custom agent string. Don't "clean up". function getMiniMaxBrowserUserAgent(): string { if (process.platform === 'win32') { return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0' @@ -83,7 +85,8 @@ export function redactMiniMaxSecret(value: string): string { for (const name of SENSITIVE_COOKIE_NAMES) { redacted = redacted .replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`) - .replace(new RegExp(`${name}:["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) + // Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too. + .replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`) } return redacted } @@ -101,6 +104,14 @@ export function makeMiniMaxRequestHeaders(groupId: string | null): Record { + await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) +} + +export async function clearMiniMaxSessionCookieJar(): Promise { + await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION)) +} + export async function fetchMiniMaxWithSessionCookieJar(args: { cookie: string endpoint: string @@ -109,28 +120,34 @@ export async function fetchMiniMaxWithSessionCookieJar(args: { }): Promise { const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) const cookiePairs = parseCookiePairs(args.cookie) - await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] }) - await Promise.all( - cookiePairs.map((pair) => - miniMaxSession.cookies.set({ - url: MINIMAX_ORIGIN, - name: pair.name, - value: pair.value, - secure: true, - path: '/' - }) + try { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession) + await Promise.all( + cookiePairs.map((pair) => + miniMaxSession.cookies.set({ + url: MINIMAX_ORIGIN, + name: pair.name, + value: pair.value, + secure: true, + path: '/' + }) + ) ) - ) - const headers = makeMiniMaxRequestHeaders(args.groupId) - return { - response: await miniMaxSession.fetch(args.endpoint, { - method: 'GET', - headers, - signal: args.signal - }), - requestHeaderNames: Object.keys(headers), - cookieNames: getUniqueMiniMaxCookieNames(args.cookie), - transport: 'session-cookie-jar' + const headers = makeMiniMaxRequestHeaders(args.groupId) + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'session-cookie-jar' + } + } finally { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => { + console.warn('[minimax] failed to clear session cookie jar after fetch', error) + }) } } @@ -140,19 +157,27 @@ export async function fetchMiniMaxWithManualCookieHeader(args: { groupId: string | null signal: AbortSignal }): Promise { - const headers = { - ...makeMiniMaxRequestHeaders(args.groupId), - Cookie: args.cookie - } - return { - response: await net.fetch(args.endpoint, { - method: 'GET', - headers, - signal: args.signal - }), - requestHeaderNames: Object.keys(headers), - cookieNames: getUniqueMiniMaxCookieNames(args.cookie), - transport: 'manual-cookie-header' + const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) + try { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession) + const headers = { + ...makeMiniMaxRequestHeaders(args.groupId), + Cookie: normalizeMiniMaxCookieHeader(args.cookie) + } + return { + response: await miniMaxSession.fetch(args.endpoint, { + method: 'GET', + headers, + signal: args.signal + }), + requestHeaderNames: Object.keys(headers), + cookieNames: getUniqueMiniMaxCookieNames(args.cookie), + transport: 'manual-cookie-header' + } + } finally { + await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => { + console.warn('[minimax] failed to clear session cookie jar after fetch', error) + }) } } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 796d7d516ef..082dcf282a9 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1124,6 +1124,42 @@ describe('RateLimitService', () => { expect(state.minimax?.session?.usedPercent).toBe(10) }) + it('does not apply an in-flight MiniMax result after credential invalidation', async () => { + const service = new RateLimitService() + const firstMiniMax = deferred() + const secondMiniMax = deferred() + service.setMiniMaxConfigResolver(() => ({ + sessionCookie: '_token=abc', + groupId: '', + models: 'general' + })) + vi.mocked(fetchMiniMaxRateLimits) + .mockImplementationOnce(() => firstMiniMax.promise) + .mockImplementationOnce(() => secondMiniMax.promise) + + const firstRefresh = service.refresh() + await Promise.resolve() + + service.invalidateMiniMaxCredentialState() + const queuedRefresh = service.refresh() + await Promise.resolve() + + firstMiniMax.resolve(okProvider('minimax', 50, Date.now())) + await Promise.resolve() + await Promise.resolve() + + expect(service.getState().minimax?.status).toBe('fetching') + expect(service.getState().minimax?.session).toBeNull() + + secondMiniMax.resolve(okProvider('minimax', 10, Date.now())) + await firstRefresh + await queuedRefresh + + const state = service.getState() + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2) + expect(state.minimax?.session?.usedPercent).toBe(10) + }) + it('isolates MiniMax failures from other providers', async () => { const service = new RateLimitService() service.setMiniMaxConfigResolver(() => ({ @@ -1141,4 +1177,20 @@ describe('RateLimitService', () => { expect(state.minimax?.error).toBe('minimax down') expect(state.claude?.status).toBe('ok') }) + + it('isolates MiniMax config resolver failures from other providers', async () => { + const service = new RateLimitService() + service.setMiniMaxConfigResolver(() => { + throw new Error('MiniMax session cookie could not be decrypted') + }) + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + + await service.refresh() + + const state = service.getState() + expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() + expect(state.minimax?.status).toBe('error') + expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted') + expect(state.claude?.status).toBe('ok') + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 7e2bf948148..d270c6773a8 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -51,6 +51,11 @@ type MiniMaxRateLimitConfig = { models: string } +type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + type GeminiCliOAuthEnabledResolver = () => boolean // Why: Claude's subscription usage endpoint has a tight request budget. Quota @@ -94,6 +99,10 @@ function isSystemDefaultClaudeAuth( return provenance === 'system' || Boolean(provenance?.endsWith(':system')) } +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -282,6 +291,16 @@ export class RateLimitService { return this.getState() } + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving or forgetting the browser cookie can race an in-flight usage + // fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + async refreshForCodexAccountChange( outgoingAccountId?: string | null, target?: CodexAccountSelectionTarget @@ -968,6 +987,42 @@ export class RateLimitService { return process.platform !== 'win32' } + private resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable browser cookie must not abort every provider's + // quota refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } + + private getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' @@ -1004,10 +1059,10 @@ export class RateLimitService { const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfig = this.miniMaxConfigResolver?.() - const miniMaxCookie = miniMaxConfig?.sessionCookie ?? '' - const miniMaxGroupId = miniMaxConfig?.groupId ?? '' - const miniMaxModels = miniMaxConfig?.models ?? 'general' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Detect if configuration changed — if it did, we must discard any stale @@ -1020,7 +1075,7 @@ export class RateLimitService { } const opencodeGeneration = this.opencodeFetchGeneration - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}` + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash if (miniMaxConfigChanged) { this.lastMiniMaxConfigHash = currentMiniMaxConfigHash @@ -1066,11 +1121,13 @@ export class RateLimitService { fetchGeminiRateLimits(geminiCliOAuthEnabled), fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), fetchKimiRateLimits(), - fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) ]) if (signal.aborted) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 5bc855ee7c4..4be25f39e2b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1228,27 +1228,33 @@ computeWorktreePathMock.mockImplementation( ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath) describe('OrcaRuntimeService', () => { - it('projects worktree card display settings to paired clients', () => { + it('projects runtime-backed settings to paired clients', () => { const runtime = new OrcaRuntimeService({ ...store, getSettings: () => ({ ...store.getSettings(), experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) } as never) expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) }) - it('accepts worktree card display setting updates from paired clients', () => { + it('accepts runtime-backed setting updates from paired clients', () => { let settings = { ...store.getSettings(), experimentalNewWorktreeCardStyle: false, - compactWorktreeCards: false + compactWorktreeCards: false, + minimaxGroupId: '', + minimaxUsageModels: 'general' } const updateSettings = vi.fn((updates: Partial) => { settings = { ...settings, ...updates } @@ -1263,19 +1269,30 @@ describe('OrcaRuntimeService', () => { expect( runtime.updateClientSettings({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) ).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) expect(updateSettings).toHaveBeenCalledWith( - { experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true }, + { + experimentalNewWorktreeCardStyle: true, + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + }, { notifyListeners: true } ) expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true, - compactWorktreeCards: true + compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12d6c3e9f37..053e2028750 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -796,6 +796,8 @@ type RuntimeStore = { githubProjects?: GlobalSettings['githubProjects'] experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] + minimaxGroupId?: GlobalSettings['minimaxGroupId'] + minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null @@ -2395,6 +2397,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') @@ -2414,7 +2418,9 @@ export class OrcaRuntimeService { defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, - compactWorktreeCards: settings.compactWorktreeCards === true + compactWorktreeCards: settings.compactWorktreeCards === true, + minimaxGroupId: settings.minimaxGroupId ?? '', + minimaxUsageModels: settings.minimaxUsageModels ?? 'general' } } @@ -2434,6 +2440,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > ): Pick< GlobalSettings, @@ -2451,6 +2459,8 @@ export class OrcaRuntimeService { | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' + | 'minimaxGroupId' + | 'minimaxUsageModels' > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4baba6aef9a..4c692264cc0 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -22,6 +22,8 @@ describe('client UI RPC methods', () => { defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', githubProjects: { pinned: [], recent: [], @@ -77,6 +79,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -91,6 +95,8 @@ describe('client UI RPC methods', () => { defaultTaskViewPreset: 'my-prs', experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true, + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5', defaultRepoSelection: settings.defaultRepoSelection, defaultLinearTeamSelection: ['team-1', 'team-2'], githubProjects: settings.githubProjects @@ -176,7 +182,10 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -212,7 +221,10 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex'], + statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a18dfb3bd0f..b5991f669e4 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -40,7 +40,17 @@ const WorktreeCardProperties = z .array(LegacyWorktreeCardProperty) .transform((value) => normalizeWorktreeCardProperties(value)) const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage']) +const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'opencode-go', + 'kimi', + 'minimax', + 'ssh', + 'resource-usage', + 'ports' +]) const WorkspaceStatusDefinition = z.object({ id: z.string(), label: z.string(), @@ -142,6 +152,8 @@ const SettingsUpdate = z defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), githubProjects: GitHubProjectSettings.optional() }) .strict() @@ -188,6 +200,9 @@ const UiUpdate = z _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index aac01de620f..993914c89ae 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -116,7 +116,7 @@ function MiniMaxCookieHelpPopover(): React.JSX.Element {

{translate( 'auto.components.settings.AccountsPane.4e32e030b2', - 'The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes.' + 'Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.' )}

@@ -340,7 +340,6 @@ export function AccountsPane({ const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) - const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) @@ -425,11 +424,18 @@ export function AccountsPane({ } setMiniMaxCredentialBusy(true) try { - const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft) + const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft.trim()) + if (!status.configured) { + throw new Error( + translate( + 'auto.components.settings.AccountsPane.8e6f0cb1d8', + 'MiniMax cookie was not saved.' + ) + ) + } setMiniMaxConfigured(status.configured) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') - void refreshRateLimits() toast.success( translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.') ) @@ -453,7 +459,6 @@ export function AccountsPane({ setMiniMaxConfigured(status.configured) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') - void refreshRateLimits() } catch (error) { toast.error( translate( @@ -1544,15 +1549,14 @@ export function AccountsPane({ />

- {translate( - 'auto.components.settings.AccountsPane.0b8c1c7e02', - miniMaxConfigured ? 'Encrypted locally' : 'Cookie not set' - )} + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.0b8c1c7e02', 'Stored locally') + : translate('auto.components.settings.AccountsPane.1fd1b1b6b4', 'Cookie not set')}

{translate( 'auto.components.settings.AccountsPane.5e08b0fe57', - 'Sent only to platform.minimax.io. Never leaves Orca.' + 'Stored locally and sent only to platform.minimax.io for usage refreshes.' )}

@@ -1635,7 +1639,7 @@ export function AccountsPane({ disabled={miniMaxCredentialBusy} className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground" > - {translate('auto.components.settings.AccountsPane.b398b834c9', 'Forget cookie')} + {translate('auto.components.settings.AccountsPane.316ca4e610', 'Forget cookie')} ) : null}
@@ -1677,17 +1681,6 @@ export function AccountsPane({ )}

- {miniMaxConfigured ? ( - - ) : null}
updateSettings({ minimaxGroupId: e.target.value })} placeholder={translate( 'auto.components.settings.AccountsPane.0747d6391a', - settings.minimaxGroupId ? 'From cookie' : 'Use group ID from cookie' + 'Use group ID from cookie' )} spellCheck={false} className="text-xs" diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index e26cffcccde..714844932f1 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -6,13 +6,13 @@ import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { availableStatusBarToggles: [] as { description: string - id: 'ports' + id: StatusBarItem keywords: string[] title: string toggleDescription: string @@ -364,6 +364,31 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { + mocks.state.availableStatusBarToggles = [ + { + id: 'minimax', + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.', + keywords: ['status bar', 'minimax', 'usage'] + } + ] + mocks.state.settingsSearchQuery = 'minimax' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const miniMaxSwitch = container.querySelector( + 'button[role="switch"][aria-label="MiniMax Usage"]' + ) + + expect(miniMaxSwitch).not.toBeNull() + await act(async () => { + miniMaxSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax') + }) + it('collapses sibling sections so only the Interface section is expanded by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 065056fbe6f..2173196651a 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -37,7 +37,14 @@ function recordStatusBarToggleInteraction( recordFeatureInteraction('ports') } else if (id === 'ssh') { recordFeatureInteraction('ssh') - } else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') { + } else if ( + id === 'claude' || + id === 'codex' || + id === 'gemini' || + id === 'opencode-go' || + id === 'kimi' || + id === 'minimax' + ) { recordFeatureInteraction('usage-tracking') } } diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts new file mode 100644 index 00000000000..18a218aca4c --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getStatusBarToggles } from './appearance-status-bar-search' + +describe('getStatusBarToggles', () => { + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { + const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') + + expect(miniMaxToggle).toMatchObject({ + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.' + }) + expect(miniMaxToggle?.keywords).toEqual( + expect.arrayContaining(['status bar', 'minimax', 'usage', 'subscription', 'cookie']) + ) + }) +}) diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index c97f198da24..673deda4b54 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -161,6 +161,41 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Kimi subscription usage for the active workspace.' ) }, + { + id: 'minimax', + title: translate('auto.components.settings.appearance.search.0f08f6b483', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.appearance.search.e46178eb1b', + 'Show MiniMax subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d16378a88f', + 'minimax' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d9e7cef86f', + 'cookie' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.25e51b62ee', + 'rate limit' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.minimaxToggleDescription', + 'Show MiniMax subscription usage for the active workspace.' + ) + }, { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 6fcbc68801d..6dd7c102d18 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1864,6 +1864,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating + // doesn't apply (same rationale as OpenCode Go below). const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d07151569b4..f00e6bd8e92 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", + "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." @@ -4571,6 +4572,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "Find this in the URL after logging into opencode.ai (e.g.", "b398b834c9": "Clear", + "316ca4e610": "Forget cookie", "a122332371": "wrk_… (leave blank for automatic lookup)", "dbdb0b0bd8": "Workspace ID override", "d70a5287a4": "Optional workspace ID override if the automatic lookup fails.", @@ -4638,6 +4640,7 @@ "350b2a1aa7": "Use your current", "e05d0ff737": "Use your current {{value0}} Claude login.", "2f24f244a4": "MiniMax cookie is required.", + "8e6f0cb1d8": "MiniMax cookie was not saved.", "8d61637a77": "MiniMax cookie saved.", "b43e761fe5": "MiniMax cookie update failed.", "5d63bbfbec": "MiniMax", @@ -4659,13 +4662,13 @@ "5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.", "3c92b0d31c": "general", "0d8e77bc40": "Open console", - "0b8c1c7e02": "Encrypted locally", - "5e08b0fe57": "Sent only to platform.minimax.io. Never leaves Orca.", + "0b8c1c7e02": "Stored locally", + "1fd1b1b6b4": "Cookie not set", + "5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.", "43d7a45b97": "How to copy", "b8a4f21c3e": "Paste the Cookie header from DevTools", "53f7b8c7a2": "Last refresh: {{value0}}", "31d24a4e87": "Cookie expires when you sign out in the browser.", - "42c2cb21cf": "Forget cookie", "3a30aaf526": "just now", "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", "24560fe830": "Open DevTools.", @@ -4675,7 +4678,7 @@ "435df0ee51": "Under Request Headers, copy the Cookie value.", "7492fb3bba": "Paste it here and click Save.", "9fec52de4b": "How to copy the cookie", - "4e32e030b2": "The cookie stays on this device. Orca only sends it to platform.minimax.io for usage refreshes." + "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -7017,6 +7020,11 @@ "40e5c3c285": "kimi", "c927a155d5": "Show Kimi subscription usage in the status bar.", "3a6c028ea8": "Kimi Usage", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Show MiniMax subscription usage in the status bar.", + "0f08f6b483": "MiniMax Usage", "edbf0f63a0": "cost", "afbb6a3767": "tokens", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 07a1201658d..b2c5b024265 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "Muestra el consumo de tokens y el costo de Gemini para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el consumo de tokens y el costo de OpenCode Go para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", + "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos." @@ -2936,8 +2937,8 @@ "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", "c0e972d726": "Cancelar", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "Abrir detalles de uso de MiniMax", + "3bbf140864": "Uso de MiniMax" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4571,6 +4572,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk__…/go", "51c9104e13": "Encuéntralo en la URL después de iniciar sesión en opencode.ai (p. ej.", "b398b834c9": "Limpiar", + "316ca4e610": "Olvidar cookie", "a122332371": "wrk_… (dejar en blanco para búsqueda automática)", "dbdb0b0bd8": "Override de Workspace ID", "d70a5287a4": "Override opcional de Workspace ID si falla la búsqueda automática.", @@ -4638,6 +4640,7 @@ "350b2a1aa7": "Usa tu actual", "e05d0ff737": "Usa tu login actual de Claude de {{value0}}.", "2f24f244a4": "La cookie de MiniMax es obligatoria.", + "8e6f0cb1d8": "La cookie de MiniMax no se guardó.", "8d61637a77": "Cookie de MiniMax guardada.", "b43e761fe5": "No se pudo actualizar la cookie de MiniMax.", "5d63bbfbec": "MiniMax", @@ -4659,13 +4662,13 @@ "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", "3c92b0d31c": "general", "0d8e77bc40": "Abrir consola", - "0b8c1c7e02": "Cifrado localmente", - "5e08b0fe57": "Solo se envía a platform.minimax.io. Nunca sale de Orca.", + "0b8c1c7e02": "Almacenado localmente", + "1fd1b1b6b4": "Cookie no configurada", + "5e08b0fe57": "Se almacena localmente y se envía solo a platform.minimax.io para actualizar el uso.", "43d7a45b97": "Cómo copiar", "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", "53f7b8c7a2": "Última actualización: {{value0}}", "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", - "42c2cb21cf": "Olvidar cookie", "3a30aaf526": "ahora mismo", "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", "24560fe830": "Abre DevTools.", @@ -4675,7 +4678,7 @@ "435df0ee51": "En Request Headers, copia el valor de Cookie.", "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", "9fec52de4b": "Cómo copiar la cookie", - "4e32e030b2": "La cookie permanece en este dispositivo. Orca solo la envía a platform.minimax.io para actualizar el uso." + "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", @@ -6978,6 +6981,11 @@ "40e5c3c285": "kimi", "c927a155d5": "Muestra el uso de la suscripción de Kimi en la barra de estado.", "3a6c028ea8": "Uso de Kimi", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Muestra el uso de la suscripción de MiniMax en la barra de estado.", + "0f08f6b483": "Uso de MiniMax", "edbf0f63a0": "costo", "afbb6a3767": "tokens", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index be351905992..d5d6043054b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", + "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" @@ -2936,8 +2937,8 @@ "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", "f077f586db": "今後表示しない", "c0e972d726": "キャンセル", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "MiniMax の使用量詳細を開く", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai にログインした後、URL でこれを見つけます (例:", "b398b834c9": "クリア", + "316ca4e610": "Cookie を削除", "a122332371": "wrk_… (自動検索の場合は空白のままにします)", "dbdb0b0bd8": "ワークスペースIDのオーバーライド", "d70a5287a4": "自動検索が失敗した場合は、オプションのワークスペース ID をオーバーライドします。", @@ -4622,18 +4624,19 @@ "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "MiniMax Cookie が必要です。", + "8e6f0cb1d8": "MiniMax Cookie は保存されませんでした。", + "8d61637a77": "MiniMax Cookie を保存しました。", + "b43e761fe5": "MiniMax Cookie の更新に失敗しました。", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "platform.minimax.io から MiniMax の使用量追跡を設定します。", + "21d6eb141e": "MiniMax セッション Cookie", + "33bba5ad83": "ローカルのレート制限取得用に MiniMax セッション Cookie を貼り付けます。", + "73ea15f24b": "保存済み", + "23afe8f226": "未保存", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", + "f38b9cc4bd": "置き換え", + "590a3130f9": "保存", "79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。", "9dd50d3f75": "詳細設定", "174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。", @@ -4644,13 +4647,13 @@ "5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。", "3c92b0d31c": "general", "0d8e77bc40": "コンソールを開く", - "0b8c1c7e02": "ローカルで暗号化", - "5e08b0fe57": "platform.minimax.io のみに送信されます。Orca の外部に出ることはありません。", + "0b8c1c7e02": "ローカルに保存済み", + "1fd1b1b6b4": "Cookie は未設定です", + "5e08b0fe57": "ローカルに保存され、使用量の更新のために platform.minimax.io のみに送信されます。", "43d7a45b97": "コピー方法", "b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け", "53f7b8c7a2": "最終更新: {{value0}}", "31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。", - "42c2cb21cf": "Cookie を削除", "3a30aaf526": "たった今", "f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。", "24560fe830": "DevTools を開きます。", @@ -4660,7 +4663,7 @@ "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", "7492fb3bba": "ここに貼り付けて保存をクリックします。", "9fec52de4b": "Cookie のコピー方法", - "4e32e030b2": "Cookie はこのデバイスにのみ保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" + "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -6829,8 +6832,8 @@ "593720c17f": "場所", "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", "d09fb5ca92": "アカウントの場所", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。" } }, "advanced": { @@ -7000,6 +7003,11 @@ "40e5c3c285": "kimi", "c927a155d5": "ステータス バーに Kimi サブスクリプションの使用状況を表示します。", "3a6c028ea8": "Kimi 使用量", + "25e51b62ee": "レート制限", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "ステータスバーに MiniMax サブスクリプションの使用状況を表示します。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "料金", "afbb6a3767": "トークン", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ef3d48a60bb..419d2229dcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", + "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." @@ -2936,8 +2937,8 @@ "6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.", "f077f586db": "다시 묻지 않기", "c0e972d726": "취소", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "MiniMax 사용량 세부 정보 열기", + "3bbf140864": "MiniMax 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai에 로그인한 후 URL에서 이를 찾으세요(예:", "b398b834c9": "지우기", + "316ca4e610": "쿠키 잊기", "a122332371": "wrk_… (자동 조회를 위해 비워두세요)", "dbdb0b0bd8": "워크스페이스 ID 재정의", "d70a5287a4": "자동 조회가 실패할 때 사용할 선택적 워크스페이스 ID 재정의입니다.", @@ -4622,18 +4624,19 @@ "b11078a9c2": "wsl", "350b2a1aa7": "현재 사용 중인", "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "MiniMax Cookie가 필요합니다.", + "8e6f0cb1d8": "MiniMax Cookie가 저장되지 않았습니다.", + "8d61637a77": "MiniMax Cookie가 저장되었습니다.", + "b43e761fe5": "MiniMax Cookie 업데이트에 실패했습니다.", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "platform.minimax.io에서 MiniMax 사용량 추적을 설정합니다.", + "21d6eb141e": "MiniMax 세션 Cookie", + "33bba5ad83": "로컬 rate-limit 가져오기에 사용할 MiniMax 세션 Cookie를 붙여넣으세요.", + "73ea15f24b": "저장됨", + "23afe8f226": "저장되지 않음", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", + "f38b9cc4bd": "교체", + "590a3130f9": "저장", "79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.", "9dd50d3f75": "고급", "174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.", @@ -4644,13 +4647,13 @@ "5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.", "3c92b0d31c": "general", "0d8e77bc40": "콘솔 열기", - "0b8c1c7e02": "로컬에서 암호화됨", - "5e08b0fe57": "platform.minimax.io에만 전송됩니다. Orca 외부로 나가지 않습니다.", + "0b8c1c7e02": "로컬에 저장됨", + "1fd1b1b6b4": "Cookie가 설정되지 않음", + "5e08b0fe57": "로컬에 저장되며 사용량 새로 고침을 위해 platform.minimax.io로만 전송됩니다.", "43d7a45b97": "복사 방법", "b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기", "53f7b8c7a2": "마지막 새로 고침: {{value0}}", "31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.", - "42c2cb21cf": "Cookie 삭제", "3a30aaf526": "방금 전", "f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.", "24560fe830": "DevTools를 엽니다.", @@ -4660,7 +4663,7 @@ "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", "9fec52de4b": "Cookie 복사 방법", - "4e32e030b2": "Cookie는 이 기기에만 보관됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." + "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -6792,8 +6795,8 @@ "593720c17f": "위치", "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", "d09fb5ca92": "계정 위치", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 사용량", + "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요." } }, "advanced": { @@ -6963,6 +6966,11 @@ "40e5c3c285": "kimi", "c927a155d5": "상태 표시줄에 Kimi 구독 사용량을 표시합니다.", "3a6c028ea8": "Kimi 사용량", + "25e51b62ee": "사용 한도", + "d9e7cef86f": "쿠키", + "d16378a88f": "minimax", + "e46178eb1b": "상태 표시줄에 MiniMax 구독 사용량을 표시합니다.", + "0f08f6b483": "MiniMax 사용량", "edbf0f63a0": "비용", "afbb6a3767": "토큰", "d77537b580": "opencode-go", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 75667d4483e..56c381482f7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", + "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" @@ -2936,8 +2937,8 @@ "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", "f077f586db": "不再询问", "c0e972d726": "取消", - "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "06741a2f3d": "打开 MiniMax 使用量详情", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -4556,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "登录 opencode.ai 后在 URL 中找到此内容(例如", "b398b834c9": "清除", + "316ca4e610": "忘记 Cookie", "a122332371": "wrk_…(留空自动查找)", "dbdb0b0bd8": "工作区 ID 覆盖", "d70a5287a4": "如果自动查找失败,可选的工作区 ID 覆盖。", @@ -4622,45 +4624,46 @@ "b11078a9c2": "wsl", "350b2a1aa7": "用你已有的", "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。", - "2f24f244a4": "MiniMax cookie is required.", - "8d61637a77": "MiniMax cookie saved.", - "b43e761fe5": "MiniMax cookie update failed.", + "2f24f244a4": "需要 MiniMax Cookie。", + "8e6f0cb1d8": "MiniMax Cookie 未保存。", + "8d61637a77": "MiniMax Cookie 已保存。", + "b43e761fe5": "MiniMax Cookie 更新失败。", "5d63bbfbec": "MiniMax", - "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", - "21d6eb141e": "MiniMax Session Cookie", - "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", - "73ea15f24b": "Saved", - "23afe8f226": "Not saved", + "15e831350e": "从 platform.minimax.io 配置 MiniMax 使用量跟踪。", + "21d6eb141e": "MiniMax 会话 Cookie", + "33bba5ad83": "粘贴 MiniMax 会话 Cookie 以在本地获取速率限制。", + "73ea15f24b": "已保存", + "23afe8f226": "未保存", "566d9a99ab": "_token=…; minimax_group_id_v2=…", - "f38b9cc4bd": "Replace", - "590a3130f9": "Save", - "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", + "f38b9cc4bd": "替换", + "590a3130f9": "保存", + "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", "9dd50d3f75": "高级", - "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", + "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", "bf160bb6c0": "Group ID 覆盖", "b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。", "0747d6391a": "使用 Cookie 中的 Group ID", "4ff2af7524": "使用模型名称", - "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", + "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", "3c92b0d31c": "general", "0d8e77bc40": "打开控制台", - "0b8c1c7e02": "本地加密", - "5e08b0fe57": "仅发送到 platform.minimax.io。不会离开 Orca。", + "0b8c1c7e02": "已存储在本地", + "1fd1b1b6b4": "未设置 Cookie", + "5e08b0fe57": "存储在本地,仅发送到 platform.minimax.io 以刷新使用量。", "43d7a45b97": "如何复制", "b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头", "53f7b8c7a2": "上次刷新: {{value0}}", - "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", - "42c2cb21cf": "忘记 Cookie", + "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", "3a30aaf526": "刚刚", "f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。", "24560fe830": "打开 DevTools。", "4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。", "bee4e63e1c": "重新加载页面。", "87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。", - "435df0ee51": "在请求头中,复制 Cookie 的值。", + "435df0ee51": "在请求头中,复制 Cookie 的值。", "7492fb3bba": "在此粘贴并点击保存。", "9fec52de4b": "如何复制 Cookie", - "4e32e030b2": "Cookie 仅保留在此设备上。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" + "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -6792,8 +6795,8 @@ "593720c17f": "位置", "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", "d09fb5ca92": "账户位置", - "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。" } }, "advanced": { @@ -6963,6 +6966,11 @@ "40e5c3c285": "基米", "c927a155d5": "在状态栏中显示 Kimi 订阅使用情况。", "3a6c028ea8": "基米用法", + "25e51b62ee": "速率限制", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "在状态栏中显示 MiniMax 订阅使用情况。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "成本", "afbb6a3767": "Token", "d77537b580": "opencode-go", diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4b64524c786..64899d1a169 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1162,11 +1162,18 @@ describe('createUISlice hydratePersistedUI', () => { }) ) - expect(store.getState().statusBarItems).toEqual(['claude', 'resource-usage', 'ports', 'kimi']) + expect(store.getState().statusBarItems).toEqual([ + 'claude', + 'resource-usage', + 'ports', + 'kimi', + 'minimax' + ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi'], + statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) }) @@ -1179,7 +1186,8 @@ describe('createUISlice hydratePersistedUI', () => { makePersistedUI({ statusBarItems: ['claude', 'resource-usage'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 5e0c78c2945..c8e5f112e39 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -263,6 +263,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) @@ -2218,15 +2219,22 @@ export const createUISlice: StateCreator = (set, get) ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') ? statusBarItemsWithPorts : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] + const statusBarItemsWithMiniMax = + ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') + ? statusBarItems + : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] if ( - (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded) && + (!ui._portsStatusBarDefaultAdded || + !ui._kimiStatusBarDefaultAdded || + !ui._minimaxStatusBarDefaultAdded) && typeof window !== 'undefined' ) { window.api.ui .set({ - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) .catch(console.error) } @@ -2286,7 +2294,7 @@ export const createUISlice: StateCreator = (set, get) workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 18f64e1266d..0dd6b240f40 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -410,6 +410,47 @@ describe('web settings preload API', () => { expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) }) + it('hydrates MiniMax usage settings from a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) + }) + it('forwards compact worktree card updates to a paired runtime', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ @@ -478,6 +519,77 @@ describe('web settings preload API', () => { { method: 'settings.update', params: { experimentalNewWorktreeCardStyle: true } } ]) }) + + it('forwards MiniMax usage setting updates to a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.set({ + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + }) + + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([ + { + method: 'settings.update', + params: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + } + ]) + }) +}) + +describe('web MiniMax preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => { + const { api } = await installApi('Linux') + + await expect(api.minimaxCredentials.getStatus()).resolves.toEqual({ configured: false }) + await expect(api.minimaxCredentials.saveCookie('_token=abc')).rejects.toThrow(/desktop app/i) + await expect(api.minimaxCredentials.clearCookie()).resolves.toEqual({ configured: false }) + }) }) describe('web UI preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 9ad9b1e0ba3..892b47706d2 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -638,6 +638,7 @@ function createWebPreloadApi(): Partial { preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), + minimaxCredentials: createMiniMaxCredentialsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), @@ -2476,6 +2477,16 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { } } +function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + function createAccountsApi(): never { const empty = { accounts: [], @@ -2850,6 +2861,12 @@ async function getRuntimeBackedStoredSettings(): Promise { if (typeof result.settings.compactWorktreeCards === 'boolean') { runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } const next = mergeSettings(local, runtimeSettings) writeJson(SETTINGS_STORAGE_KEY, next) return next @@ -2873,6 +2890,12 @@ async function syncRuntimeBackedSettings( if (typeof updates.compactWorktreeCards === 'boolean') { runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } if (Object.keys(runtimeUpdates).length === 0) { return localNext } diff --git a/src/shared/types.ts b/src/shared/types.ts index 8d3268035aa..3592987167f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3227,6 +3227,8 @@ export type PersistedUIState = { _portsStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Kimi status item. */ _kimiStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on MiniMax status item. */ + _minimaxStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null From 812f24bd19b72740afb0d12322951541dc2b8e3c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:36:24 -0700 Subject: [PATCH 228/233] fix(terminal): clear PTY-side buffers on Ctrl+K so the prompt stops repainting at a stale row (#7413) --- src/main/daemon/headless-emulator.ts | 17 +++ src/main/daemon/pty-subprocess.ts | 10 ++ src/main/daemon/session.test.ts | 102 +++++++++++++++++- src/main/daemon/session.ts | 34 ++++++ src/main/ipc/pty.ts | 11 ++ src/main/providers/local-pty-provider.ts | 15 ++- src/main/runtime/orca-runtime.ts | 4 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 7 ++ .../components/terminal-pane/TerminalPane.tsx | 8 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/shell-process-detection.ts | 18 ++++ 12 files changed, 224 insertions(+), 5 deletions(-) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index f5c2bb34049..fb0a9c190d4 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -172,6 +172,23 @@ export class HeadlessEmulator { return this.terminal.buffer.active.type === 'alternate' } + /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with + * pending input it re-renders at a cached buffer row that ConPTY's fixed + * viewport doesn't track, painting the input well below the prompt. The + * cursor line counts as an empty prompt when everything before the cursor + * ends with a single '>' and nothing follows it ('>>' is PowerShell's + * continuation prompt, i.e. a multiline edit in flight). */ + isCursorOnEmptyPromptLine(): boolean { + const buffer = this.terminal.buffer.active + const line = buffer.getLine(buffer.baseY + buffer.cursorY) + if (!line) { + return false + } + const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd() + const fullLine = line.translateToString(true).trimEnd() + return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>') + } + getVisibleLines(): string[] { const buffer = this.terminal.buffer.active const lines: string[] = [] diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 35e92157ba4..b9800941b7e 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1030,6 +1030,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true } }, + clear: () => { + if (dead) { + return + } + try { + proc.clear() + } catch { + // Best-effort: a clear on a just-exited PTY must not kill the handle. + } + }, kill: () => { if (dead) { return diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index fb5b961cc98..478b4ea99f0 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -9,6 +9,7 @@ function createMockSubprocess() { let onData: ((data: string) => void) | null = null let onExit: ((code: number) => void) | null = null let killed = false + let clearCalls = 0 let pid = 12345 return { @@ -20,13 +21,20 @@ function createMockSubprocess() { get pid() { return pid }, + foregroundProcess: null as string | null, getForegroundProcess(): string | null { - return null + return this.foregroundProcess }, write(data: string) { written.push(data) }, resize(_cols: number, _rows: number) {}, + get clearCalls() { + return clearCalls + }, + clear() { + clearCalls++ + }, kill() { killed = true // Simulate async exit @@ -426,6 +434,98 @@ describe('Session', () => { }) }) + describe('clearScrollback', () => { + function withPlatform(platform: NodeJS.Platform, run: () => void): void { + const original = process.platform + Object.defineProperty(process, 'platform', { value: platform }) + try { + run() + } finally { + Object.defineProperty(process, 'platform', { value: original }) + } + } + + it('resyncs the native PTY screen state alongside the emulator clear', () => { + createSession() + session.clearScrollback() + // Why: without the subprocess clear, ConPTY keeps a stale cursor row and + // the next prompt repaint lands below a blank gap on Windows. + expect(subprocess.clearCalls).toBe(1) + const take = session.takePendingOutput(false) + expect(take?.records).toContainEqual({ kind: 'clear' }) + }) + + it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + // Why: the ConPTY clear cannot reach PSReadLine's cached cursor row; + // Ctrl+L makes PSReadLine repaint the prompt at the true origin. + expect(subprocess.written).toEqual(['\x0c']) + }) + + it('does not send a form feed while input is pending at the prompt', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> fd') + await vi.advanceTimersByTimeAsync(10) + // Why: PSReadLine repaints pending input at a stale cached row that + // ConPTY's fixed viewport doesn't track, so the nudge must be skipped. + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send or queue a form feed before shell-ready', async () => { + createSession({ shellReadySupported: true }) + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + // Why: a queued form feed would flush after the startup command at an + // arbitrary later moment, when the prompt gates no longer hold. + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed at a PowerShell continuation prompt', async () => { + createSession() + subprocess.foregroundProcess = 'powershell.exe' + subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed while a command owns the foreground', async () => { + createSession() + subprocess.foregroundProcess = 'node' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('win32', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not send a form feed on POSIX platforms', async () => { + createSession() + subprocess.foregroundProcess = 'pwsh' + subprocess.simulateData('PS C:\\Users\\me> ') + await vi.advanceTimersByTimeAsync(10) + withPlatform('linux', () => session.clearScrollback()) + expect(subprocess.written).toEqual([]) + }) + + it('does not touch the subprocess after dispose', () => { + createSession() + session.dispose() + session.clearScrollback() + expect(subprocess.clearCalls).toBe(0) + }) + }) + describe('snapshot', () => { it('returns a terminal snapshot', async () => { createSession() diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b4d3433a5f8..d156aeb4802 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -8,6 +8,7 @@ import { scanForShellReady, type ShellReadyScanState } from '../shell-ready-marker-scanner' +import { isPowerShellProcess } from '../../shared/shell-process-detection' import type { PendingOutputRecord, SessionState, @@ -41,6 +42,10 @@ export type SubprocessHandle = { startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void + /** Resync the native PTY's own screen state after a frontend clear. + * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes + * the next prompt repaint land below a blank gap. */ + clear?(): void kill(): void forceKill(): void signal(sig: string): void @@ -285,6 +290,35 @@ export class Session { } this.emulator.clearScrollback() this.recordPendingOutput({ kind: 'clear' }) + this.subprocess.clear?.() + this.#nudgePowerShellPromptRepaint() + } + + /** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row, + * so PowerShell's first Enter after a clear would still repaint the prompt + * at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes + * PSReadLine itself repaint at the true origin. Gated to a PowerShell + * foreground so a running command or TUI never gets a stray 0x0C, and to + * an empty prompt because PSReadLine repaints pending input at a stale + * cached row that ConPTY's fixed viewport doesn't track. */ + #nudgePowerShellPromptRepaint(): void { + if (process.platform !== 'win32') { + return + } + // Why: before shell-ready, write() would queue the form feed behind the + // buffered startup command and deliver it at an arbitrary later moment, + // when the foreground/prompt gates below no longer hold. The nudge is + // cosmetic — skip it rather than defer it. + if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { + return + } + if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) { + return + } + if (!this.emulator.isCursorOnEmptyPromptLine()) { + return + } + this.subprocess.write('\x0c') } prepareForFinalSnapshot(): string { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8a2c109f744..c43104d3b03 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3509,6 +3509,17 @@ export function registerPtyHandlers( .catch(() => {}) }) + ipcMain.removeAllListeners('pty:clearBuffer') + ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + // Why: the renderer already cleared its own xterm buffer. This clears the + // PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer) + // so the next prompt repaint doesn't land at a stale cursor row. + tryGetProviderForPty(args.id) + ?.clearBuffer(args.id) + .catch(() => {}) + runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + }) + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0d2eb19f1a6..81f9cbcd3b2 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -898,8 +898,19 @@ export class LocalPtyProvider implements IPtyProvider { async getInitialCwd(_id: string): Promise { return '' } - async clearBuffer(_id: string): Promise { - /* handled client-side in xterm.js */ + async clearBuffer(id: string): Promise { + // Why: xterm.js clear() only resets the renderer. ConPTY keeps its own + // screen buffer, so without this its stale cursor row makes the next + // prompt repaint land below a blank gap. No-op on POSIX. + // + // Unlike the daemon session, no PSReadLine form-feed nudge here: it is + // only safe at an empty prompt, and without a headless emulator this + // provider cannot tell whether input is pending. + try { + ptyProcesses.get(id)?.clear() + } catch { + /* PTY may have just exited */ + } } acknowledgeDataEvent(_id: string, _charCount: number): void { /* no flow control for local */ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 053e2028750..962a72dccdb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5955,7 +5955,9 @@ export class OrcaRuntimeService { }) } - private async clearHeadlessTerminalBuffer(ptyId: string): Promise { + // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile + // mirror or a resubscribing mobile client resurrects the cleared scrollback. + async clearHeadlessTerminalBuffer(ptyId: string): Promise { const state = this.headlessTerminals.get(ptyId) if (!state) { return diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cb9cd51aa20..02a70ec0e01 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1150,6 +1150,7 @@ export type PreloadApi = { resize: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void + clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 680c8b8d88c..ba7760a5bf9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -812,6 +812,13 @@ const api = { ipcRenderer.send('pty:signal', { id, signal }) }, + /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host (ConPTY, + * daemon emulator, SSH host buffer) keeps its own screen state and would + * repaint the next prompt at the stale cursor row. */ + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index afc5f3f70c5..7ed49ac0da0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1048,7 +1048,13 @@ export default function TerminalPane({ // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - clearWebRuntimeTerminalBuffer(ptyId) + const clearedRemoteHostBuffer = clearWebRuntimeTerminalBuffer(ptyId) + if (!clearedRemoteHostBuffer && ptyId) { + // Why: local/daemon/SSH PTYs keep their own screen state (ConPTY on + // Windows), and a stale host cursor row makes the next prompt repaint + // land below a blank gap after a frontend-only clear. + window.api.pty.clearBuffer(ptyId) + } persistLayoutSnapshot() }, [paneTransportsRef, persistLayoutSnapshot] diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 892b47706d2..8647c9b0d86 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2552,6 +2552,8 @@ function createPtyApi(): NonNullable['pty']> { resize: () => {}, reportGeometry: () => {}, signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts index 381b3bc42a4..0ae76e42673 100644 --- a/src/shared/shell-process-detection.ts +++ b/src/shared/shell-process-detection.ts @@ -21,3 +21,21 @@ export function isShellProcess(processName: string): boolean { SHELL_NAMES.has(basenameWithoutWindowsExtension) ) } + +// Why: a ConPTY-side buffer clear cannot reach PSReadLine's cached cursor +// row, so the first Enter after a terminal clear still repaints the prompt at +// the stale row. Only the PowerShell family binds Ctrl+L (form feed) to a +// full prompt repaint, so the post-clear nudge must stay scoped to it. +const POWERSHELL_NAMES = new Set(['powershell', 'pwsh']) + +export function isPowerShellProcess(processName: string | null | undefined): boolean { + if (!processName) { + return false + } + const normalized = processName + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase() + const basename = normalized.split(/[\\/]/).pop() ?? normalized + return POWERSHELL_NAMES.has(basename.replace(WINDOWS_PROCESS_EXTENSION_RE, '')) +} From 9981c3b82764d7f0cf7ab4256abb3243da05030e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:53:17 -0700 Subject: [PATCH 229/233] Check for Updates: cmd/ctrl-click fetches latest perf-tagged prerelease (#7278) Co-authored-by: Orca --- src/main/menu/register-app-menu.test.ts | 39 ++++-- src/main/menu/register-app-menu.ts | 16 ++- src/main/updater-prerelease-feed.test.ts | 80 ++++++++++++ src/main/updater-prerelease-feed.ts | 24 +++- src/main/updater.test.ts | 91 +++++++++++++ src/main/updater.ts | 122 ++++++++++++++---- .../window/attach-main-window-services.ts | 8 +- src/preload/api-types.ts | 3 +- .../settings/GeneralUpdateSettingsSection.tsx | 14 +- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 59 ++++++++- .../sidebar/SidebarSettingsHelpMenu.tsx | 22 +++- .../lib/update-check-click-options.test.ts | 53 ++++++++ .../src/lib/update-check-click-options.ts | 24 ++++ src/shared/types.ts | 5 + 14 files changed, 500 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/lib/update-check-click-options.test.ts create mode 100644 src/renderer/src/lib/update-check-click-options.ts diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 39b266b6846..fa636e8623c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -139,7 +139,7 @@ describe('registerAppMenu', () => { expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 }) }) - it('includes prereleases when Check for Updates is clicked with shift held', () => { + it('routes Check for Updates modifier clicks to prerelease and perf checks', () => { const options = buildMenuOptions() registerAppMenu(options) @@ -152,19 +152,42 @@ describe('registerAppMenu', () => { ) item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent) + item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) item?.click?.( {} as never, undefined as never, - { metaKey: true, shiftKey: true } as Electron.KeyboardEvent + { + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + { + triggeredByAccelerator: true, + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent ) - item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) - item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent) expect(options.onCheckForUpdates.mock.calls).toEqual([ - [{ includePrerelease: true }], - [{ includePrerelease: true }], - [{ includePrerelease: false }], - [{ includePrerelease: false }] + [{ includePrerelease: true, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: true, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index a5390e36afc..c36aca6973a 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -5,6 +5,7 @@ import { type KeybindingActionId, type KeybindingOverrides } from '../../shared/keybindings' +import type { UpdateCheckOptions } from '../../shared/types' import { translateMain } from '../i18n/main-i18n' export type AppearanceMenuState = { @@ -26,7 +27,7 @@ type RegisterAppMenuOptions = { onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void onOpenCrashReport: (window?: Electron.BaseWindow | null) => void - onCheckForUpdates: (options: { includePrerelease: boolean }) => void + onCheckForUpdates: (options: UpdateCheckOptions) => void onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void onZoomIn: () => void onZoomOut: () => void @@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { webContents.reload() } - // Why: holding Shift while clicking Check for Updates opts this check into - // the release-candidate channel. Extracted so both the macOS app-menu entry - // and the Windows/Linux Help-menu entry share the exact same behavior. + // Why: modifier-click update checks are hidden power-user affordances. + // Extracted so the macOS app-menu entry and Windows/Linux Help entry share + // identical RC/perf channel routing. const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = ( _menuItem, _window, event ) => { - const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true - onCheckForUpdates({ includePrerelease }) + const modifierClick = !event.triggeredByAccelerator + const includePerfPrerelease = + modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) + const includePrerelease = modifierClick && event.shiftKey === true + onCheckForUpdates({ includePrerelease, includePerfPrerelease }) } const checkForUpdatesItem: Electron.MenuItemConstructorOptions = { diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index e9f40a72a09..f30fa276b65 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => { expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1') }) + it('ignores perf-tagged prereleases for regular RC checks', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe( + 'v1.4.121-rc.6' + ) + }) + + it('reports no RC update when only perf-tagged prereleases are newer', async () => { + respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + + it('picks the semver-newest perf-tagged prerelease', async () => { + respondWithAtom([ + 'v1.4.121-rc.6.perf', + 'v1.4.121-rc.7', + 'v1.4.121-rc.5.perf', + 'v1.4.121-rc.6', + 'v1.4.122' + ]) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('matches only literal rc.N.perf prerelease tags for perf checks', async () => { + respondWithAtom([ + 'v1.4.121-rc.7.performance', + 'v1.4.121-beta.7.perf', + 'v1.4.121-rc.7.perf.extra', + 'v1.4.121-rc.6.perf' + ]) + + const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed') + + expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false) + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('reports no perf update instead of falling back to stable or RC tags', async () => { + respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.120', 1, { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + it('returns a bounded fallback candidate after the newest newer tag', async () => { respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index ec328db10eb..55f49a77282 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -43,6 +43,18 @@ type ReleaseFeedTag = { version: string } +export function isPerfPrereleaseTag(tag: string): boolean { + const version = normalizeTagToVersion(tag) + const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/) + const identifiers = match?.[1]?.split('.') ?? [] + return ( + identifiers.length === 3 && + identifiers[0] === 'rc' && + /^\d+$/.test(identifiers[1]) && + identifiers[2] === 'perf' + ) +} + async function fetchReleaseFeedTags(): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) @@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { */ type FetchNewerReleaseTagOptions = { includePrerelease?: boolean + releaseFilter?: 'perf' } export type FetchNewerReleaseTagsResult = { @@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness( return { tags: [], state: 'unavailable' } } - const candidates = includePrerelease - ? tags - : tags.filter(({ version }) => !isPrereleaseVersion(version)) + // Why: perf builds are explicit opt-in; regular prerelease checks should + // stay on the main RC/stable series even though perf tags are semver-newer. + const candidates = + options.releaseFilter === 'perf' + ? tags.filter(({ tag }) => isPerfPrereleaseTag(tag)) + : includePrerelease + ? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag)) + : tags.filter(({ version }) => !isPrereleaseVersion(version)) const newestNewerIndex = candidates.findIndex( ({ version }) => compareVersions(version, currentVersion) > 0 ) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 60ff680a012..b4c1a47c54b 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1012,6 +1012,97 @@ describe('updater', () => { expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1) }) + it('pins the generic feed to a perf-tagged prerelease when requested', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf' + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowPrerelease).toBe(true) + }) + + it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true + }) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls) + }) + + it('keeps background retries on the stable channel after a perf publishing-window miss', async () => { + vi.useFakeTimers() + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) + .mockResolvedValueOnce(['v1.4.121']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, { + includePrerelease: false + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121' + }) + }) + }) + it('leaves the feed URL alone for a normal user-initiated check', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const mainWindow = { webContents: { send: vi.fn() } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 0d383880088..99553eadfb8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, powerMonitor } from 'electron' import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' -import type { UpdateStatus } from '../shared/types' +import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } type PrimaryEventSuppression = { failureKey: string; error: unknown } +type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' } let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void | Promise) | null = null let autoUpdaterInitialized = false -// Why: Shift-clicking "Check for Updates" opts the user into the RC release -// channel for the rest of this process. The generic feed still gets pinned to -// a concrete tag on every check so cancelled RCs without manifests are skipped. +// Why: modifier-clicking "Check for Updates" can target prerelease manifests. +// The generic feed still gets pinned to a concrete tag on every check so +// cancelled prereleases without manifests are skipped. let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null @@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null let activeUpdateCheckLaunchAttemptId: number | null = null let activeUpdateCheckEventAttemptId: number | null = null let updateAvailableEventPendingAttemptId: number | null = null -let pendingPrereleaseUserInitiatedCheckAfterInFlight = false +let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false @@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { - const shouldLaunchPendingPrereleaseCheck = - pendingPrereleaseUserInitiatedCheckAfterInFlight && + const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && (status.state === 'idle' || status.state === 'not-available' || status.state === 'available' || @@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void { ) { downloadInFlight = false } - if (shouldLaunchPendingPrereleaseCheck) { - launchPendingPrereleaseUserInitiatedCheckAfterInFlight() + if (shouldLaunchPendingUserInitiatedCheck) { + launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) return } if (statusesEqual(currentStatus, decoratedStatus)) { @@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } -function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void { - pendingPrereleaseUserInitiatedCheckAfterInFlight = false +function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } +} + +function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + return 'default' +} + +function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + pendingUserInitiatedCheckAfterInFlight = null setTimeout(() => { // Why: electron-updater clears its in-flight promise after emitting the - // terminal event. Deferring one tick lets the queued RC check start fresh - // instead of being deduped into the just-finished stable check. + // terminal event. Deferring one tick lets the queued modifier check start + // fresh instead of being deduped into the just-finished stable check. if (currentStatus.state === 'checking') { currentStatus = { state: 'idle' } } - checkForUpdatesFromMenu({ includePrerelease: true }) + checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) }, 0) } @@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v ) } -async function pinDefaultReleaseFeed(): Promise { +async function pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' +): Promise { const autoUpdater = getAutoUpdater() // Why: the /releases/latest/download/ redirect can move between the update // check and the later manual download click. Pinning to the concrete tag @@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise { // Prerelease users still need any-channel resolution so they can move to a // newer RC or the next stable. Stable users should only resolve stable tags. const currentVersion = app.getVersion() - const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion) + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( currentVersion, includePrerelease ? 2 : 1, { - includePrerelease + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) } ) const newerTag = releaseTagsResult.tags[0] ?? null @@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } else if (releaseTagsResult.state === 'not-ready') { clearPrereleaseFallbackContext() if (releaseTagsResult.lastGoodTag) { @@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise { ) publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } autoUpdater.setFeedURL({ provider: 'generic', url }) - return + return 'ready' } clearPublishingWindowLastGoodCheck() console.info( `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` ) throw new Error('Latest release assets are still publishing') + } else if (isPerfCheck) { + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') } else { clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() @@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } } @@ -972,6 +1013,10 @@ export function checkForUpdates(): void { }) } +function enablePrereleaseManifestChecks(): void { + getAutoUpdater().allowPrerelease = true +} + function enableIncludePrerelease(): void { if (includePrereleaseActive) { return @@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void { // accept a prerelease manifest for users who intentionally Shift-clicked. // We keep using the manifest-probed generic feed instead of the native // GitHub provider because cancelled RC releases can appear without assets. - getAutoUpdater().allowPrerelease = true + enablePrereleaseManifestChecks() includePrereleaseActive = true } /** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ -export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void { +export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available', userInitiated: true }) return } - if (options?.includePrerelease) { + const checkVariant = getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { clearPrereleaseFallbackContext() enableIncludePrerelease() + } else if (checkVariant === 'perf') { + clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests for this check, but must not + // opt future default/background checks into the RC channel. + enablePrereleaseManifestChecks() } const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' @@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean if (checkAlreadyInFlight) { backgroundCheckPromotedToUserInitiated = true rearmActiveUpdateCheckStallTimer() - if (options?.includePrerelease) { - // Why: the in-flight check may have already pinned the stable feed. - // Queue a fresh RC check so Shift-click doesn't inherit a stable result. - pendingPrereleaseUserInitiatedCheckAfterInFlight = true + if (checkVariant !== 'default') { + // Why: the in-flight check may have already pinned the stable feed. Queue + // a fresh modifier check so it doesn't inherit a stale-channel result. + pendingUserInitiatedCheckAfterInFlight = checkVariant } return } @@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean markUpdateCheckLaunched(attemptId) return autoUpdater.checkForUpdates() } - const run = pinDefaultReleaseFeed().then(launch) + const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!isActiveUpdateCheckAttempt(attemptId)) { + return false + } + userInitiatedCheck = false + finishActiveUpdateCheckAttempt() + recordCompletedUpdateCheck() + sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) + .then((launchResult) => { + if (launchResult === false) { + return + } + handleSettledUpdateCheckPromise(attemptId) + }) .catch((err) => { if (!isActiveUpdateCheckAttempt(attemptId)) { return diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 674bd39e3f2..2d34a4c9fd0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' +import type { + CreateWorktreeResult, + UpdateCheckOptions, + WorktreeStartupLaunch +} from '../../shared/types' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' @@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) - ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => { + ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => { ensureAutoUpdaterConfigured() return checkForUpdatesFromMenu(options) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 02a70ec0e01..a6cf04e7d75 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -162,6 +162,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + UpdateCheckOptions, UpdateStatus, Worktree, WorktreeBaseStatusEvent, @@ -2160,7 +2161,7 @@ export type PreloadApi = { updater: { getVersion: () => Promise getStatus: () => Promise - check: (options?: { includePrerelease?: boolean }) => Promise + check: (options?: UpdateCheckOptions) => Promise download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index c67c40f8b73..51668b1ac12 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -7,6 +7,7 @@ import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -36,6 +37,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { } const [appVersion, setAppVersion] = useState(null) + const updateCheckHint = getUpdateCheckHint() useEffect(() => { let cancelled = false @@ -87,14 +89,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { ), @@ -250,6 +268,43 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Check for Updates menu item', () => { const html = renderToStaticMarkup() expect(html).toContain('Check for Updates') + expect(html).toMatch(/(⇧\+click|Shift\+click) checks the latest RC/) + expect(html).toMatch(/(⌘\+click|Ctrl\+click) checks the latest perf build/) + }) + + it('passes update-check modifier options through the updater bridge', async () => { + const container = await renderMenu() + const checkButton = findMenuItem(container, 'Check for Updates') + const primaryModifier = navigator.userAgent.includes('Mac') + ? { metaKey: true } + : { ctrlKey: true } + + await act(async () => { + checkButton.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, shiftKey: true })) + checkButton.click() + }) + await act(async () => { + checkButton.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, ...primaryModifier }) + ) + checkButton.click() + }) + await act(async () => { + checkButton.click() + }) + + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(1, { + includePrerelease: true, + includePerfPrerelease: false + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(2, { + includePrerelease: false, + includePerfPrerelease: true + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(3, { + includePrerelease: false, + includePerfPrerelease: false + }) }) it('renders shortcut keys in the settings tooltip', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index d74fcfb11aa..1c6e282be74 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -33,12 +33,14 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress' import { SidebarFeedbackDialog } from './SidebarFeedbackDialog' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' const DOCS_URL = 'https://www.onorca.dev/docs' const CHANGELOG_URL = 'https://onorca.dev/changelog' const GITHUB_URL = 'https://github.com/stablyai/orca' const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' const X_URL = 'https://x.com/orca_build' +const NO_UPDATE_CHECK_MODIFIERS = { ctrlKey: false, metaKey: false, shiftKey: false } function openExternalUrl(url: string): void { void window.api.shell.openUrl(url) @@ -91,13 +93,16 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const [showAdminOptions, setShowAdminOptions] = useState(false) const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) + const updateCheckModifiersRef = React.useRef(NO_UPDATE_CHECK_MODIFIERS) const mountedRef = useMountedRef() + const updateCheckHint = getUpdateCheckHint() const showMilestones = setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal const handleMenuOpenChange = (open: boolean): void => { setMenuOpen(open) + updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS if (!open) { setShowAdminOptions(false) } @@ -147,9 +152,18 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { openSettingsPage() } - const handleCheckForUpdates = (event: Event): void => { - const shiftKey = (event as PointerEvent).shiftKey - void window.api.updater.check({ includePrerelease: shiftKey }) + const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => { + updateCheckModifiersRef.current = { + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey + } + } + + const handleCheckForUpdates = (): void => { + const modifiers = updateCheckModifiersRef.current + updateCheckModifiersRef.current = NO_UPDATE_CHECK_MODIFIERS + void window.api.updater.check(getUpdateCheckClickOptions(modifiers)) } const openMilestones = (): void => { @@ -299,7 +313,9 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { {updateStatus.state === 'checking' ? ( diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts new file mode 100644 index 00000000000..02f66401301 --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options' + +function clickEvent(overrides: Partial>) { + return { + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides + } as Pick +} + +describe('getUpdateCheckClickOptions', () => { + it('uses Cmd on macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('uses Ctrl outside macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('keeps Shift as the RC prerelease modifier', () => { + expect( + getUpdateCheckClickOptions(clickEvent({ shiftKey: true, ctrlKey: true }), false) + ).toEqual({ + includePrerelease: true, + includePerfPrerelease: true + }) + }) + + it('formats the tooltip hint by platform', () => { + expect(getUpdateCheckHint(true)).toBe( + '⇧+click checks the latest RC; ⌘+click checks the latest perf build.' + ) + expect(getUpdateCheckHint(false)).toBe( + 'Shift+click checks the latest RC; Ctrl+click checks the latest perf build.' + ) + }) +}) diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts new file mode 100644 index 00000000000..2ee4191c34b --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -0,0 +1,24 @@ +import type { UpdateCheckOptions } from '../../../shared/types' +import { getShortcutPlatform } from './shortcut-platform' + +type UpdateCheckClickEvent = Pick + +function isMacShortcutPlatform(): boolean { + return getShortcutPlatform() === 'darwin' +} + +export function getUpdateCheckHint(isMac = isMacShortcutPlatform()): string { + const rcClickLabel = isMac ? '⇧+click' : 'Shift+click' + const perfClickLabel = isMac ? '⌘+click' : 'Ctrl+click' + return `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.` +} + +export function getUpdateCheckClickOptions( + event: UpdateCheckClickEvent, + isMac = isMacShortcutPlatform() +): UpdateCheckOptions { + return { + includePrerelease: event.shiftKey, + includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3592987167f..55ed3007608 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2190,6 +2190,11 @@ export type ChangelogData = { releasesBehind: number | null } +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean +} + export type UpdateStatus = | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } From 492bbe31b678a0062034cfa778e2b83c6d7ae472 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:45:05 +0000 Subject: [PATCH 230/233] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f5d3d95b9d0..abc2d902e9a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.5m + + downloads: 3.6m @@ -15,7 +15,7 @@ downloads downloads - 3.5m - 3.5m + 3.6m + 3.6m From e7d1fa4a21e2198a1dc9d70b07dbfff8cf83191b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:05:58 -0700 Subject: [PATCH 231/233] Disable auto-merge for unstable GitHub PRs (#7415) Prevent enabling auto-merge when a PR is in an UNSTABLE merge state. GitHub auto-merge mutations reject UNSTABLE PRs directly instead of allowing them to wait, so we should suppress the option. --- .../pr-sidebar/pr-auto-merge-availability.ts | 8 +++++++- .../src/components/github-pr-merge-state.test.ts | 12 ++++++++++++ src/shared/github-pr-auto-merge-availability.test.ts | 8 +++++++- src/shared/github-pr-auto-merge-availability.ts | 8 +++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts index d6040f87e2b..5453a0209b6 100644 --- a/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts +++ b/mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts @@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean { return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY' } +function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean { + return item.mergeStateStatus === 'UNSTABLE' +} + function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean { return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED' } @@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean } function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean { - if (item.state !== 'open' || isConflicting(item)) { + // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected + // by the mutation rather than becoming a waitable auto-merge request. + if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) { return false } if (item.mergeQueueRequired === true) { diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index 9284f52729e..a2f52f8fd31 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -62,6 +62,18 @@ describe('presentGitHubPRMergeState', () => { }) }) + it('does not offer enable auto-merge for GitHub unstable PRs', () => { + expect( + presentGitHubPRMergeState( + pr({ + mergeable: 'UNKNOWN', + mergeStateStatus: 'UNSTABLE', + checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } + }) + ).autoMergeAction + ).toBeNull() + }) + it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => { expect( presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) diff --git a/src/shared/github-pr-auto-merge-availability.test.ts b/src/shared/github-pr-auto-merge-availability.test.ts index 47989de6b6d..2e22a3bd3f2 100644 --- a/src/shared/github-pr-auto-merge-availability.test.ts +++ b/src/shared/github-pr-auto-merge-availability.test.ts @@ -48,7 +48,7 @@ describe('github PR auto-merge availability', () => { expect(canShowGitHubPRAutoMergeControl(pr({ autoMergeEnabled: true }))).toBe(true) }) - it('suppresses closed, draft, disallowed, and conflicting PRs', () => { + it('suppresses closed, draft, disallowed, conflicting, and unstable PRs', () => { expect( canShowGitHubPRAutoMergeControl(pr({ state: 'draft', mergeStateStatus: 'BLOCKED' })) ).toBe(false) @@ -63,5 +63,11 @@ describe('github PR auto-merge availability', () => { expect( canShowGitHubPRAutoMergeControl(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) ).toBe(false) + expect( + canEnableGitHubPRAutoMerge(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) + expect( + canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) }) }) diff --git a/src/shared/github-pr-auto-merge-availability.ts b/src/shared/github-pr-auto-merge-availability.ts index 5c7417d622f..67b6e25ad72 100644 --- a/src/shared/github-pr-auto-merge-availability.ts +++ b/src/shared/github-pr-auto-merge-availability.ts @@ -18,6 +18,10 @@ function isConflicting(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY' } +function isUnstable(item: GitHubPRAutoMergeAvailabilityInput): boolean { + return item.mergeStateStatus === 'UNSTABLE' +} + function hasReviewRequirement(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED' } @@ -30,7 +34,9 @@ function canMergeImmediately(item: GitHubPRAutoMergeAvailabilityInput): boolean } function canRequestWhenReady(item: GitHubPRAutoMergeAvailabilityInput): boolean { - if (!isOpenPR(item) || isConflicting(item)) { + // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected + // by the mutation rather than becoming a waitable auto-merge request. + if (!isOpenPR(item) || isConflicting(item) || isUnstable(item)) { return false } if (item.mergeQueueRequired === true) { From e5008b1f3c3c5c623bbd6a68fe782151c9299f8d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:07:52 -0700 Subject: [PATCH 232/233] Clear PR comment selection on AI launch and defer mismatching clears (#7416) - Clear sent comments from selection queue when confirming AI resolution. - Keep selection clear requests pending until the matching review context is mounted, preventing them from being ignored on context changes. - Fix calculation of skipped threads when comment resolution is launched. --- .../components/right-sidebar/ChecksPanel.tsx | 29 ++++++++- .../right-sidebar/checks-panel-content.tsx | 9 ++- .../pr-comments-list-selection.test.tsx | 62 ++++++++++++++++++- .../pr-comments-list-selection.ts | 11 ++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 5ccc1b9f4ec..0682c1b0790 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -49,6 +49,10 @@ import { PRCommentsList, PRTriageStrip } from './checks-panel-content' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, @@ -435,6 +439,9 @@ export default function ChecksPanel(): React.JSX.Element { const [comments, setComments] = useState([]) const [commentsLoading, setCommentsLoading] = useState(false) const commentsRef = useRef([]) + const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] = + useState(null) + const commentsSelectionClearTokenRef = useRef(0) const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const refreshInFlightRef = useRef(false) @@ -2789,6 +2796,15 @@ export default function ChecksPanel(): React.JSX.Element { ] ) + const clearSentCommentSelection = useCallback((reviewContextKey: string): void => { + clearPRCommentsListSelection(reviewContextKey) + commentsSelectionClearTokenRef.current += 1 + setCommentsSelectionClearRequest({ + contextKey: reviewContextKey, + token: commentsSelectionClearTokenRef.current + }) + }, []) + const refreshCommentsAfterBulkResolve = useCallback( async (provider: ChecksPanelReview['provider']): Promise => { if (provider === 'gitlab') { @@ -2802,9 +2818,14 @@ export default function ChecksPanel(): React.JSX.Element { const resolveSelectedThreadsAfterLaunch = useCallback( async (resolution: NonNullable) => { + clearSentCommentSelection(resolution.reviewContextKey) let resolved = 0 - let skipped = 0 + let skipped = Math.max( + 0, + resolution.selectedGroups.length - resolution.selectedThreadIds.length + ) let failed = 0 + let attemptedThreadCount = 0 if (resolution.selectedThreadIds.length === 0) { toast.success( translate( @@ -2816,9 +2837,10 @@ export default function ChecksPanel(): React.JSX.Element { } for (const threadId of resolution.selectedThreadIds) { if (asyncResultKeyRef.current !== resolution.reviewContextKey) { - skipped += resolution.selectedThreadIds.length - resolved - skipped - failed + skipped += resolution.selectedThreadIds.length - attemptedThreadCount break } + attemptedThreadCount += 1 const currentGroup = groupPRComments(commentsRef.current).find( (group) => group.kind === 'thread' && group.threadId === threadId ) @@ -2856,7 +2878,7 @@ export default function ChecksPanel(): React.JSX.Element { ) ) }, - [handleResolve, refreshCommentsAfterBulkResolve] + [clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve] ) const handleFixChecksWithAI = useCallback(async (): Promise => { @@ -3780,6 +3802,7 @@ export default function ChecksPanel(): React.JSX.Element { commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} + selectionClearRequest={commentsSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 30a78f5391b..43ca52db9ea 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -94,7 +94,10 @@ import { RightPanelCommentComposer, type RightPanelCommentSubmitResult } from './right-panel-comment-composer' -import { usePRCommentsListSelection } from './pr-comments-list-selection' +import { + usePRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { translate } from '@/i18n/i18n' import { useActiveWorktree } from '@/store/selectors' import { useAppStore } from '@/store' @@ -2171,6 +2174,7 @@ export function PRCommentsList({ commentsDisabled, commentsDisabledReason, selectionContextKey, + selectionClearRequest, resolveCommentsWithAIDisabled, resolveCommentsWithAIDisabledReason, onAddComment, @@ -2186,6 +2190,7 @@ export function PRCommentsList({ commentsDisabled?: boolean commentsDisabledReason?: string selectionContextKey?: string + selectionClearRequest?: PRCommentsListSelectionClearRequest | null resolveCommentsWithAIDisabled?: boolean resolveCommentsWithAIDisabledReason?: string onAddComment?: (body: string) => Promise @@ -2212,7 +2217,7 @@ export function PRCommentsList({ addGroupToSelection, clearSelection, toggleGroupSelection - } = usePRCommentsListSelection(comments, selectionContextKey) + } = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest) const visibleComments = React.useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index 69d07fff0a2..61924494157 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -45,7 +45,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' -import { clearPRCommentsListSelection } from './pr-comments-list-selection' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' let container: HTMLDivElement @@ -79,7 +82,9 @@ function comment(overrides: Partial): PRComment { function renderList(props: { comments: PRComment[] + contextKey?: string onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void + clearRequest?: PRCommentsListSelectionClearRequest | null }): void { act(() => { root.render( @@ -87,7 +92,8 @@ function renderList(props: { @@ -297,6 +303,58 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('clears sent standalone bot comments from the queue when the parent confirms launch', () => { + const comments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments, + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + + it('keeps a clear request pending until its review context is mounted', () => { + const queuedComments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments: queuedComments, contextKey: 'review:42' }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments: [comment({ id: 2, body: 'Other review comment.' })], + contextKey: 'review:99', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + renderList({ + comments: queuedComments, + contextKey: 'review:42', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + it('exits selection mode when refresh leaves no eligible loaded threads', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 01bfeddf103..71b51ad5b3f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -65,7 +65,7 @@ export function usePRCommentsListSelection( selectionContextKey: string | undefined, clearRequest?: PRCommentsListSelectionClearRequest | null ): PRCommentsListSelection { - const lastClearRequestTokenRef = useRef(clearRequest?.token ?? null) + const lastClearRequestTokenRef = useRef(null) const [selectionState, setSelectionState] = useState(() => createSelectionState(selectionContextKey) ) @@ -77,13 +77,14 @@ export function usePRCommentsListSelection( }, [selectionContextKey]) useEffect(() => { - if (!clearRequest || clearRequest.token === lastClearRequestTokenRef.current) { + if ( + !clearRequest || + clearRequest.contextKey !== selectionContextKey || + clearRequest.token === lastClearRequestTokenRef.current + ) { return } lastClearRequestTokenRef.current = clearRequest.token - if (clearRequest.contextKey !== selectionContextKey) { - return - } const next = { contextKey: selectionContextKey, isSelectingForAI: false, From 4b2e4735d7b2a916b04d6d076eb3a708e6e90996 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:29:31 +0000 Subject: [PATCH 233/233] release: v1.4.124-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb6139b6668..9f5b0b3245c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.123-rc.1", + "version": "1.4.124-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai",