From 476b6f97d056caf3e2efbaa62c4253f6245e893c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:06:59 -0700 Subject: [PATCH 01/52] fix(native-chat): preserve initial chat mode on paired-host launches (#8567) * fix(native-chat): preserve initial chat mode on paired-host launches * fix(native-chat): keep paired launch mode authoritative * fix(native-chat): preserve mode through PTY materialization --- src/main/runtime/orca-runtime.test.ts | 66 ++++++++++++++++--- src/main/runtime/orca-runtime.ts | 54 +++++++++++++-- .../rpc/methods/session-tabs-schemas.ts | 1 + .../runtime/rpc/methods/session-tabs.test.ts | 2 + src/main/runtime/rpc/methods/session-tabs.ts | 1 + .../attach-main-window-services.test.ts | 13 +++- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + src/renderer/src/hooks/useIpcEvents.test.ts | 41 +++++++++++- src/renderer/src/hooks/useIpcEvents.ts | 35 ++++++---- .../src/lib/launch-agent-in-new-tab.test.ts | 54 ++++++++++++++- .../src/lib/launch-agent-in-new-tab.ts | 25 ++++--- .../src/lib/launch-agent-web-host-tab.ts | 16 ++++- .../src/runtime/web-runtime-session.test.ts | 2 + .../src/runtime/web-runtime-session.ts | 2 + src/shared/runtime-types.ts | 1 + 17 files changed, 272 insertions(+), 45 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9aa5c00a853..96ba22d5cea 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -17066,6 +17066,16 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) const runtime = new OrcaRuntimeService(store) + const persistViewMode = vi.spyOn( + runtime as unknown as { + persistHeadlessSessionTabProps: ( + worktreeId: string, + tabId: string, + props: { viewMode: 'terminal' | 'chat' } + ) => void + }, + 'persistHeadlessSessionTabProps' + ) runtime.setPtyController({ spawn, write: () => true, @@ -17074,7 +17084,9 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + viewMode: 'chat' + }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -17090,8 +17102,12 @@ describe('OrcaRuntimeService', () => { type: 'terminal', status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'chat', isActive: true }) + expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { + viewMode: 'chat' + }) const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ @@ -20138,7 +20154,8 @@ describe('OrcaRuntimeService', () => { }) const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: false + activate: false, + viewMode: 'chat' }) expect(send).toHaveBeenCalledWith( @@ -20146,7 +20163,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, activate: false, - source: 'runtime-session' + source: 'runtime-session', + viewMode: 'chat' }) ) expect(focusTerminal).not.toHaveBeenCalled() @@ -20504,7 +20522,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'terminal' }) let settled = false const settledCreate = create.finally(() => { @@ -20550,6 +20569,7 @@ describe('OrcaRuntimeService', () => { leafId: pendingLeafId, status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'terminal', isActive: true }) expect(spawn).toHaveBeenCalledWith( @@ -20567,7 +20587,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ ptyId: 'pty-materialized', tabId: 'tab-pending', - leafId: pendingLeafId + leafId: pendingLeafId, + viewMode: 'terminal' }) ) expect(closeTerminal).not.toHaveBeenCalled() @@ -20761,7 +20782,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'chat' }) let settled = false const settledCreate = create.finally(() => { @@ -20769,8 +20791,35 @@ describe('OrcaRuntimeService', () => { }) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + // A shell-only renderer snapshot can win the first race but still omit + // launch props. The later PTY rescue must fill the explicit mode. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-shell', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-alive::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-alive::${leafId}`, + parentTabId: 'tab-alive', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + // The renderer's own PTY spawn registers with the tab binding — the same - // call the pty IPC layer now makes — without any mobileSessionTabs sync. + // call the pty IPC layer now makes — after the shell-only snapshot. runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { tabId: 'tab-alive', leafId @@ -20786,7 +20835,8 @@ describe('OrcaRuntimeService', () => { parentTabId: 'tab-alive', leafId, status: 'ready', - terminal: expect.stringMatching(/^term_/) + terminal: expect.stringMatching(/^term_/), + viewMode: 'chat' }) expect(closeTerminal).not.toHaveBeenCalled() } finally { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 09185299c82..6f1ca862fbe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1020,6 +1020,7 @@ type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string @@ -1318,6 +1319,7 @@ type RuntimeNotifier = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation tabId?: string @@ -2139,7 +2141,11 @@ export class OrcaRuntimeService { // creates so ordinary renderer spawns never publish here. private pendingMobileTerminalCreatesByKey = new Map< string, - { activate: boolean; selectIfNoActiveTab: boolean } + { + activate: boolean + selectIfNoActiveTab: boolean + viewMode?: 'terminal' | 'chat' + } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() // Why: coalesces title/status-driven session.tabs emits so spinner churn @@ -3663,6 +3669,7 @@ export class OrcaRuntimeService { activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string + viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { @@ -3694,6 +3701,17 @@ export class OrcaRuntimeService { baseLayout, args.split ) + // Why: a main-side PTY rescue or split publication must not erase the + // host's explicit tab mode before the renderer graph catches up. + const viewMode = + args.viewMode ?? + existingTab?.viewMode ?? + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.viewMode !== undefined + )?.viewMode const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${args.tabId}::${args.leafId}`, @@ -3703,6 +3721,7 @@ export class OrcaRuntimeService { title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + ...(viewMode ? { viewMode } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) @@ -17718,6 +17737,7 @@ export class OrcaRuntimeService { // Why: explicit background presentation may carry legacy activate // metadata from an already-owned renderer pane; don't select it on mobile. selectIfNoActiveTab: presentation !== 'background', + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } @@ -17736,6 +17756,7 @@ export class OrcaRuntimeService { ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), ...(launchToken ? { launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), tabId, @@ -17811,6 +17832,7 @@ export class OrcaRuntimeService { ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', @@ -17869,6 +17891,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -17913,6 +17936,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -17946,6 +17970,7 @@ export class OrcaRuntimeService { env: startupCommand.env, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -17994,6 +18019,7 @@ export class OrcaRuntimeService { ...(startupCommand.env ? { env: startupCommand.env } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', activate: opts.activate @@ -18012,7 +18038,8 @@ export class OrcaRuntimeService { // requested group, so any wrong-group placement is cosmetic and stall-window-only. this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { activate: opts.activate !== false, - selectIfNoActiveTab: true + selectIfNoActiveTab: true, + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) }) try { // Why: the PTY spawn and the tabCreate reply race on independent IPC @@ -18057,6 +18084,7 @@ export class OrcaRuntimeService { startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -18180,6 +18208,7 @@ export class OrcaRuntimeService { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' targetGroupId?: string launchConfig?: SleepingAgentLaunchConfig } = {} @@ -18197,6 +18226,7 @@ export class OrcaRuntimeService { env: opts.env, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -18218,6 +18248,11 @@ export class OrcaRuntimeService { } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() + if (opts.viewMode) { + // Why: the runtime-owned binding must survive a serve restart with the + // same initial mode, not fall back to a later client's local default. + this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) + } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( @@ -18240,6 +18275,7 @@ export class OrcaRuntimeService { title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } @@ -18386,21 +18422,27 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) - if (existing) { - // Why: the renderer's own publication already landed; stay idempotent. + if ( + existing && + this.isReadyMobileTerminalSurface(existing) && + (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) + ) { + // Why: the renderer's ready publication already landed with the intended + // mode; only a pending shell still needs the main-side PTY rescue. return existing } const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { - return null + return existing } this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { tabId, leafId, title: null, activate: pending.activate, - selectIfNoActiveTab: pending.selectIfNoActiveTab + selectIfNoActiveTab: pending.selectIfNoActiveTab, + ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) }) // Why: waitForMobileTerminalSurface's check closures are drained only inside // syncWindowGraph; a main-side publish must drain them too or the pending diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 0c21af7de72..1b8afbd2f46 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -130,6 +130,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ message: 'Unknown launch agent' }) .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 625959f6d64..d81faa169cd 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -148,6 +148,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ) @@ -167,6 +168,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d27d771f321..55be4d0b21e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -53,6 +53,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 718711b629d..c2a730f9026 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -631,13 +631,20 @@ describe('attachMainWindowServices', () => { const notifier = runtime.setNotifier.mock.calls[0][0] as { revealTerminalSession: ( worktreeId: string, - opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean } + opts: { + ptyId: string + title?: string + cwd?: string + viewMode?: 'terminal' | 'chat' + activate?: boolean + } ) => Promise<{ tabId: string; title?: string }> } const revealPromise = notifier.revealTerminalSession('wt-1', { ptyId: 'pty-1', title: 'SSH tmux', - cwd: '/repo/packages/web' + cwd: '/repo/packages/web', + viewMode: 'chat' }) const sentPayload = sendMock.mock.calls.find( ([channel]) => channel === 'ui:createTerminal' @@ -645,7 +652,7 @@ describe('attachMainWindowServices', () => { const handler = onMock.mock.calls.find( ([channel]) => channel === 'terminal:tabCreateReply' )?.[1] - expect(sentPayload.cwd).toBe('/repo/packages/web') + expect(sentPayload).toMatchObject({ cwd: '/repo/packages/web', viewMode: 'chat' }) handler?.( { sender: { send: vi.fn() } }, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cc03c40a904..0dd54c2c598 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -328,6 +328,7 @@ function registerRuntimeWindowLifecycle( ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), activate: opts.activate !== false, ...(opts.presentation ? { presentation: opts.presentation } : {}), // Why: pre-minted tabId from main keeps the renderer's tab id aligned diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 599c1a8f81b..9fd010a8695 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2800,6 +2800,7 @@ export type PreloadApi = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/preload/index.ts b/src/preload/index.ts index 83466e7b962..9e5b7e419e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3445,6 +3445,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -3467,6 +3468,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index dee27778244..ec7764ec287 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1732,6 +1732,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1760,6 +1761,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -1877,6 +1879,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1906,6 +1909,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -2223,11 +2227,16 @@ describe('useIpcEvents updater integration', () => { targetGroupId: 'group-left', title: 'Runtime Terminal', command: 'codex', + launchAgent: 'codex', + viewMode: 'terminal', activate: true, source: 'runtime-session' }) - expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, undefined) + expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { + launchAgent: 'codex', + viewMode: 'terminal' + }) expect(replyTerminalCreate).toHaveBeenCalledWith({ requestId: 'req-runtime-session', tabId: 'tab-new', @@ -2364,6 +2373,36 @@ describe('useIpcEvents updater integration', () => { } ) + createTab.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-terminal', + launchAgent: 'codex', + viewMode: 'terminal' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-terminal', + activate: false, + launchAgent: 'codex', + viewMode: 'terminal' + }) + + createTab.mockClear() + storeState.settings.openAgentTabsInChatByDefault = false + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-chat', + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-chat', + activate: false, + launchAgent: 'codex', + viewMode: 'chat' + }) + storeState.settings.openAgentTabsInChatByDefault = true + createTab.mockClear() setActiveView.mockClear() setActiveWorktree.mockClear() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b93f3d1610b..e341ff8a185 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1434,6 +1434,7 @@ export function useIpcEvents(): void { launchConfig, launchToken, launchAgent, + viewMode, title, ptyId, activate, @@ -1502,13 +1503,17 @@ export function useIpcEvents(): void { ...(launchAgent ? { launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + // Why: a paired client resolved explicit mode before + // PTY materialization; only omitted mode uses host defaults. + ...(viewMode + ? { viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })) } : {}), ...(cwd ? { startupCwd: cwd } : {}), @@ -1684,16 +1689,20 @@ export function useIpcEvents(): void { if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } + // Why: the paired launch client already resolved the initial mode, so + // its explicit choice must win over this host renderer's local default. const tabOptions = data.launchAgent ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }), + ...(data.viewMode + ? { viewMode: data.viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })), ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fa7283725c4..467ca7d07fe 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -300,7 +300,8 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - agent: 'claude' + agent: 'claude', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() @@ -345,12 +346,61 @@ describe('launchAgentInNewTab', () => { agentArgs: '--model gpt-5 --reasoning-effort high', agentEnv: { CODEX_PROFILE: 'captured' } }, - launchAgent: 'codex' + launchAgent: 'codex', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() }) + it('propagates the default chat mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agent: 'codex', + viewMode: 'chat' + }) + ) + }) + + it('propagates the resolved terminal mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agent: 'codex', + viewMode: 'terminal' + }) + ) + }) + it('surfaces a toast when host agent launch fails in paired web clients', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) mockCreateWebRuntimeSessionTerminal.mockResolvedValue(false) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 52255ac16cf..292cc9e4626 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -201,6 +201,18 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } + // Why: host-owned paired tabs must receive the same initial-view decision as + // local tabs; the remote host cannot infer this client's draft/default choice. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const initialViewModeProps = initialAgentTabViewModeProps(store.settings, { + agent, + promptDelivery: viewModePromptDelivery, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + }) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { launchAgentInWebHostTab({ @@ -210,6 +222,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI groupId, hasPrompt, startupPlan, + // Why: omission means terminal locally, but would let a paired host apply + // its own default; send the client's resolved terminal choice explicitly. + viewMode: initialViewModeProps.viewMode ?? 'terminal', onPromptDelivered }) return { tabId: null, startupPlan, pasteDraftAfterLaunch: false } @@ -224,18 +239,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings, { - agent, - promptDelivery: viewModePromptDelivery, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + ...initialViewModeProps }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index b95ccd8aba1..c685dea9968 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -5,7 +5,7 @@ import { createWebRuntimeSessionTerminal, isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' -import type { TuiAgent } from '../../../shared/types' +import type { Tab, TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { @@ -32,16 +32,26 @@ export function launchAgentInWebHostTab(args: { groupId?: string hasPrompt: boolean startupPlan: AgentStartupPlan + viewMode?: Tab['viewMode'] onPromptDelivered?: () => void }): void { - const { agent, worktreeId, environmentId, groupId, hasPrompt, startupPlan, onPromptDelivered } = - args + const { + agent, + worktreeId, + environmentId, + groupId, + hasPrompt, + startupPlan, + viewMode, + onPromptDelivered + } = args removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) void createWebRuntimeSessionTerminal({ worktreeId, environmentId, targetGroupId: groupId, activate: true, + ...(viewMode ? { viewMode } : {}), ...(hasPrompt ? { command: startupPlan.launchCommand, diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 85025022fea..af47e5a0350 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -490,6 +490,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ).resolves.toBe(true) @@ -510,6 +511,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }, timeoutMs: 15_000 diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 338e92e8656..8cfc23eb19c 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -56,6 +56,7 @@ export async function createWebRuntimeSessionTerminal(args: { launchConfig?: SleepingAgentLaunchConfig agent?: TuiAgent launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean }): Promise { @@ -85,6 +86,7 @@ export async function createWebRuntimeSessionTerminal(args: { ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), agent: args.agent, ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), activate: args.activate !== false }, timeoutMs: 15_000 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 9f91e7dcc8e..028327eb226 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -490,6 +490,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean From 39b2ec832ed2ce6f337840a1e7ed4bf54232e47b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:07:25 -0700 Subject: [PATCH 02/52] fix(native-chat): scope composer sends to the active turn (#8568) * fix(native-chat): scope composer sends to the active turn * fix(native-chat): close pending send lifecycle gaps * fix(native-chat): deduplicate pending message derivation --- .../native-chat/NativeChatComposer.test.tsx | 125 ++++++++++++++ .../native-chat/NativeChatComposer.tsx | 42 +++-- .../components/native-chat/NativeChatView.tsx | 47 ++++-- .../native-chat-pending-occurrence.test.ts | 66 ++++++++ .../native-chat-pending-occurrence.ts | 119 ++++++++++++++ .../native-chat/native-chat-pending.test.ts | 137 ++++++++++++++-- .../native-chat/native-chat-pending.ts | 152 ++++++++++++------ .../native-chat-runtime-send.test.ts | 37 ++++- .../native-chat/native-chat-runtime-send.ts | 17 +- .../use-native-chat-interactive-send.test.tsx | 61 +++++++ .../use-native-chat-interactive-send.ts | 6 +- .../use-native-chat-send-lifecycle.test.tsx | 69 ++++++++ .../use-native-chat-send-lifecycle.ts | 46 ++++++ src/shared/native-chat-streaming.test.ts | 16 ++ 14 files changed, 848 insertions(+), 92 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposer.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx new file mode 100644 index 00000000000..c8be699f8f6 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancelPendingSends: vi.fn(), + fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, + sendNativeChatMessage: vi.fn(), + trackPendingSend: vi.fn(), + setDraft: vi.fn() +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageWithImageAttachments: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./use-native-chat-draft', () => ({ + useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) +})) +vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./NativeChatComposerField', () => ({ + NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { + mocks.fieldProps = props + return null + } +})) +vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] })) +vi.mock('./use-native-chat-composer-attachments', () => ({ + useNativeChatComposerAttachments: () => ({ + imageAttachments: [], + attachResolvedPaths: vi.fn(), + clearImageAttachments: vi.fn(), + removeImageAttachment: vi.fn() + }) +})) +vi.mock('./use-native-chat-composer-paste', () => ({ + useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) +})) +vi.mock('./use-native-chat-external-attachments', () => ({ + useNativeChatExternalAttachments: () => ({ + attachExternalPaths: vi.fn(), + resolveAttachmentOwner: vi.fn() + }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('./use-native-chat-composer-keydown', () => ({ + useNativeChatComposerKeyDown: () => vi.fn() +})) +vi.mock('./use-native-chat-send-lifecycle', () => ({ + useNativeChatSendLifecycle: () => ({ + cancelPendingSends: mocks.cancelPendingSends, + trackPendingSend: mocks.trackPendingSend + }) +})) + +import { NativeChatComposer } from './NativeChatComposer' + +describe('NativeChatComposer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fieldProps = null + mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { onFileDrop: () => vi.fn() } } + }) + }) + + afterEach(() => cleanup()) + + it('cancels delayed composer writes before the Stop button interrupts the agent', () => { + const onStop = vi.fn() + render( + + ) + + act(() => mocks.fieldProps?.onStop?.()) + + expect(mocks.cancelPendingSends).toHaveBeenCalledOnce() + expect(onStop).toHaveBeenCalledOnce() + expect(mocks.cancelPendingSends.mock.invocationCallOrder[0]).toBeLessThan( + onStop.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('associates a delayed submit with its optimistic cache entry', () => { + const onOptimisticSend = vi.fn(() => 'pending-1') + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) + expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d85a3a59bbe..41b254639dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -17,6 +17,7 @@ import { sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' +import type { NativeChatSendHandle } from './native-chat-runtime-send' import { getAgentSlashCommands } from './native-chat-agent-commands' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -44,6 +45,7 @@ import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' +import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -70,7 +72,9 @@ export type NativeChatComposerProps = { onStop?: () => void /** Optional optimistic-send hook: called with the sent text so the view can * render a "queued" echo until the real transcript turn lands (mobile parity). */ - onOptimisticSend?: (text: string, imagePaths?: string[]) => void + onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + /** Remove an optimistic echo when its delayed submit is canceled. */ + onOptimisticSendCanceled?: (pendingId: string) => void /** Called with a dispatched slash command (e.g. `/clear`) so the view can show * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ @@ -111,6 +115,7 @@ export const NativeChatComposer = forwardRef(null) + const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( + terminalTabId, + targetPtyId, + onOptimisticSendCanceled + ) const dictationState = useAppStore((store) => store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' @@ -293,21 +303,33 @@ export const NativeChatComposer = forwardRef 0) { - sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths) + pendingHandle = sendNativeChatMessageWithImageAttachments( + target.settings, + target.ptyId, + text, + imagePaths + ) } else if (text.trim().length > 0) { - sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) } else { submitNativeChatPrompt(target.settings, target.ptyId) } // Slash commands don't echo a user bubble, but DO surface a small // "Ran /clear" system line so the command leaves a visible trace. if (isSlashCommand) { + if (pendingHandle) { + trackPendingSend(pendingHandle) + } onSlashCommand?.(text.trim()) } else { - onOptimisticSend?.(text, imagePaths) + const pendingId = onOptimisticSend?.(text, imagePaths) + if (pendingHandle) { + trackPendingSend(pendingHandle, pendingId) + } } // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. @@ -329,10 +351,12 @@ export const NativeChatComposer = forwardRef { + cancelPendingSends() if (isWorking && onStop) { onStop() return @@ -342,7 +366,7 @@ export const NativeChatComposer = forwardRef { @@ -362,7 +386,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index a9b3bca01ce..1f8af9bcef7 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -25,6 +25,7 @@ import { appendCommandMarkerCache, launchPromptAsMessage, pendingSendsAsMessages, + nextNativeChatPendingSendId, prunePendingSends, readCommandMarkerCache, readPendingSendCache, @@ -211,7 +212,6 @@ function NativeChatResolvedView({ const [pending, setPending] = useState(() => readPendingSendCache(pendingScope) ) - const pendingCounter = useRef(0) // Slash commands aren't chat turns, so they get a small local "Ran /clear" // system line instead of a user bubble. Capped + cached per conversation. const [commandMarkers, setCommandMarkers] = useState(() => @@ -246,14 +246,27 @@ function NativeChatResolvedView({ const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) - pendingCounter.current += 1 + const sentAt = Date.now() + const boundary = session.messages.at(-1) const entry: NativeChatPendingSend = { - id: `${pendingCounter.current}`, + id: nextNativeChatPendingSendId(sentAt), text, - sentAt: Date.now(), + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, ...(imagePaths ? { imagePaths } : {}) } setPending(appendPendingSendCache(pendingScope, entry)) + return entry.id + }, + [pendingScope, session.messages] + ) + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo + // must not come back from the pane cache as a prompt that was delivered. + const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(pendingScope, next)) }, [pendingScope] ) @@ -293,15 +306,20 @@ function NativeChatResolvedView({ // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. - const streamingText = useMemo( - () => - deriveNativeChatStreamingText({ - messages: sessionAfterCommandBoundaries.messages, - previewText: hookPreview, - working: hookWorking - }), - [sessionAfterCommandBoundaries.messages, hookPreview, hookWorking] + const pendingMessages = useMemo( + () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), + [pending, sessionAfterCommandBoundaries.messages] ) + const streamingText = useMemo(() => { + return deriveNativeChatStreamingText({ + messages: + pendingMessages.length > 0 + ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] + : sessionAfterCommandBoundaries.messages, + previewText: hookPreview, + working: hookWorking + }) + }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, hookWorking]) const sessionWithPending = useMemo(() => { if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { return sessionAfterCommandBoundaries @@ -312,10 +330,10 @@ function NativeChatResolvedView({ ...sessionAfterCommandBoundaries.messages, ...commandMarkersAsMessages(commandMarkers), ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages) + ...pendingMessages ] } - }, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText]) + }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) // Derive the view state from the pending-augmented session so a send into an // otherwise-empty conversation flips to the list (showing the queued bubble) // instead of staying on the empty state. @@ -436,6 +454,7 @@ function NativeChatResolvedView({ isWorking={isWorking} onStop={stopAgent} onOptimisticSend={onOptimisticSend} + onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} /> {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts new file mode 100644 index 00000000000..06d7111095d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + appendPendingSendCache, + clearPendingSendCacheForTests, + pendingSendsAsMessages, + prunePendingSends, + type NativeChatPendingSendScope +} from './native-chat-pending' + +const scope: NativeChatPendingSendScope = { paneKey: 'tab:leaf', agent: 'codex' } + +function message( + id: string, + role: 'user' | 'assistant', + text: string, + timestamp: number +): NativeChatMessage { + return { + id, + role, + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } +} + +describe('pending send occurrence reconciliation', () => { + beforeEach(() => clearPendingSendCacheForTests()) + + it('keeps the next identical echo after pruning an earlier occurrence', () => { + const first = appendPendingSendCache(scope, { + id: 'p1', + text: 'repeat', + sentAt: 100, + afterMessageId: 'paged-out-boundary' + }) + const repeated = appendPendingSendCache(scope, { + id: 'p2', + text: 'repeat', + sentAt: 200, + afterMessageId: 'paged-out-boundary' + }) + expect(first[0]?.matchingOccurrence).toBeUndefined() + expect(repeated[1]).toMatchObject({ matchingOccurrence: 2, matchingAfterTimestamp: 100 }) + + const firstCompletedTurn = [ + message('u1', 'user', 'repeat', 150), + message('a1', 'assistant', 'done', 160) + ] + const afterFirstPrune = prunePendingSends(repeated, firstCompletedTurn) + + expect(afterFirstPrune.map((entry) => entry.id)).toEqual(['p2']) + expect( + pendingSendsAsMessages(afterFirstPrune, firstCompletedTurn).map((entry) => entry.id) + ).toEqual(['pending:p2']) + + const secondCompletedTurn = [ + ...firstCompletedTurn, + message('u2', 'user', 'repeat', 250), + message('a2', 'assistant', 'done again', 260) + ] + expect(pendingSendsAsMessages(afterFirstPrune, secondCompletedTurn)).toEqual([]) + expect(prunePendingSends(afterFirstPrune, secondCompletedTurn)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts new file mode 100644 index 00000000000..3c94e5bfadd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -0,0 +1,119 @@ +import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../../shared/native-chat-types' + +export type NativeChatPendingOccurrence = { + text: string + imagePaths?: readonly string[] + sentAt: number + afterMessageId?: string | null + afterMessageTimestamp?: number | null + matchingOccurrence?: number + matchingAfterTimestamp?: number +} + +export function normalizeNativeChatPendingText(text: string): string { + return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') +} + +export function nativeChatPendingContentKey( + pending: Pick +): string { + const text = normalizeNativeChatPendingText(pending.text) + if (text) { + return `text:${text}` + } + const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] + return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty' +} + +function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join(' ') + const imagePaths = message.blocks + .filter(isImageRefBlock) + .map((block) => block.path) + .filter((path): path is string => Boolean(path)) + const key = nativeChatPendingContentKey({ text, imagePaths }) + return key === 'empty' ? null : key +} + +export function matchingNativeChatUserContentCounts( + messages: readonly NativeChatMessage[] +): Map { + const counts = new Map() + for (const message of messages) { + const key = nativeChatUserMessageContentKey(message) + if (key) { + counts.set(key, (counts.get(key) ?? 0) + 1) + } + } + return counts +} + +export function advancedNativeChatUserContentCounts( + messages: readonly NativeChatMessage[] +): Map { + const advanced = new Map() + const waiting = new Map() + for (const message of messages) { + if (message.role === 'user') { + const key = nativeChatUserMessageContentKey(message) + if (key) { + waiting.set(key, (waiting.get(key) ?? 0) + 1) + } + continue + } + for (const [key, count] of waiting) { + advanced.set(key, (advanced.get(key) ?? 0) + count) + } + waiting.clear() + } + return advanced +} + +export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string { + return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}` +} + +export function assignNativeChatPendingOccurrence( + existing: readonly T[], + entry: T +): T { + const key = nativeChatPendingMatchKey(entry) + const matching = existing.filter((candidate) => nativeChatPendingMatchKey(candidate) === key) + if (matching.length === 0) { + return entry + } + const previousOccurrence = Math.max( + ...matching.map((candidate, index) => candidate.matchingOccurrence ?? index + 1) + ) + const first = matching[0] + // Why: pruning an earlier echo must not let a later identical send reuse the + // same transcript occurrence, even after the read pages out its boundary. + return { + ...entry, + matchingOccurrence: previousOccurrence + 1, + matchingAfterTimestamp: + first?.matchingAfterTimestamp ?? first?.afterMessageTimestamp ?? first?.sentAt + } +} + +export function nativeChatPendingMatchingAfter(pending: NativeChatPendingOccurrence): number { + return pending.matchingAfterTimestamp ?? pending.afterMessageTimestamp ?? pending.sentAt +} + +export function nativeChatPendingOccurrence( + pending: NativeChatPendingOccurrence, + alreadyConsumed: number +): number { + return pending.matchingOccurrence ?? alreadyConsumed + 1 +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 58393460467..b63dba307b2 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -11,6 +11,7 @@ import { isLaunchPromptMessageId, isPendingMessageId, launchPromptAsMessage, + nextNativeChatPendingSendId, pendingSendsAsMessages, prunePendingSends, readCommandMarkerCache, @@ -41,6 +42,16 @@ function assistantMessage(id: string, text: string): NativeChatMessage { } } +function imageMessage(id: string, ...paths: string[]): NativeChatMessage { + return { + id, + role: 'user', + blocks: paths.map((path) => ({ type: 'image-ref' as const, path })), + timestamp: 1, + source: 'transcript' + } +} + const pendingOf = (id: string, text: string): NativeChatPendingSend => ({ id, text, sentAt: 100 }) describe('prunePendingSends', () => { @@ -84,6 +95,16 @@ describe('prunePendingSends', () => { expect(next).toEqual([]) }) + it('drops an attachment-only pending send once its image turn advances', () => { + const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/first.png', '/tmp/second.png'] }] + const transcript = [ + imageMessage('m1', '/tmp/first.png', '/tmp/second.png'), + assistantMessage('m2', 'two images') + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + it('keeps a pending send that has not landed yet', () => { const pending = [pendingOf('p1', 'not yet')] const next = prunePendingSends(pending, [assistantMessage('m1', 'working on it')]) @@ -104,6 +125,21 @@ describe('prunePendingSends', () => { ]) expect(next).toEqual([pendingOf('p2', 'second')]) }) + + it('does not prune a repeated prompt against a turn before its send boundary', () => { + const oldUser = userMessage('old-user', 'run tests') + const oldAnswer = assistantMessage('old-answer', 'passed') + const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: oldAnswer.id }] + + expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending) + }) + + it('prunes only one of two identical pending sends for one completed turn', () => { + const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] + expect( + prunePendingSends(pending, [userMessage('u1', 'repeat'), assistantMessage('a1', 'done')]) + ).toEqual([pendingOf('p2', 'repeat')]) + }) }) describe('pendingSendsAsMessages', () => { @@ -130,12 +166,65 @@ describe('pendingSendsAsMessages', () => { ]) }) + it('hides an attachment-only pending send while its real image turn is visible', () => { + const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/shot.png'] }] + + expect(pendingSendsAsMessages(pending, [imageMessage('u1', '/tmp/shot.png')])).toEqual([]) + }) + it('hides a pending send while its real user turn is visible', () => { const pending = [pendingOf('p1', 'first prompt')] expect(pendingSendsAsMessages(pending, [userMessage('u1', 'first prompt')])).toEqual([]) expect(pendingSendsAsMessages(pending, [])).toHaveLength(1) }) + + it('keeps a repeated prompt visible when its only match predates the send boundary', () => { + const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')] + const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }] + + expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([ + 'pending:new-send' + ]) + }) + + it('keeps a loading-time send visible when older matching history arrives later', () => { + const history = [ + { ...userMessage('old-user', 'run tests'), timestamp: 10 }, + { ...assistantMessage('old-answer', 'passed'), timestamp: 20 } + ] + const pending = [{ ...pendingOf('new-send', 'run tests'), sentAt: 100, afterMessageId: null }] + + expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([ + 'pending:new-send' + ]) + expect(prunePendingSends(pending, history)).toEqual(pending) + }) + + it('uses the transcript boundary clock after pagination, not the renderer send clock', () => { + const pending = [ + { + ...pendingOf('new-send', 'run tests'), + sentAt: 100_000, + afterMessageId: 'paged-out-answer', + afterMessageTimestamp: 20 + } + ] + const remoteTranscript = [ + { ...userMessage('new-user', 'run tests'), timestamp: 30 }, + { ...assistantMessage('new-answer', 'passed'), timestamp: 40 } + ] + + expect(pendingSendsAsMessages(pending, remoteTranscript)).toEqual([]) + expect(prunePendingSends(pending, remoteTranscript)).toEqual([]) + }) + + it('hides only one of two identical pending sends for one real user turn', () => { + const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] + expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual( + ['pending:p2'] + ) + }) }) describe('launchPromptAsMessage', () => { @@ -165,7 +254,7 @@ describe('launchPromptAsMessage', () => { text: 'Fix failing checks', createdAt: 42 }, - [userMessage('u1', 'Fix failing checks')] + [{ ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }] ) ).toBeNull() }) @@ -180,11 +269,14 @@ describe('launchPromptAsMessage', () => { ' fix spacing' ].join('\n') const transcript = [ - userMessage( - 'u1', - 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' - ), - assistantMessage('a1', 'I will fix it') + { + ...userMessage( + 'u1', + 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing' + ), + timestamp: 43 + }, + { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } ] expect( @@ -208,14 +300,34 @@ describe('launchPromptAsMessage', () => { createdAt: 42 } - expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false) expect( shouldPruneLaunchPrompt(prompt, [ - userMessage('u1', 'Fix failing checks'), - assistantMessage('a1', 'working') + { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 } + ]) + ).toBe(false) + expect( + shouldPruneLaunchPrompt(prompt, [ + { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }, + { ...assistantMessage('a1', 'working'), timestamp: 44 } ]) ).toBe(true) }) + + it('does not bind a launch prompt to an older identical completed turn', () => { + const entry = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'run tests', + createdAt: 100 + } + const oldHistory = [ + { ...userMessage('old-user', 'run tests'), timestamp: 10 }, + { ...assistantMessage('old-answer', 'passed'), timestamp: 20 } + ] + + expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull() + expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false) + }) }) describe('pending send cache', () => { @@ -230,6 +342,13 @@ describe('pending send cache', () => { expect(readPendingSendCache({ ...scope, agent: 'claude' })).toEqual([]) }) + it('mints unique ids across chat-view remounts while the cache survives', () => { + clearPendingSendCacheForTests() + const first = nextNativeChatPendingSendId(100) + const second = nextNativeChatPendingSendId(100) + expect(second).not.toBe(first) + }) + it('clears cached pending sends when pruning removes all entries', () => { clearPendingSendCacheForTests() const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index c03f4c0b96a..92dbaf17ef7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -1,12 +1,20 @@ // Pure logic for desktop optimistic "queued" composer sends (mobile parity). // A sent prompt is echoed immediately as a queued entry and pruned once its real // user turn lands in the transcript. Kept separate from the view so the prune -// rule (match on normalized user-message text) is unit-testable without React. +// rule (match on normalized user-message content) is unit-testable without React. -import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { stripImagePromptMarker } from './native-chat-image-transcript-markers' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import { + advancedNativeChatUserContentCounts, + assignNativeChatPendingOccurrence, + matchingNativeChatUserContentCounts, + nativeChatPendingContentKey, + nativeChatPendingMatchKey, + nativeChatPendingMatchingAfter, + nativeChatPendingOccurrence +} from './native-chat-pending-occurrence' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -18,6 +26,15 @@ export type NativeChatPendingSend = { imagePaths?: string[] /** Epoch ms when the send was issued, so the queued bubble sorts to the end. */ sentAt: number + /** Last authoritative transcript message visible when this send was issued. + * Matching starts after it so repeated prompts cannot bind to an old turn. */ + afterMessageId?: string | null + /** Timestamp of that boundary in the transcript host's clock domain. */ + afterMessageTimestamp?: number | null + /** 1-based occurrence among identical sends sharing the same boundary. */ + matchingOccurrence?: number + /** Shared time boundary when that message boundary is unavailable. */ + matchingAfterTimestamp?: number } export type NativeChatPendingSendScope = { @@ -27,6 +44,7 @@ export type NativeChatPendingSendScope = { const PENDING_SEND_LIMIT = 8 const pendingSendCache = new Map() +let pendingSendCounter = 0 function pendingSendScopeKey(scope: NativeChatPendingSendScope): string { return `${scope.paneKey}\0${scope.agent}` @@ -57,56 +75,48 @@ export function appendPendingSendCache( scope: NativeChatPendingSendScope, entry: NativeChatPendingSend ): NativeChatPendingSend[] { - return writePendingSendCache(scope, [...readPendingSendCache(scope), entry]) + const existing = readPendingSendCache(scope) + const next = assignNativeChatPendingOccurrence(existing, entry) + return writePendingSendCache(scope, [...existing, next]) } export function clearPendingSendCacheForTests(): void { pendingSendCache.clear() + pendingSendCounter = 0 } -function normalize(text: string): string { - return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') -} - -/** The prose of a user message, normalized for matching against a pending send. */ -function userMessageText(message: NativeChatMessage): string | null { - if (message.role !== 'user') { - return null +function messagesAfterPendingBoundary( + messages: readonly NativeChatMessage[], + pending: NativeChatPendingSend +): readonly NativeChatMessage[] { + if (pending.afterMessageId === undefined) { + return messages } - const text = message.blocks - .filter(isTextBlock) - .map((block) => block.text) - .join(' ') - return normalize(text) + if (pending.afterMessageId === null) { + return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending)) + } + const boundaryIndex = messages.findIndex((message) => message.id === pending.afterMessageId) + if (boundaryIndex >= 0) { + return messages.slice(boundaryIndex + 1) + } + // A bounded authoritative read can page the boundary out. Fall back to the + // send time instead of matching an arbitrary older identical prompt. + return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending)) } -function matchingUserMessageTexts(messages: NativeChatMessage[]): Set { - const texts = new Set() - for (const message of messages) { - const text = userMessageText(message) - if (text) { - texts.add(text) - } +function messageIsAfterPendingTimestamp( + message: NativeChatMessage, + pending: NativeChatPendingSend +): boolean { + if (message.timestamp === null) { + return false } - return texts -} - -function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set { - const advanced = new Set() - const waiting = new Set() - for (const message of messages) { - if (message.role === 'user') { - const text = userMessageText(message) - if (text) { - waiting.add(text) - } - continue - } - for (const text of waiting) { - advanced.add(text) - } - } - return advanced + const boundary = nativeChatPendingMatchingAfter(pending) + // A transcript-clock boundary describes an existing message, so exclude ties. + // Local send time has no existing record and remains inclusive. + return pending.afterMessageTimestamp == null + ? message.timestamp >= boundary + : message.timestamp > boundary } /** @@ -122,8 +132,22 @@ export function prunePendingSends( if (pending.length === 0) { return pending } - const advanced = advancedPastUserMessageTexts(messages) - const next = pending.filter((entry) => !advanced.has(normalize(entry.text))) + const consumed = new Map() + const next = pending.filter((entry) => { + const contentKey = nativeChatPendingContentKey(entry) + const key = nativeChatPendingMatchKey(entry) + const available = + advancedNativeChatUserContentCounts(messagesAfterPendingBoundary(messages, entry)).get( + contentKey + ) ?? 0 + const used = consumed.get(key) ?? 0 + const occurrence = nativeChatPendingOccurrence(entry, used) + consumed.set(key, Math.max(used, occurrence)) + if (occurrence > available) { + return true + } + return false + }) return next.length === pending.length ? pending : next } @@ -137,9 +161,23 @@ export function pendingSendsAsMessages( pending: NativeChatPendingSend[], existingMessages: NativeChatMessage[] = [] ): NativeChatMessage[] { - const represented = matchingUserMessageTexts(existingMessages) + const consumed = new Map() return pending - .filter((entry) => !represented.has(normalize(entry.text))) + .filter((entry) => { + const contentKey = nativeChatPendingContentKey(entry) + const key = nativeChatPendingMatchKey(entry) + const represented = + matchingNativeChatUserContentCounts( + messagesAfterPendingBoundary(existingMessages, entry) + ).get(contentKey) ?? 0 + const used = consumed.get(key) ?? 0 + const occurrence = nativeChatPendingOccurrence(entry, used) + consumed.set(key, Math.max(used, occurrence)) + if (occurrence > represented) { + return true + } + return false + }) .map((entry) => ({ id: `pending:${entry.id}`, role: 'user' as const, @@ -167,8 +205,12 @@ export function launchPromptAsMessage( if (!entry) { return null } - const represented = matchingUserMessageTexts(existingMessages) - if (represented.has(normalize(entry.text))) { + const represented = matchingNativeChatUserContentCounts( + existingMessages.filter( + (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + ) + ) + if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) { return null } return { @@ -187,7 +229,17 @@ export function shouldPruneLaunchPrompt( entry: NativeChatLaunchPrompt, messages: NativeChatMessage[] ): boolean { - return advancedPastUserMessageTexts(messages).has(normalize(entry.text)) + const relevant = messages.filter( + (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + ) + return ( + (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0 + ) +} + +export function nextNativeChatPendingSendId(now = Date.now()): string { + pendingSendCounter += 1 + return `${now}-${pendingSendCounter}` } export function isLaunchPromptMessageId(id: string): boolean { diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 20badc4d9e4..25efca9af09 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -37,7 +37,7 @@ describe('sendNativeChatMessage', () => { }) it('writes the framed body immediately, before the Enter', () => { - sendNativeChatMessage(SETTINGS, PTY, 'hello world') + const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world') // Body lands synchronously; Enter is still pending on the timer. expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) expect(sendRuntimePtyInput).toHaveBeenCalledWith( @@ -45,6 +45,7 @@ describe('sendNativeChatMessage', () => { PTY, buildNativeChatPasteBytes('hello world') ) + expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) }) it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => { @@ -62,6 +63,14 @@ describe('sendNativeChatMessage', () => { expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + it('cancels the delayed Enter when its owning composer is detached', () => { + const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi') + handle.cancel() + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + }) + it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => { expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) }) @@ -77,10 +86,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => { - sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [ + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [ '/tmp/orca-paste-image.png' ]) + expect(handle.settleAfterMs).toBe( + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + ) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, @@ -102,7 +115,11 @@ describe('sendNativeChatMessageWithImageAttachments', () => { }) it('waits the normal submit gap for an attachment-only send', () => { - sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', ['/tmp/orca-paste-image.png']) + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', [ + '/tmp/orca-paste-image.png' + ]) + + expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) @@ -111,6 +128,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) + + it('cancels deferred prompt and Enter writes after the attachment path', () => { + const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'describe', [ + '/tmp/orca-paste-image.png' + ]) + handle.cancel() + vi.runAllTimers() + + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + }) }) describe('empty prompt submit', () => { @@ -160,7 +187,9 @@ describe('sendNativeChatAnswer', () => { it('multi-line: 3 bodies + 3 Enters in order, each Enter 500ms after its body, next body only after prior Enter+buffer', () => { const lines = ['answer one', 'answer two', 'answer three'] - sendNativeChatAnswer(SETTINGS, PTY, lines) + const handle = sendNativeChatAnswer(SETTINGS, PTY, lines) + + expect(handle.settleAfterMs).toBe(nativeChatQuestionOffsets(lines.length - 1).enterAt) // Nothing fires synchronously: even question 0's body is scheduled (setTimeout 0). expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 4a2b9e54381..1f5721e07df 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -51,7 +51,11 @@ export function nativeChatQuestionOffsets(index: number): { /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ -export type NativeChatSendHandle = { cancel: () => void } +export type NativeChatSendHandle = { + cancel: () => void + /** Time after which every scheduled write has fired and the handle can drop. */ + settleAfterMs: number +} /** * Send a native-chat message through the verified runtime pty path: framed body @@ -68,7 +72,7 @@ export function sendNativeChatMessage( const timer = setTimeout(() => { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) }, NATIVE_CHAT_SUBMIT_DELAY_MS) - return { cancel: () => clearTimeout(timer) } + return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } export function sendNativeChatMessageWithImageAttachments( @@ -107,7 +111,11 @@ export function sendNativeChatMessageWithImageAttachments( for (const timer of timers) { clearTimeout(timer) } - } + }, + settleAfterMs: + trimmedText.length > 0 + ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS } } @@ -157,6 +165,7 @@ export function sendNativeChatAnswer( for (const timer of timers) { clearTimeout(timer) } - } + }, + settleAfterMs: nativeChatQuestionOffsets(lines.length - 1).enterAt } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx new file mode 100644 index 00000000000..09075d99903 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + sendRuntimePtyInput: vi.fn(), + sendNativeChatAnswer: vi.fn(), + sendNativeChatMessage: vi.fn() +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId }) +})) + +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatAnswer: (...args: unknown[]) => mocks.sendNativeChatAnswer(...args), + sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args) +})) + +import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' + +describe('useNativeChatInteractiveSend', () => { + beforeEach(() => { + vi.clearAllMocks() + const handle = { cancel: mocks.cancel, settleAfterMs: 500 } + mocks.sendNativeChatAnswer.mockReturnValue(handle) + mocks.sendNativeChatMessage.mockReturnValue(handle) + }) + + it('cancels delayed answer writes when the PTY target changes', () => { + const { result, rerender } = renderHook( + ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.sendAnswer('continue')) + rerender({ targetPtyId: 'pty-2' }) + + expect(mocks.cancel).toHaveBeenCalledOnce() + }) + + it('cancels delayed answer writes before interrupting the active PTY', () => { + const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude')) + + act(() => result.current.sendAnswer('one\ntwo')) + act(() => result.current.cancel()) + + expect(mocks.cancel).toHaveBeenCalledOnce() + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + '\x1b' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 7065fc19cea..36d7d8cb1ea 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' @@ -42,7 +42,9 @@ export function useNativeChatInteractiveSend( inFlightRef.current?.cancel() inFlightRef.current = null }, []) - useEffect(() => cancelInFlight, [cancelInFlight]) + // Why: a split can be rebound without unmounting this view. Cancel during + // commit so no delayed answer write can race the replacement PTY. + useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId]) const sendRaw = useCallback( (raw: string) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx new file mode 100644 index 00000000000..63525c1e3e7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' + +function handle(settleAfterMs = 500) { + return { cancel: vi.fn<() => void>(), settleAfterMs } +} + +describe('useNativeChatSendLifecycle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('cancels owned writes when the PTY target changes and when the composer unmounts', () => { + vi.useFakeTimers() + const first = handle() + const second = handle() + const onPendingSendCanceled = vi.fn() + const { result, rerender, unmount } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(first, 'pending-1')) + rerender({ targetPtyId: 'pty-2' }) + expect(first.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + + act(() => result.current.trackPendingSend(second, 'pending-2')) + unmount() + expect(second.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-2') + }) + + it('cancels pending writes immediately on interrupt without double-cancelling', () => { + vi.useFakeTimers() + const pending = handle() + const onPendingSendCanceled = vi.fn() + const { result, unmount } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled) + ) + + act(() => result.current.trackPendingSend(pending, 'pending-1')) + act(() => result.current.cancelPendingSends()) + expect(pending.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + + unmount() + expect(pending.cancel).toHaveBeenCalledOnce() + }) + + it('drops settled handles so a later interrupt does not revisit completed sends', () => { + vi.useFakeTimers() + const settled = handle(800) + const onPendingSendCanceled = vi.fn() + const { result } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled) + ) + + act(() => result.current.trackPendingSend(settled, 'pending-1')) + act(() => vi.advanceTimersByTime(settled.settleAfterMs)) + act(() => result.current.cancelPendingSends()) + + expect(settled.cancel).not.toHaveBeenCalled() + expect(onPendingSendCanceled).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts new file mode 100644 index 00000000000..41e46148bfe --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -0,0 +1,46 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { NativeChatSendHandle } from './native-chat-runtime-send' + +export type NativeChatSendLifecycle = { + cancelPendingSends: () => void + trackPendingSend: (handle: NativeChatSendHandle, pendingId?: string) => void +} + +export function useNativeChatSendLifecycle( + terminalTabId: string, + targetPtyId: string | null, + onPendingSendCanceled?: (pendingId: string) => void +): NativeChatSendLifecycle { + const pendingSendHandlesRef = useRef( + new Map< + NativeChatSendHandle, + { cleanupTimer: ReturnType; pendingId?: string } + >() + ) + const cancelPendingSends = useCallback(() => { + for (const [handle, entry] of pendingSendHandlesRef.current) { + const { cleanupTimer, pendingId } = entry + clearTimeout(cleanupTimer) + handle.cancel() + if (pendingId) { + onPendingSendCanceled?.(pendingId) + } + } + pendingSendHandlesRef.current.clear() + }, [onPendingSendCanceled]) + const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { + const cleanupTimer = setTimeout(() => { + pendingSendHandlesRef.current.delete(handle) + }, handle.settleAfterMs) + pendingSendHandlesRef.current.set(handle, { + cleanupTimer, + ...(pendingId ? { pendingId } : {}) + }) + }, []) + + // Why: delayed Enter/image writes belong to the exact PTY target. A pane + // swap or unmount must cancel them before that PTY can close or be reused. + useLayoutEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId]) + + return { cancelPendingSends, trackPendingSend } +} diff --git a/src/shared/native-chat-streaming.test.ts b/src/shared/native-chat-streaming.test.ts index 689f83f9d5b..8795b1b4430 100644 --- a/src/shared/native-chat-streaming.test.ts +++ b/src/shared/native-chat-streaming.test.ts @@ -47,6 +47,22 @@ describe('deriveNativeChatStreamingText', () => { ).toBe('Working on it') }) + it('treats an optimistic user echo as the active streaming-turn boundary', () => { + const optimistic = { + ...user('new prompt'), + id: 'pending:send-1', + timestamp: 20, + source: 'scrape' as const + } + expect( + deriveNativeChatStreamingText({ + messages: [assistant('A much longer answer from the completed prior turn'), optimistic], + previewText: 'New reply', + working: true + }) + ).toBe('New reply') + }) + it('drops the preview once the real assistant turn contains it (no duplicate)', () => { expect( deriveNativeChatStreamingText({ From 5be44d1553423187a5167a75c39fa1bea5895aea Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:07:52 -0700 Subject: [PATCH 03/52] fix(native-chat): target native chat to the active split leaf (#8569) * fix(native-chat): target native chat to the active split leaf * fix(native-chat): harden split leaf reassignment * fix(native-chat): tolerate missing layout state --- .../native-chat-leaf-routing.test.ts | 172 ++++++++++++++++++ .../native-chat/native-chat-leaf-routing.ts | 94 ++++++++++ .../use-native-chat-toggle-shortcut.test.ts | 94 ++++++++-- .../use-native-chat-toggle-shortcut.ts | 28 +-- .../src/components/tab-bar/TabBar.tsx | 23 ++- .../tab-bar/tab-agent-types-by-tab-id.test.ts | 144 ++++++++++++++- .../tab-bar/tab-agent-types-by-tab-id.ts | 47 ++++- .../components/terminal-pane/TerminalPane.tsx | 150 ++++++++++----- .../terminal-pane-close-identity.test.ts | 32 ++++ .../terminal-pane-close-identity.ts | 19 ++ .../terminal-parked-tab-watchers.test.ts | 69 ++++++- .../terminal-parked-tab-watchers.ts | 21 +++ .../use-terminal-pane-lifecycle.ts | 16 +- 13 files changed, 820 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-leaf-routing.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts new file mode 100644 index 00000000000..1d284d64b3e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from './native-chat-leaf-routing' + +describe('nativeChatLaunchAgentForLeaf', () => { + it('uses the tab launch hint only for its sole leaf', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: ['leaf-a'] + }) + ).toBe('claude') + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-b', + leafIds: ['leaf-a'] + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'claude', + launchAgentLeafId: 'leaf-a', + leafId: 'leaf-a', + leafIds: [] + }) + ).toBeNull() + }) + + it('does not lend the original launch agent to either leaf of a mixed split', () => { + const leafIds = ['agent-leaf', 'shell-leaf'] + + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'agent-leaf', + leafIds + }) + ).toBeNull() + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'agent-leaf', + leafId: 'shell-leaf', + leafIds + }) + ).toBeNull() + }) + + it('does not transfer the launch hint when the original leaf closes', () => { + expect( + nativeChatLaunchAgentForLeaf({ + launchAgent: 'codex', + launchAgentLeafId: 'closed-agent-leaf', + leafId: 'remaining-shell-leaf', + leafIds: ['remaining-shell-leaf'] + }) + ).toBeNull() + }) +}) + +describe('resolveNativeChatLeafRoute', () => { + it('keeps chat attached to its eligible leaf when focus moves to a shell sibling', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'agent-leaf', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'agent-leaf', exitChat: false }) + }) + + it('moves chat to an eligible active sibling after its leaf closes', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-leaf', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + + it('exits chat rather than inheriting an active shell after close', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'closed-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'stopped-agent', + activeLeafId: 'shell-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('attaches a tab-level chat request to the eligible active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'active-agent', + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: 'active-agent', exitChat: false }) + }) + + it('waits through manager hydration when there is no concrete active leaf', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'restored-agent', + activeLeafId: null, + chatLeafStillMounted: false, + chatLeafIsEligible: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: 'restored-agent', exitChat: false }) + }) + + it('clears leaf ownership after returning to terminal view', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: false, + chatLeafId: 'agent-leaf', + activeLeafId: 'agent-leaf', + chatLeafStillMounted: true, + chatLeafIsEligible: true, + activeLeafIsEligible: true + }) + ).toEqual({ chatLeafId: null, exitChat: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts new file mode 100644 index 00000000000..f381f589014 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -0,0 +1,94 @@ +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TuiAgent +} from '../../../../shared/types' + +function layoutNodeContainsLeaf(node: TerminalPaneLayoutNode | null, leafId: string): boolean { + if (!node) { + return false + } + if (node.type === 'leaf') { + return node.leafId === leafId + } + return layoutNodeContainsLeaf(node.first, leafId) || layoutNodeContainsLeaf(node.second, leafId) +} + +export function resolveNativeChatActiveLayoutLeafId( + layout: TerminalLayoutSnapshot | null | undefined +): string | null { + if (!layout) { + return null + } + if (layout.activeLeafId) { + // Why: close/hydration races can leave activeLeafId one snapshot behind + // the topology; stale pane evidence must not route chat to a removed leaf. + return !layout.root || layoutNodeContainsLeaf(layout.root, layout.activeLeafId) + ? layout.activeLeafId + : null + } + return layout.root?.type === 'leaf' ? layout.root.leafId : null +} + +export function isNativeChatTabWideFallbackSafe( + layout: TerminalLayoutSnapshot | null | undefined +): boolean { + if (!layout?.root) { + return true + } + if (layout.root.type === 'split') { + return false + } + // Why: a stale active id means the single-leaf collapse is not yet settled; + // tab-wide launch/title evidence could still describe the removed sibling. + return !layout.activeLeafId || layout.activeLeafId === layout.root.leafId +} + +export function nativeChatLaunchAgentForLeaf(args: { + launchAgent?: TuiAgent | null + launchAgentLeafId: string | null + leafId: string | null + leafIds: readonly string[] +}): TuiAgent | null { + const { launchAgent, launchAgentLeafId, leafId, leafIds } = args + if (!launchAgent || !launchAgentLeafId || !leafId) { + return null + } + // Why: launchAgent belongs to the tab's original pane. Once a split exists, + // it is not evidence that an agent is running in any particular sibling. + return leafIds.length === 1 && leafIds[0] === leafId && launchAgentLeafId === leafId + ? launchAgent + : null +} + +export type NativeChatLeafRoute = { + chatLeafId: string | null + exitChat: boolean +} + +export function resolveNativeChatLeafRoute(args: { + isChatViewMode: boolean + chatLeafId: string | null + activeLeafId: string | null + chatLeafStillMounted: boolean + chatLeafIsEligible: boolean + activeLeafIsEligible: boolean +}): NativeChatLeafRoute { + if (!args.isChatViewMode) { + return { chatLeafId: null, exitChat: false } + } + if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + // Manager hydration can briefly have no active pane; preserve the requested + // mode until a concrete leaf exists instead of toggling it off during mount. + if (!args.activeLeafId) { + return { chatLeafId: args.chatLeafId, exitChat: false } + } + if (args.activeLeafIsEligible) { + return { chatLeafId: args.activeLeafId, exitChat: false } + } + // Why: closing or invalidating the chat-owning leaf must not move its composer + // onto a plain-shell sibling. Return the tab to terminal mode instead. + return { chatLeafId: null, exitChat: true } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts index bc89a09539f..d2a037da269 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts @@ -1,15 +1,24 @@ import { describe, expect, it } from 'vitest' -import { - isNativeChatShortcutTitleFallbackSafe, - resolveNativeChatToggleShortcutDetectedAgent -} from './use-native-chat-toggle-shortcut' +import { isNativeChatTabWideFallbackSafe } from './native-chat-leaf-routing' +import { resolveNativeChatToggleShortcutDetectedAgent } from './use-native-chat-toggle-shortcut' + +const splitLayout = { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, leafId: 'leaf-1' }, + second: { type: 'leaf' as const, leafId: 'leaf-2' } + }, + activeLeafId: 'leaf-2', + expandedLeafId: null +} describe('resolveNativeChatToggleShortcutDetectedAgent', () => { it('uses the active split leaf instead of the first tab agent entry', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'gemini' }, 'tab-1:leaf-2': { agentType: 'codex' } @@ -22,7 +31,7 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: 'leaf-2', + terminalLayout: splitLayout, agentStatusByPaneKey: { 'tab-1:leaf-1': { agentType: 'claude' }, 'tab-1:leaf-2': { agentType: 'grok' } @@ -35,7 +44,6 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { expect( resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: 'tab-1', - activeLeafId: null, agentStatusByPaneKey: { 'tab-2:leaf-1': { agentType: 'codex' }, 'tab-1:leaf-1': { agentType: 'claude' } @@ -43,24 +51,80 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => { }) ).toBe('claude') }) + + it('does not inherit a split sibling before the active leaf is known', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { ...splitLayout, activeLeafId: null }, + agentStatusByPaneKey: { + 'tab-1:agent-leaf': { agentType: 'claude' }, + 'tab-1:shell-leaf': {} + } + }) + ).toBeNull() + }) + + it('uses the sole layout leaf when activeLeafId has not hydrated yet', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: null, + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBe('codex') + }) + + it('rejects a stale active leaf instead of reading its retained status', () => { + expect( + resolveNativeChatToggleShortcutDetectedAgent({ + terminalTabId: 'tab-1', + terminalLayout: { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + }, + agentStatusByPaneKey: { + 'tab-1:closed-leaf': { agentType: 'claude' }, + 'tab-1:leaf-2': { agentType: 'codex' } + } + }) + ).toBeNull() + }) }) -describe('isNativeChatShortcutTitleFallbackSafe', () => { +describe('isNativeChatTabWideFallbackSafe', () => { it('allows title fallback before a layout snapshot exists', () => { - expect(isNativeChatShortcutTitleFallbackSafe(null)).toBe(true) + expect(isNativeChatTabWideFallbackSafe(null)).toBe(true) }) it('allows title fallback for a single leaf layout', () => { - expect(isNativeChatShortcutTitleFallbackSafe({ type: 'leaf', leafId: 'leaf-1' })).toBe(true) + expect( + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + }) + ).toBe(true) }) it('rejects title fallback for split layouts', () => { + expect(isNativeChatTabWideFallbackSafe(splitLayout)).toBe(false) + }) + + it('rejects title fallback while a collapsed layout still has a stale active id', () => { expect( - isNativeChatShortcutTitleFallbackSafe({ - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: 'leaf-1' }, - second: { type: 'leaf', leafId: 'leaf-2' } + isNativeChatTabWideFallbackSafe({ + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null }) ).toBe(false) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index c53b5263de0..3096077e820 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -1,31 +1,33 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { TerminalPaneLayoutNode } from '../../../../shared/types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' import { getConnectionIdFromState } from '@/lib/connection-context' - -export function isNativeChatShortcutTitleFallbackSafe( - root: TerminalPaneLayoutNode | null | undefined -): boolean { - return !root || root.type === 'leaf' -} +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from './native-chat-leaf-routing' export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, - activeLeafId, + terminalLayout, agentStatusByPaneKey }: { terminalTabId: string - activeLeafId: string | null + terminalLayout?: TerminalLayoutSnapshot | null agentStatusByPaneKey: Record }): AgentType | null { + const activeLeafId = resolveNativeChatActiveLayoutLeafId(terminalLayout) if (activeLeafId) { return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null } + if (!isNativeChatTabWideFallbackSafe(terminalLayout)) { + return null + } return ( Object.entries(agentStatusByPaneKey).find(([paneKey]) => paneKey.startsWith(`${terminalTabId}:`) @@ -67,13 +69,13 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive // Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] - const activeLeafId = terminalLayout?.activeLeafId ?? null + const tabWideFallbackSafe = isNativeChatTabWideFallbackSafe(terminalLayout) const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId: tab.entityId, - activeLeafId, + terminalLayout, agentStatusByPaneKey: state.agentStatusByPaneKey }) - const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) + const titleFallbackAgent = tabWideFallbackSafe ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null @@ -81,7 +83,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive !canToggleNativeChat({ experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent || !tabWideFallbackSafe ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : titleFallbackAgent, nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 292952390bf..7a7cc8f0c21 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -81,7 +81,10 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') @@ -449,16 +452,20 @@ function TabBarInner({ [unifiedTabs] ) - // Why: gate the tab long-press view-mode toggle to agent terminals. A tab is - // eligible when it launched an agent or has a live agent-status entry on any of - // its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate. + // Why: gate the tab long-press view-mode toggle to the agent in its active leaf. + // Tab-wide launch/title hints are safe only before the terminal is split. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) // Why: the strip only needs each tab's stable agent identity, but the whole // agentStatusByPaneKey map churns on every working↔idle transition app-wide. // Select a shallow-stable { tabId: agentType } projection so the strip // re-renders only when a tab gains/loses/changes its agent, not on status flips. const tabAgentTypesByTabId = useAppStore( - useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) + useShallow((s) => + selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}, s.terminalLayoutsByTabId) + ) + ) + const nativeChatTabWideFallbackUnsafeTabsById = useAppStore( + useShallow((s) => selectNativeChatTabWideFallbackUnsafeTabsById(s.terminalLayoutsByTabId)) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) const nativeChatTranscriptIsLocalReadable = useAppStore((s) => @@ -1117,14 +1124,16 @@ function TabBarInner({ // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null + const tabWideFallbackSafe = + nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: terminalTab.launchAgent, + launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, detectedAgent, - resolvedAgent, + resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 75ac3995670..727624abbef 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' +import { + selectNativeChatTabWideFallbackUnsafeTabsById, + selectTabAgentTypesByTabId +} from './tab-agent-types-by-tab-id' function entry(partial: Partial): AgentStatusEntry { return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry } +function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-b' } + }, + activeLeafId, + expandedLeafId: null + } +} + describe('selectTabAgentTypesByTabId', () => { it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { const map: Record = { @@ -36,6 +53,131 @@ describe('selectTabAgentTypesByTabId', () => { ) }) + it('uses the active split leaf regardless of pane-map insertion order', () => { + const layouts = { 'tab-1': splitLayout('leaf-b') } + const agentFirst = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + const activeFirst = { + 'tab-1:leaf-b': entry({ agentType: 'codex' }), + 'tab-1:leaf-a': entry({ agentType: 'claude' }) + } + + expect(selectTabAgentTypesByTabId(agentFirst, layouts)['tab-1']).toBe('codex') + expect(selectTabAgentTypesByTabId(activeFirst, layouts)['tab-1']).toBe('codex') + expect(selectNativeChatTabWideFallbackUnsafeTabsById(layouts)).toEqual({ 'tab-1': true }) + }) + + it('does not inherit a supported sibling when the active split leaf is a shell', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout('leaf-b') } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('uses the reassigned active sibling after the prior agent leaf closes', () => { + const statuses = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }) + } + + expect( + selectTabAgentTypesByTabId(statuses, { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-b' }, + activeLeafId: 'leaf-b', + expandedLeafId: null + } + })['tab-1'] + ).toBe('codex') + }) + + it('does not fall back to insertion order while a split has no active leaf', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: undefined }) + }, + { 'tab-1': splitLayout(null) } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + }) + + it('ignores a stale active leaf id that is no longer in the layout', () => { + const projection = selectTabAgentTypesByTabId( + { + 'tab-1:closed-leaf': entry({ agentType: 'claude' }), + 'tab-1:leaf-a': entry({ agentType: undefined }) + }, + { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + } + ) + + expect(projection['tab-1'] ?? null).toBeNull() + expect( + selectNativeChatTabWideFallbackUnsafeTabsById({ + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'closed-leaf', + expandedLeafId: null + } + }) + ).toEqual({ 'tab-1': true }) + }) + + it('uses the pane entry while a rootless layout is still hydrating', () => { + expect( + selectTabAgentTypesByTabId( + { 'tab-1:leaf-a': entry({ agentType: 'claude' }) }, + { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } } + ) + ).toEqual({ 'tab-1': 'claude' }) + }) + + it('treats a missing layout map as no unsafe split evidence during hydration', () => { + expect(selectNativeChatTabWideFallbackUnsafeTabsById()).toEqual({}) + }) + + it('resolves the active leaf through nested splits and ignores expanded siblings', () => { + const layout: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-b' }, + second: { type: 'leaf', leafId: 'leaf-c' } + } + }, + activeLeafId: 'leaf-c', + expandedLeafId: 'leaf-a' + } + + expect( + selectTabAgentTypesByTabId( + { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-c': entry({ agentType: 'codex' }) + }, + { 'tab-1': layout } + ) + ).toEqual({ 'tab-1': 'codex' }) + }) + it('stays shallow-equal across a working<->idle status flip (no re-render)', () => { const working: Record = { 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 5fc8335a93c..68d78a43ab3 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,4 +1,9 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { + isNativeChatTabWideFallbackSafe, + resolveNativeChatActiveLayoutLeafId +} from '../native-chat/native-chat-leaf-routing' /** * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` @@ -13,16 +18,34 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-statu * those transitions, so the strip re-renders only when a tab actually gains, loses, * or changes its agent. * - * First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids - * are colon-free by construction, so the substring before the first `:` is the - * tab id). A pane whose entry has no `agentType` still claims the tab and yields - * `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`. + * The active layout leaf wins when available because that is where a tab-level + * chat action opens. Before layout hydration, the first matching pane preserves + * the legacy lookup behavior (tab ids are colon-free by construction). */ export function selectTabAgentTypesByTabId( - agentStatusByPaneKey: Record + agentStatusByPaneKey: Record, + terminalLayoutsByTabId: Record = {} ): Record { const byTabId: Record = {} const claimed = new Set() + // Why: the tab action opens chat on the active split leaf, so that leaf's + // identity must outrank object insertion order from unrelated siblings. + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + // A rootless snapshot with no active leaf is hydration absence, not a + // topology decision; preserve the legacy tab lookup until a leaf exists. + if (!layout.root && !layout.activeLeafId) { + continue + } + claimed.add(tabId) + const activeLeafId = resolveNativeChatActiveLayoutLeafId(layout) + if (!activeLeafId) { + continue + } + const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] + if (entry?.agentType != null) { + byTabId[tabId] = entry.agentType + } + } for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { const colon = paneKey.indexOf(':') if (colon <= 0) { @@ -39,3 +62,17 @@ export function selectTabAgentTypesByTabId( } return byTabId } + +export function selectNativeChatTabWideFallbackUnsafeTabsById( + terminalLayoutsByTabId: Record = {} +): Record { + // Why: legacy and hydrating store shapes may not expose layout state yet; + // absence carries no unsafe split evidence and must not crash tab rendering. + const unsafeTabs: Record = {} + for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) { + if (!isNativeChatTabWideFallbackSafe(layout)) { + unsafeTabs[tabId] = true + } + } + return unsafeTabs +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4f6e1ecbe9e..f9a4e1fc40f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -101,6 +101,10 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { + nativeChatLaunchAgentForLeaf, + resolveNativeChatLeafRoute +} from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -376,10 +380,8 @@ export default function TerminalPane({ // list via managerRef.current?.getPanes()) re-runs when a pane is split or // closed. managerRef is imperative and doesn't trigger React's dependency // tracking. The lifecycle hook updates this via setPaneCount on - // onPaneCreated / onPaneClosed / onLayoutChanged. The value is never - // read — the portal map at line ~914 calls `managerRef.current?.getPanes()` - // imperatively, so `setPaneCount` is used only as a render-trigger side - // effect to force that map to re-run when a pane is split or closed. + // onPaneCreated / onPaneClosed / onLayoutChanged. The portal map reads the + // manager imperatively; the count also wakes leaf-ownership initialization. const [paneCount, setPaneCount] = useState(0) // Why: pane reorders can move panes without changing count or size, so // overlay rects need an explicit layout-change render trigger. @@ -394,6 +396,9 @@ export default function TerminalPane({ } | null>(null) const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) const [chatLeafId, setChatLeafId] = useState(null) + const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( + undefined + ) // Why: the terminal menu can be the first quick-command entry point, so each // Add action starts with a fresh draft instead of reusing cancelled text. const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft) @@ -685,53 +690,106 @@ export default function TerminalPane({ selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) + const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) const terminalTab = useAppStore((store) => getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId) ) + const restoredLayout = useMemo( + () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), + [savedLayout, terminalTab] + ) + const expectedLayoutLeafIds = useMemo( + () => collectLeafIdsInOrder(restoredLayout.root), + [restoredLayout.root] + ) + const getNativeChatLeafIds = useCallback((): string[] => { + const mountedLeafIds = managerRef.current?.getPanes().map((pane) => pane.leafId) ?? [] + // Why: a partially hydrated manager can expose one pane from a restored + // split. Union both sources so tab-wide evidence stays disabled meanwhile. + return [...new Set([...expectedLayoutLeafIds, ...mountedLeafIds])] + }, [expectedLayoutLeafIds]) + const getTabWideAgentHintLeafId = useCallback((): string | null => { + if (tabWideAgentHintLeafId !== undefined) { + return tabWideAgentHintLeafId + } + const leafIds = getNativeChatLeafIds() + return leafIds.length === 1 ? leafIds[0] : null + }, [getNativeChatLeafIds, tabWideAgentHintLeafId]) + useEffect(() => { + if (tabWideAgentHintLeafId !== undefined) { + return + } + const leafIds = getNativeChatLeafIds() + if (leafIds.length === 0) { + return + } + // Why: tab-wide launch/title metadata predates leaf ownership. Bind it only + // when the first concrete topology proves which sole leaf it can describe. + setTabWideAgentHintLeafId(leafIds.length === 1 ? leafIds[0] : null) + }, [getNativeChatLeafIds, paneCount, tabWideAgentHintLeafId]) const resolveTitleAgentForLeaf = useCallback( - (leafId: string | null) => - resolveNativeChatLeafTitleAgent({ + (leafId: string | null) => { + const hasSingleKnownLeaf = + getNativeChatLeafIds().length === 1 && getTabWideAgentHintLeafId() === leafId + return resolveNativeChatLeafTitleAgent({ leafId, panes: managerRef.current?.getPanes() ?? [], runtimePaneTitlesByPaneId, - tabLabel: unifiedTabLabel, - terminalTitle: terminalTab?.title - }), - [runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel] + tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null, + terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null + }) + }, + [ + getNativeChatLeafIds, + getTabWideAgentHintLeafId, + runtimePaneTitlesByPaneId, + terminalTab?.title, + unifiedTabLabel + ] ) // Per-leaf eligibility: a split can mix a supported agent in one leaf with an // unsupported one in another, so the toggle is gated by the specific leaf. // A leaf's own live agent is authoritative; the tab-wide launch/title hints // only fill in before hooks arrive (or for the single-pane case) so they // can't enable the toggle on a sibling actually running an unsupported agent. - const canToggleChatForLeaf = useCallback( + const isChatEligibleForLeaf = useCallback( (leafId: string | null): boolean => { const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null - // Scope the "always allow toggling back" rule to the leaf actually showing - // chat — passing the tab-wide flag would re-enable the toggle on an - // unsupported sibling whenever any leaf in the split is in chat view. - const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId, + leafIds: getNativeChatLeafIds() + }) return canToggleNativeChat({ experimentalNativeChatEnabled: nativeChatEnabled, contentType: 'terminal', - launchAgent: detectedAgent ? null : terminalTab?.launchAgent, + launchAgent: detectedAgent ? null : launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), - nativeChatTranscriptIsLocalReadable, - isChatViewMode: isChatViewForLeaf + nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, - effectiveChatViewMode, - chatLeafId, nativeChatEnabled, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, + getNativeChatLeafIds, + getTabWideAgentHintLeafId, resolveTitleAgentForLeaf ] ) + const canToggleChatForLeaf = useCallback( + (leafId: string | null): boolean => { + // Scope the "always allow toggling back" rule to the leaf actually showing + // chat; it must not make an unsupported sibling look eligible. + const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) + }, + [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] + ) const toggleNativeChatForLeaf = useCallback( (leafId: string) => { if (!unifiedTabId) { @@ -757,14 +815,6 @@ export default function TerminalPane({ toggleNativeChatForLeaf(activeLeafId) }, [toggleNativeChatForLeaf]) const setTabLayout = useAppStore((store) => store.setTabLayout) - const restoredLayout = useMemo( - () => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout), - [savedLayout, terminalTab] - ) - const expectedLayoutLeafIds = useMemo( - () => collectLeafIdsInOrder(restoredLayout.root), - [restoredLayout.root] - ) const expectedLayoutLeafIdsAttr = expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined const initialLayoutRef = useRef(restoredLayout) @@ -2920,23 +2970,31 @@ export default function TerminalPane({ ? managedPanes.some((pane) => pane.leafId === chatLeafId) : false useEffect(() => { - if (!isChatViewMode) { - if (chatLeafId !== null) { - setChatLeafId(null) - } - return - } const activeLeafId = activePane?.leafId ?? null - if (!chatLeafId) { - if (activeLeafId) { - setChatLeafId(activeLeafId) - } - return + const route = resolveNativeChatLeafRoute({ + isChatViewMode, + chatLeafId, + activeLeafId, + chatLeafStillMounted, + chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId), + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + }) + if (route.chatLeafId !== chatLeafId) { + setChatLeafId(route.chatLeafId) } - if (!chatLeafStillMounted) { - setChatLeafId(activeLeafId) + if (route.exitChat && unifiedTabId) { + // Why: effect replay must not flip terminal mode back to chat. + setTabViewMode(unifiedTabId, 'terminal') } - }, [isChatViewMode, chatLeafId, activePane?.leafId, chatLeafStillMounted]) + }, [ + isChatViewMode, + chatLeafId, + activePane?.leafId, + chatLeafStillMounted, + isChatEligibleForLeaf, + unifiedTabId, + setTabViewMode + ]) const chatPane = isChatViewMode && chatLeafId ? (managedPanes.find((pane) => pane.leafId === chatLeafId) ?? null) @@ -2945,6 +3003,12 @@ export default function TerminalPane({ ? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null) : null const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null + const chatPaneLaunchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminalTab?.launchAgent, + launchAgentLeafId: getTabWideAgentHintLeafId(), + leafId: chatPane?.leafId ?? null, + leafIds: getNativeChatLeafIds() + }) const activePaneIsChatLeaf = Boolean( isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId ) @@ -3045,7 +3109,7 @@ export default function TerminalPane({ terminalTabId={tabId} paneKey={makePaneKey(tabId, chatPane.leafId)} targetPtyId={chatPanePtyId} - launchAgent={terminalTab?.launchAgent} + launchAgent={chatPaneLaunchAgent} resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} contextMenuActions={{ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts new file mode 100644 index 00000000000..1fb24a000dd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' + +describe('shouldClearLaunchAgentForClosedPane', () => { + it('clears launch identity only when the launch-owning PTY closes', () => { + const tab = { launchAgent: 'codex' as const, ptyId: 'pty-agent' } + + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-agent')).toBe(true) + expect(shouldClearLaunchAgentForClosedPane(tab, 'pty-shell')).toBe(false) + }) + + it('does not mutate identity-free or not-yet-bound tabs', () => { + expect(shouldClearLaunchAgentForClosedPane({ ptyId: 'pty-1' }, 'pty-1')).toBe(false) + expect( + shouldClearLaunchAgentForClosedPane({ launchAgent: 'claude', ptyId: null }, 'pty-1') + ).toBe(false) + }) +}) + +describe('resolveTabTitleAfterPaneClose', () => { + it('uses the promoted sibling title when one is known', () => { + expect(resolveTabTitleAfterPaneClose({ 2: 'codex' }, 2)).toBe('codex') + }) + + it('resets to the tab fallback when the promoted shell has no title', () => { + expect(resolveTabTitleAfterPaneClose({ 1: 'closed agent' }, 2)).toBe('') + expect(resolveTabTitleAfterPaneClose({}, null)).toBe('') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts new file mode 100644 index 00000000000..2325241098f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-identity.ts @@ -0,0 +1,19 @@ +import type { TerminalTab } from '../../../../shared/types' + +export function shouldClearLaunchAgentForClosedPane( + tab: Pick | null | undefined, + closedPtyId: string | null | undefined +): boolean { + // Why: launchAgent describes the tab's original PTY only. Closing that PTY + // must not transfer its bootstrap identity to a surviving shell sibling. + return Boolean(tab?.launchAgent && closedPtyId && tab.ptyId === closedPtyId) +} + +export function resolveTabTitleAfterPaneClose( + runtimePaneTitlesByPaneId: Readonly>, + activePaneId: number | null | undefined +): string { + // Why: an empty update resets the tab to its stable fallback instead of + // leaving the closed pane's agent title attached to an untitled survivor. + return activePaneId == null ? '' : (runtimePaneTitlesByPaneId[activePaneId] ?? '') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index bfd6b988291..27d2c383f5d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -45,6 +45,10 @@ vi.mock('./pty-dispatcher', () => ({ })) type MockStoreState = { + tabsByWorktree: Record< + string, + { id: string; launchAgent?: 'claude' | 'codex'; ptyId: string | null }[] + > terminalLayoutsByTabId: Record< string, { @@ -55,8 +59,10 @@ type MockStoreState = { } > runtimePaneTitlesByTabId: Record> + clearTabLaunchAgent: ReturnType clearRuntimePaneTitle: ReturnType setTabLayout: ReturnType + updateTabTitle: ReturnType } let mockStoreState: MockStoreState @@ -102,10 +108,13 @@ function syncParked(args?: { describe('terminal-parked-tab-watchers', () => { beforeEach(() => { mockStoreState = { + tabsByWorktree: {}, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, + clearTabLaunchAgent: vi.fn(), clearRuntimePaneTitle: vi.fn(), - setTabLayout: vi.fn() + setTabLayout: vi.fn(), + updateTabTitle: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } }) @@ -370,6 +379,64 @@ describe('terminal-parked-tab-watchers', () => { }) }) + it('retires launch/title hints when the launch-owning parked leaf exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'codex', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { + [TAB_ID]: { 1: 'Codex', 2: 'PowerShell' } + } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'PowerShell') + }) + + it('keeps launch ownership when only a parked shell sibling exits', () => { + mockStoreState.tabsByWorktree = { + [WORKTREE_ID]: [{ id: TAB_ID, launchAgent: 'claude', ptyId: PTY_ID }] + } + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: 'Claude Code' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(mockStoreState.clearTabLaunchAgent).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).toHaveBeenCalledWith(TAB_ID, 'Claude Code') + }) + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 8871bdf4905..ba86ba3e512 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -18,6 +18,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' import { capturedPanesByTabId, disposeParkedTabWatchers, @@ -232,7 +236,24 @@ function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { } const detached = detachTerminalLayoutLeaf(layout, leafId) if (detached) { + const terminalTab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if (shouldClearLaunchAgentForClosedPane(terminalTab, ptyId)) { + state.clearTabLaunchAgent(tabId) + } state.setTabLayout(tabId, detached.sourceLayout) + const activeLeafId = detached.sourceLayout.activeLeafId + const activePtyId = activeLeafId + ? detached.sourceLayout.ptyIdsByLeafId?.[activeLeafId] + : undefined + const activePaneId = activePtyId + ? (parkedWatchersByTabId.get(tabId)?.paneIdByPtyId.get(activePtyId) ?? null) + : null + state.updateTabTitle( + tabId, + resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId) + ) } } 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 5a64694000e..feeedbc6cd0 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 @@ -127,6 +127,10 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state' +import { + resolveTabTitleAfterPaneClose, + shouldClearLaunchAgentForClosedPane +} from './terminal-pane-close-identity' export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, @@ -1278,6 +1282,13 @@ export function useTerminalPaneLifecycle({ mouseHideDisposablesRef.current.delete(paneId) } const transport = paneTransportsRef.current.get(paneId) + const closedPtyId = transport?.getPtyId() ?? null + const terminalTab = useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + if (!isDetachedToTab && shouldClearLaunchAgentForClosedPane(terminalTab, closedPtyId)) { + useAppStore.getState().clearTabLaunchAgent(tabId) + } const panePtyBinding = panePtyBindings.get(paneId) if (panePtyBinding) { panePtyBinding.dispose() @@ -1350,10 +1361,7 @@ export function useTerminalPaneLifecycle({ if (newActivePane) { reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id) const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {} - const activeTitle = paneTitles[newActivePane.id] - if (activeTitle) { - updateTabTitle(tabId, activeTitle) - } + updateTabTitle(tabId, resolveTabTitleAfterPaneClose(paneTitles, newActivePane.id)) } scheduleRuntimeGraphSync() }, From ebc9a0a35815173a2b17eb1961acf08bbe4f7f24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:28:57 +0000 Subject: [PATCH 04/52] release: v1.4.139-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dff61b9eed2..6c899ded222 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.138-rc.7", + "version": "1.4.139-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 65c65c37e8a32d7155497f3d673efa5d4933a9d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:17:49 -0700 Subject: [PATCH 05/52] fix(terminal): stop stale PTY resize after worktree reveal (#8502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): stop stale PTY resize after worktree reveal The visibility-resume size readback captures xterm's pre-reveal grid as its resize target while the applied-size read is in flight. A reveal fit or snapshot-restore resize can change the grid mid-flight without queuing a newer request, so the resolved callback "repaired" the PTY back to the pre-reveal grid — an idle TUI (Claude Code) then redraws for the wrong grid until a manual resize (refs #7951, #7240). Instrumented traces show the stale capture on every reveal; correctness relied on the read winning FIFO against the fit's resize, which busy daemons and SSH/relay round-trips lose. Re-measure xterm at resolve time and re-run against the fresh grid instead of forwarding a stale target. Adds an e2e seam that delays the readback dispatch to reproduce the losing ordering, unit repros that fail without the guard, and a hidden-resize/reveal-cycle e2e spec. * test(terminal): cover single-flight convergence under an oscillating grid * docs(e2e): note the WINCH bar is illustrative, not a Windows ground truth The reveal repro asserts on pty:getSize convergence; the bottom-bar TUI only makes the pane WINCH-reactive. Record that a long-lived process can miss Node's stdout 'resize' event under Windows ConPTY even when the OS PTY was resized, so future readers don't add a flaky bar-content check. --- .../pty-applied-size-read-e2e-delay.ts | 15 + .../terminal-pane/pty-connection.ts | 11 +- .../pty-size-reassertion.test.ts | 147 ++++++++- .../terminal-pane/pty-size-reassertion.ts | 8 + src/renderer/src/env.d.ts | 1 + ...inal-reveal-stale-pty-resize-repro.spec.ts | 304 ++++++++++++++++++ 6 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts create mode 100644 tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts b/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts new file mode 100644 index 00000000000..a56e093a309 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-applied-size-read-e2e-delay.ts @@ -0,0 +1,15 @@ +import { e2eConfig } from '@/lib/e2e-config' + +// Why: e2e seam for the stale-reveal-resize regression spec. The visibility +// resume readback (pty-size-reassertion) is only safe when the applied-size +// read is processed before the reveal fit's PTY resize; a busy daemon or +// SSH/relay round-trip breaks that ordering in the field. The spec sets this +// window global to reproduce the losing ordering deterministically. Gated on +// exposeStore so packaged builds ignore it. +export function getAppliedSizeReadE2eDelayMs(): number { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return 0 + } + const delayMs = window.__e2ePtyAppliedSizeReadDelayMs + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ae752d77d97..add578b7e2c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -47,6 +47,7 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' +import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' import { @@ -3499,7 +3500,15 @@ export function connectPanePty( shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), fit: () => safeFit(pane), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), - getAppliedSize: (ptyId) => window.api.pty.getSize(ptyId), + getAppliedSize: async (ptyId) => { + // Why: e2e seam — delays the read past the reveal fit to reproduce the + // busy-daemon/SSH ordering; returns 0 outside e2e builds. + const delayMs = getAppliedSizeReadE2eDelayMs() + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + return window.api.pty.getSize(ptyId) + }, forwardResize: forwardPtyResize }) let pendingForegroundGridDriftCheckRaf: number | null = null diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 5ad5accfe5f..3b22b2d9acf 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -70,7 +70,8 @@ describe('createPtySizeReassertion', () => { reassertion.request() await flushAsyncTicks() - expect(calls).toEqual(['fit', 'measure', 'read-applied']) + // The trailing measure is the resolve-time staleness check. + expect(calls).toEqual(['fit', 'measure', 'read-applied', 'measure']) }) it('does not duplicate the resize when fit already triggered xterm onResize', async () => { @@ -217,6 +218,150 @@ describe('createPtySizeReassertion', () => { expect(forwardResize).not.toHaveBeenCalledWith(100, 40) }) + it('suppresses a stale forward when xterm was refit while the read was in flight', async () => { + // Regression: a reveal-time fit (or snapshot-restore xterm resize) changes + // the grid after the target was captured, with no second request() to + // guard it. The resolved callback must not resize the PTY back to the + // pre-reveal grid. + let dims = { cols: 80, rows: 24 } + let resolveRead: (value: { cols: number; rows: number }) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + .mockResolvedValue({ cols: 145, rows: 78 }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + resolveRead({ cols: 145, rows: 78 }) + await flushAsyncTicks() + + expect(forwardResize).not.toHaveBeenCalledWith(80, 24) + expect(forwardResize).not.toHaveBeenCalled() + }) + + it('re-runs against the fresh grid when the PTY kept the old size across a mid-flight refit', async () => { + let dims = { cols: 80, rows: 24 } + let resolveRead: (value: { cols: number; rows: number }) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + .mockResolvedValue({ cols: 80, rows: 24 }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + resolveRead({ cols: 80, rows: 24 }) + await flushAsyncTicks() + + expect(getAppliedSize).toHaveBeenCalledTimes(2) + expect(forwardResize).toHaveBeenCalledTimes(1) + expect(forwardResize).toHaveBeenCalledWith(145, 78) + }) + + it('keeps a single read in flight and converges when the grid oscillates across resolves', async () => { + // Why: a flapping layout (competing fitters) must not amplify into more + // than one concurrent readback, and the re-run chain must stop as soon as + // the grid holds still for one read. + const grids = [ + { cols: 80, rows: 24 }, + { cols: 145, rows: 78 }, + { cols: 80, rows: 24 }, + { cols: 100, rows: 40 } + ] + let readCount = 0 + let dims = grids[0] + const getAppliedSize = vi.fn(async () => { + readCount += 1 + // The grid moves during each of the first three flights, then settles. + dims = grids[Math.min(readCount, grids.length - 1)] + return { cols: 100, rows: 40 } + }) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + await flushAsyncTicks(20) + + // One re-run per moved-grid flight, then convergence: applied 100x40 + // matches the settled grid, so nothing is forwarded. + expect(getAppliedSize).toHaveBeenCalledTimes(4) + expect(forwardResize).not.toHaveBeenCalled() + }) + + it('does not forward a stale target when the readback fails after a mid-flight refit', async () => { + let dims = { cols: 80, rows: 24 } + let rejectRead: (reason: Error) => void = () => {} + const getAppliedSize = vi + .fn<() => Promise<{ cols: number; rows: number } | null>>() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRead = reject + }) + ) + .mockRejectedValue(new Error('unavailable')) + const forwardResize = vi.fn() + const reassertion = createPtySizeReassertion({ + isDisposed: () => false, + getPtyId: () => 'pty-1', + isRemotePtyId: () => false, + shouldSuppressDesktopResize: () => false, + fit: vi.fn(), + getTerminalDimensions: () => dims, + getAppliedSize, + forwardResize + }) + + reassertion.request({ fit: false }) + dims = { cols: 145, rows: 78 } + rejectRead(new Error('unavailable')) + await flushAsyncTicks() + + expect(forwardResize).not.toHaveBeenCalledWith(80, 24) + // The unguarded fallback resize still happens, but at the fresh grid. + expect(forwardResize).toHaveBeenCalledWith(145, 78) + }) + it('forwards once when applied-size readback fails', async () => { const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 78a2e8cf5aa..7f127b67083 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -63,6 +63,14 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt if (pending) { return } + // Why: a reveal fit or snapshot-restore resize can change xterm while the + // applied-size read is in flight without queuing a request; forwarding the + // captured target would resize the PTY back to the pre-reveal grid, so + // re-run against the fresh grid instead. + if (!dimensionsMatch(options.getTerminalDimensions(), target)) { + pending = true + return + } if (dimensionsMatch(actual, target)) { return } diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 676fa253529..e07b5d6a32b 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -73,6 +73,7 @@ declare global { parkedTabIds: () => string[] } __monacoEditorE2E?: MonacoE2EProbe + __e2ePtyAppliedSizeReadDelayMs?: number } } diff --git a/tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts b/tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts new file mode 100644 index 00000000000..f2a40b73e67 --- /dev/null +++ b/tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts @@ -0,0 +1,304 @@ +/** + * Repro: stale PTY resize after worktree reveal (issues #7951 / #7240 family). + * + * Symptom (field reports + internal, v1.4.136): returning to a worktree + * garbles the bottom of an idle TUI (Claude Code) until a manual window + * resize. + * + * Mechanism: on reveal, noteVisibilityResume() requests a PTY size readback + * that captures xterm's pre-reveal grid as the resize target + * (pty-size-reassertion.ts). The reveal fit then refits xterm and resizes the + * PTY while that read is in flight, with no follow-up request queued. + * Instrumented traces show this stale-capture interleaving on EVERY reveal; + * correctness then hinges solely on the applied-size read being processed + * before the fit's resize (local FIFO luck). When the read loses that race — + * busy daemon serializing reveal snapshots, SSH/relay round-trips — the + * resolved callback sees applied != captured target and "repairs" the PTY + * back to the pre-reveal grid. An idle TUI redraws for the wrong grid and + * nothing heals it: no output means no grid-drift check, and no later layout + * change means no ResizeObserver request. + * + * Test 1 drives the choreography with the natural read ordering (documents + * the FIFO-lucky path). Test 2 delays the readback dispatch (e2e seam in + * pty-applied-size-read-e2e-delay.ts) to model the losing ordering — on an + * unpatched build the stale forward fires live (verified via instrumented + * traces: FORWARD of the pre-reveal grid over the freshly fitted one). On + * fast idle machines a follow-up reassertion heals it within tens of ms, so + * the deterministic regression guard for the stale forward itself lives in + * pty-size-reassertion.test.ts; this spec catches the persistent-desync + * variant and keeps the full reveal path exercised under the field ordering. + */ + +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +type StaleResizeReproWindow = Window & { + __paneManagers?: Map< + string, + { + getPanes?: () => { + container?: { dataset?: { ptyId?: string } } + terminal?: { cols?: number; rows?: number } + }[] + } + > + __e2ePtyAppliedSizeReadDelayMs?: number +} + +type GridSnapshot = { + xterm: { cols: number; rows: number } | null + applied: { cols: number; rows: number } | null +} + +type CycleFailure = { + cycle: number + snapshot: GridSnapshot +} + +const CONVERGE_TIMEOUT_MS = 6_000 +// Why: sweep the applied-size read delay across the gap between the reveal +// fit's PTY resize landing in the daemon and the ResizeObserver follow-up +// request — the window where the stale forward fires. Fast idle machines +// rescue within ~30-50ms; the field (SSH/relay, loaded frames) may never. +const GETSIZE_DELAY_SWEEP_MS = [15, 20, 25, 30, 40, 60] +const VIEWPORTS = [ + { width: 1280, height: 800 }, + { width: 940, height: 640 }, + { width: 1120, height: 760 } +] + +function bottomBarTuiScript(runId: string): string { + // Why: redraw ONLY on SIGWINCH, like an idle Claude Code session. Periodic + // output would trigger the foreground grid-drift check, which heals the + // desync and masks the bug the field reports hit while idle. + // + // The bar is illustrative, NOT the assertion target: this spec asserts on + // pty:getSize converging to xterm's grid. Do not assert on the bar's printed + // cols — Node's `process.stdout.on('resize')` is unreliable under Windows + // ConPTY (no SIGWINCH; a long-lived process can miss the notification while + // the OS PTY is in fact resized), so a bar-content check flakes there even + // though delivery succeeded (confirmed via base-vs-fix A/B + fresh-process + // console read on Windows). + return [ + 'const draw = () => {', + ' const rows = process.stdout.rows || 24', + ' const cols = process.stdout.columns || 80', + ` const bar = 'BOTTOM_BAR_${runId} rows=' + rows + ' cols=' + cols + ' ' + '='.repeat(200)`, + " process.stdout.write('\\x1b7\\x1b[' + rows + ';1H\\x1b[2K' + bar.slice(0, Math.max(1, cols - 1)) + '\\x1b8')", + '}', + "process.stdout.on('resize', draw)", + 'draw()', + 'setTimeout(() => process.exit(0), 600000)' + ].join('\n') +} + +async function readGridSnapshot(page: Page, ptyId: string): Promise { + return page.evaluate(async (ptyId) => { + const win = window as StaleResizeReproWindow + let xterm: { cols: number; rows: number } | null = null + for (const manager of win.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === ptyId) { + xterm = { cols: pane.terminal?.cols ?? 0, rows: pane.terminal?.rows ?? 0 } + } + } + } + // Why: the delay seam only affects the product's own readback wiring in + // pty-connection, so probing window.api.pty.getSize directly stays fast. + const applied = (await window.api?.pty?.getSize?.(ptyId)) ?? null + return { xterm, applied } + }, ptyId) +} + +async function closeRightSidebarAndFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + return + } + store.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store.getState().rightSidebarOpen) { + store.getState().setRightSidebarOpen(false) + } + }) +} + +function gridsConverged(snapshot: GridSnapshot): boolean { + return ( + snapshot.xterm !== null && + snapshot.applied !== null && + snapshot.xterm.cols > 0 && + snapshot.xterm.rows > 0 && + snapshot.applied.cols === snapshot.xterm.cols && + snapshot.applied.rows === snapshot.xterm.rows + ) +} + +/** Arm the e2e seam that delays the reassertion's applied-size read dispatch + * past the reveal fit — the ordering a busy daemon or SSH/relay round-trip + * produces in the field (pty-applied-size-read-e2e-delay.ts). */ +async function armSlowAppliedSizeRead(page: Page, delayMs: number): Promise { + await page.evaluate((delayMs) => { + ;(window as StaleResizeReproWindow).__e2ePtyAppliedSizeReadDelayMs = delayMs + }, delayMs) +} + +type CycleDriverArgs = { + page: Page + testInfo: TestInfo + testRepoPath: string + cycles: number + label: string + delaySweepMs?: readonly number[] + onRevealSample?: (sample: { cycle: number; snapshot: GridSnapshot }) => void +} + +async function driveHiddenResizeRevealCycles(args: CycleDriverArgs): Promise { + const { page, testInfo, testRepoPath, cycles, label } = args + await waitForSessionReady(page) + const firstWorktreeId = await waitForActiveWorktree(page) + const secondWorktreeId = (await getAllWorktreeIds(page)).find((id) => id !== firstWorktreeId) + test.skip(!secondWorktreeId, 'stale-resize repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return [] + } + + await page.setViewportSize(VIEWPORTS[0]) + await closeRightSidebarAndFeatureTips(page) + + await switchToWorktree(page, secondWorktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page) + await waitForPtyShellEcho(page, ptyId, 15_000) + + const runId = Math.random().toString(36).slice(2, 10) + const scriptPath = path.join(testRepoPath, `.orca-bottom-bar-${runId}.cjs`) + writeFileSync(scriptPath, bottomBarTuiScript(runId)) + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => readGridSnapshot(page, ptyId).then(gridsConverged), { + timeout: 15_000, + message: `${label}: applied PTY size should match xterm before cycling` + }) + .toBe(true) + + const failures: CycleFailure[] = [] + let viewportIndex = 0 + for (let cycle = 0; cycle < cycles; cycle += 1) { + if (args.delaySweepMs) { + await armSlowAppliedSizeRead(page, args.delaySweepMs[cycle % args.delaySweepMs.length]) + } + await switchToWorktree(page, firstWorktreeId) + await expect.poll(() => getActiveWorktreeId(page), { timeout: 10_000 }).toBe(firstWorktreeId) + // Why: the field shape — the window changes while the idle TUI worktree + // is hidden; hidden xterm refits drop their PTY forwards + // (isRendererPtyResizeAuthoritative), so the reveal-time readback is the + // sole owner of the correction. + viewportIndex = (viewportIndex + 1) % VIEWPORTS.length + await page.setViewportSize(VIEWPORTS[viewportIndex]) + await page.waitForTimeout(400) + + await switchToWorktree(page, secondWorktreeId) + await expect.poll(() => getActiveWorktreeId(page), { timeout: 10_000 }).toBe(secondWorktreeId) + + // Why: sample tightly right after reveal — a stale forward that a later + // follow-up request heals is still the corruption firing (the TUI redraws + // for the wrong grid in that window); record every desynced sample. + for (let sample = 0; sample < 20; sample += 1) { + const snapshot = await readGridSnapshot(page, ptyId) + if (!gridsConverged(snapshot) && snapshot.xterm !== null && snapshot.applied !== null) { + args.onRevealSample?.({ cycle, snapshot }) + } + await page.waitForTimeout(10) + } + + let lastSnapshot: GridSnapshot = { xterm: null, applied: null } + const deadline = Date.now() + CONVERGE_TIMEOUT_MS + let converged = false + while (Date.now() < deadline) { + lastSnapshot = await readGridSnapshot(page, ptyId) + if (gridsConverged(lastSnapshot)) { + converged = true + break + } + await page.waitForTimeout(150) + } + if (!converged) { + failures.push({ cycle, snapshot: lastSnapshot }) + if (failures.length <= 3) { + const screenshotPath = testInfo.outputPath(`${label}-cycle-${cycle}.png`) + await page.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach(`${label}-cycle-${cycle}.png`, { + path: screenshotPath, + contentType: 'image/png' + }) + } + } + } + return failures +} + +test.describe('Terminal reveal stale PTY resize repro', () => { + test('applied PTY size converges across hidden-resize/reveal cycles (natural read ordering)', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(300_000) + const failures = await driveHiddenResizeRevealCycles({ + page: orcaPage, + testInfo, + testRepoPath, + cycles: 8, + label: 'natural-order' + }) + expect( + failures, + `PTY applied size stayed desynced from xterm after reveal: ${JSON.stringify(failures)}` + ).toEqual([]) + }) + + test('the PTY is never resized back to its stale pre-reveal grid under slow applied-size reads', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(300_000) + const staleSamples: { cycle: number; snapshot: GridSnapshot }[] = [] + const failures = await driveHiddenResizeRevealCycles({ + page: orcaPage, + testInfo, + testRepoPath, + cycles: 12, + label: 'slow-read', + delaySweepMs: GETSIZE_DELAY_SWEEP_MS, + onRevealSample: (sample) => staleSamples.push(sample) + }) + if (staleSamples.length > 0) { + console.log(`[repro] desynced post-reveal samples: ${JSON.stringify(staleSamples)}`) + } + expect( + staleSamples, + `PTY applied size diverged from xterm after reveal (stale resize fired): ${JSON.stringify(staleSamples)}` + ).toEqual([]) + expect( + failures, + `PTY stayed desynced from xterm after reveal: ${JSON.stringify(failures)}` + ).toEqual([]) + }) +}) From 2ebe207bf78c54268ce521ea6a61c227a4b7f430 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:18:54 +0000 Subject: [PATCH 06/52] release: v1.4.139-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c899ded222..1bf2bc74f30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.139-rc.0", + "version": "1.4.139-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 280103aa0462b2d49bbcc38ea633d9bcf184ec6b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:20:43 -0700 Subject: [PATCH 07/52] Allow replying to any comment in a thread, not just the root (#8562) Reply state now tracks a comment id instead of a group id, and reply composers/handlers are threaded through each comment row (root and replies alike) so any comment can receive an inline reply rather than only the thread root. --- .../right-sidebar/checks-panel-content.tsx | 78 +++++++++---------- 1 file changed, 38 insertions(+), 40 deletions(-) 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 1ff9669a8ef..0ffc41dd686 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -65,12 +65,7 @@ import { type PRCommentAudienceFilter } from '@/lib/pr-comment-audience' import { setPRBotAuthorOverride, usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - type PRCommentGroup -} from '@/lib/pr-comment-groups' +import { getPRCommentGroupId, groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' import { getPRCommentGroupActionState, isPRCommentGroupQueueableForAI, @@ -1959,7 +1954,7 @@ function CommentRow({ function PRCommentGroupView({ group, botAuthorOverrides, - replyingGroupId, + replyingCommentId, selectionControl, actionState, isQueued, @@ -1976,7 +1971,7 @@ function PRCommentGroupView({ }: { group: PRCommentGroup botAuthorOverrides: ReadonlySet - replyingGroupId: string | null + replyingCommentId: number | null selectionControl?: React.ReactNode actionState: PRCommentGroupActionState isQueued: boolean @@ -1984,34 +1979,34 @@ function PRCommentGroupView({ replyDisabledReason?: string presentation: PRCommentPresentationClasses onResolve?: (threadId: string, resolve: boolean) => boolean | Promise - onStartReply?: (groupId: string) => void + onStartReply?: (commentId: number) => void onCancelReply?: () => void onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise onQueueForAgent?: () => void }): React.JSX.Element { - const groupId = getPRCommentGroupId(group) - const root = getPRCommentGroupRoot(group) - const replyComposer = - replyingGroupId === groupId && onReply ? ( + // Reply targets a specific comment id so any comment in a thread — root or + // nested reply — can be replied to, not just the thread root. + const renderReplyComposer = (comment: PRComment): React.ReactNode => + replyingCommentId === comment.id && onReply ? (
onReply(root, body)} + onSubmit={(body) => onReply(comment, body)} />
) : null - const startReply = onStartReply ? () => onStartReply(groupId) : undefined + const startReply = onStartReply ? (comment: PRComment) => onStartReply(comment.id) : undefined const surfaceClassName = cn( getPRCommentGroupSurfaceClasses(presentation, actionState, { queued: isQueued }), group.kind === 'standalone' ? presentation.groupStandalone : presentation.groupThread @@ -2038,10 +2033,10 @@ function PRCommentGroupView({ showResolve={false} showReply={Boolean(onReply)} selectionControl={selectionControl} - onReply={startReply ? () => startReply() : undefined} + onReply={startReply} {...sharedRowProps} /> - {replyComposer} + {renderReplyComposer(group.comment)} ) : (
@@ -2051,25 +2046,28 @@ function PRCommentGroupView({ showResolve={true} showReply={Boolean(onReply)} selectionControl={selectionControl} - onReply={startReply ? () => startReply() : undefined} + onReply={startReply} {...sharedRowProps} /> + {renderReplyComposer(group.root)} {group.replies.length > 0 && (
{group.replies.map((reply) => ( - + + + {renderReplyComposer(reply)} + ))}
)} - {replyComposer}
) @@ -2096,7 +2094,7 @@ function PRCommentGroupView({ function ResolvedCommentGroupsSection({ groups, botAuthorOverrides, - replyingGroupId, + replyingCommentId, replyDisabled, replyDisabledReason, presentation, @@ -2109,12 +2107,12 @@ function ResolvedCommentGroupsSection({ }: { groups: PRCommentGroup[] botAuthorOverrides: ReadonlySet - replyingGroupId: string | null + replyingCommentId: number | null replyDisabled?: boolean replyDisabledReason?: string presentation: PRCommentPresentationClasses onResolve?: (threadId: string, resolve: boolean) => boolean | Promise - onStartReply?: (groupId: string) => void + onStartReply?: (commentId: number) => void onCancelReply?: () => void onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise @@ -2142,7 +2140,7 @@ function ResolvedCommentGroupsSection({ key={getPRCommentGroupId(group)} group={group} botAuthorOverrides={botAuthorOverrides} - replyingGroupId={replyingGroupId} + replyingCommentId={replyingCommentId} actionState="resolved" isQueued={false} replyDisabled={replyDisabled} @@ -2241,7 +2239,7 @@ export function PRCommentsList({ const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('all') const [displayMode, setDisplayMode] = useState('triage') - const [replyingGroupId, setReplyingGroupId] = useState(null) + const [replyingCommentId, setReplyingCommentId] = useState(null) const [isAddingComment, setIsAddingComment] = useState(false) const addCommentSurfaceRef = useRef(null) const shouldScrollAddCommentRef = useRef(false) @@ -2344,7 +2342,7 @@ export function PRCommentsList({ key={groupId} group={group} botAuthorOverrides={botAuthorOverrides} - replyingGroupId={replyingGroupId} + replyingCommentId={replyingCommentId} selectionControl={renderSelectionControl(group)} actionState={actionState} isQueued={isQueued} @@ -2352,8 +2350,8 @@ export function PRCommentsList({ replyDisabledReason={commentsDisabledReason} presentation={presentation} onResolve={onResolve} - onStartReply={setReplyingGroupId} - onCancelReply={() => setReplyingGroupId(null)} + onStartReply={setReplyingCommentId} + onCancelReply={() => setReplyingCommentId(null)} onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} @@ -2675,13 +2673,13 @@ export function PRCommentsList({ setReplyingGroupId(null)} + onStartReply={setReplyingCommentId} + onCancelReply={() => setReplyingCommentId(null)} onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} From 53a09afbef2cb4661e826cbb84e0ca2f371f6c5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:02 -0700 Subject: [PATCH 08/52] feat(mobile): match desktop's Smart workspace source picker exactly (#7985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): start a workspace from a branch, issue/PR, or Linear ticket Unify mobile workspace creation with desktop. The "+" Create Workspace modal now has a primary "Start from" field that opens a tabbed search drawer (Branch · GitHub · GitLab · Linear), letting a user start a workspace from an existing/new git branch, a GitHub issue/PR, a GitLab issue/MR, or a Linear ticket — in addition to the default blank workspace. No new backend is required: the search RPCs (github.listWorkItems, gitlab.listWorkItems, linear.searchIssues/listIssues, repo.searchRefs) and the worktree.create linked-item params were already used by the mobile Tasks screen. This surfaces them in the create flow, reusing the existing pure modules (buildTaskWorkspaceCreateParams, shouldResolveHostedReviewStartPoint, filterAvailableTaskProviders). Details: - New pure modules: workspace-source-selection, use-workspace-source-search, source-workspace-create, worktree-create-retry, blank-workspace-create (the blank/retry path extracted from the modal for reuse + line budget). - New UI: WorkspaceSourcePickerDrawer (+ row) and SetupHookTrustDrawer (extracted from the modal). - Older paired desktops (missing the mobile.tasks.v1 capability) degrade to Branch + Blank only; GitLab/Linear tabs appear only when available. - GitHub/GitLab sources pin their repo; switching repos resets the source. PR/MR sources resolve their base branch at create time; SSH repos gate search until connected (Linear search is repo/SSH-independent). * fix(mobile): hydrate settings/trust before availability probes settle Review fixes for #7985: setTrustedOrcaHooks/setRuntimeSettings no longer wait on status.get/preflight.check/linear.status (a first-open preflight.check can take seconds, widening the spurious setup-trust re-prompt window). Also adds param-parity tests for createBlankWorkspace and a GitLab MR base-resolve test. * feat(mobile): match desktop's Smart source picker exactly Rework the mobile create-workspace source picker to be a faithful port of desktop's Smart picker instead of the earlier divergent "Start from" drawer. The mobile field is now the workspace-name input AND the source search, with the exact desktop tabs — Smart · GitHub · Linear · GitLab · Branch · Name. "Smart" fans out across GitHub + GitLab + Linear + branches, prepends a "Use ''" row, and resolves pasted URLs / #123 / STA-42 to exact items (with a cross-repo switch prompt). Selecting a source shows a pill and moves the editable name into Advanced. The invented "Blank workspace" concept is removed — the neutral state is just a typed/empty name (blank submit still yields a creature name). DRY: the pure desktop logic (smart-workspace-source-results, -command-value, github-links, gitlab-links, work-item-link-query-bounds, github-work-item-identity) moves to src/shared/new-workspace/ with re-export shims at the old renderer paths, so both renderer and mobile share one implementation. composer-branch-selection and workspace-name were already shared and are reused directly. Two read-only lookup RPCs are allowlisted for mobile so pasted GitLab URLs and cross-repo GitHub URLs resolve to exact items (github.workItemByOwnerRepo, gitlab.workItemByPath). New mobile modules are split for max-lines: use-mobile-composer-source (selection state + desktop-parity handlers, PR/MR base resolve), use-smart-workspace-source + smart-source-fan-out/-search-requests/-paste-intent (RPC orchestration), composer-linked-work-item / work-item-lookup-text / mobile-smart-source-modes (pure logic), and SmartWorkspaceSourceField/Drawer/Row + SmartWorkspaceAdvancedFields. Replaces WorkspaceSourcePickerDrawer/Row, workspace-source-selection, use-workspace-source-search, and MobileWorkspaceNameInput. Reviewed by three adversarial agents + re-reviewed after fixes: GitHub search now returns issues AND PRs (not issues-only), Linear defaults to assigned, create-branch preserves slashy names, cross-repo PR base resolves against the item's own repo, displayName is suppressed for user-edited names, and the smart-mode GitHub fan-out respects availability. tsc/oxlint/max-lines-ratchet clean; 1328 mobile tests pass. * fix(mobile): keep smart source drawer fully visible * refactor: share workspace creation behavior across clients * fix: address workspace creation review findings --------- Co-authored-by: Brennan Benson --- mobile/app/h/[hostId]/index.tsx | 1 + mobile/app/h/[hostId]/tasks.tsx | 2 +- mobile/pnpm-lock.yaml | 136 ++-- mobile/pnpm-workspace.yaml | 5 + mobile/src/components/NewWorktreeModal.tsx | 596 +++++++++--------- .../components/NewWorktreeModalController.tsx | 3 + .../src/components/SetupHookTrustDrawer.tsx | 137 ++++ mobile/src/components/SmartSourceModeIcon.tsx | 18 + .../SmartWorkspaceAdvancedFields.tsx | 102 +++ .../components/SmartWorkspaceSourceDrawer.tsx | 425 +++++++++++++ .../components/SmartWorkspaceSourceField.tsx | 145 +++++ .../components/SmartWorkspaceSourceRow.tsx | 134 ++++ .../src/tasks/blank-workspace-create.test.ts | 145 +++++ mobile/src/tasks/blank-workspace-create.ts | 37 ++ .../tasks/composer-linked-work-item.test.ts | 243 +++++++ mobile/src/tasks/composer-linked-work-item.ts | 153 +++++ .../src/tasks/composer-source-base-resolve.ts | 76 +++ .../src/tasks/mobile-composer-source-types.ts | 64 ++ .../tasks/mobile-smart-source-modes.test.ts | 86 +++ mobile/src/tasks/mobile-smart-source-modes.ts | 93 +++ mobile/src/tasks/mobile-tasks-capability.ts | 5 + mobile/src/tasks/setup-hook-trust.test.ts | 23 + mobile/src/tasks/setup-hook-trust.ts | 16 + mobile/src/tasks/smart-source-fan-out.test.ts | 112 ++++ mobile/src/tasks/smart-source-fan-out.ts | 141 +++++ .../tasks/smart-source-paste-intent.test.ts | 142 +++++ mobile/src/tasks/smart-source-paste-intent.ts | 178 ++++++ .../smart-source-search-requests.test.ts | 45 ++ .../src/tasks/smart-source-search-requests.ts | 119 ++++ .../src/tasks/source-workspace-create.test.ts | 220 +++++++ mobile/src/tasks/source-workspace-create.ts | 250 ++++++++ .../src/tasks/use-mobile-composer-source.ts | 330 ++++++++++ .../src/tasks/use-smart-workspace-source.ts | 233 +++++++ .../src/tasks/work-item-lookup-text.test.ts | 18 + mobile/src/tasks/work-item-lookup-text.ts | 1 + .../src/tasks/workspace-create-params.test.ts | 2 +- mobile/src/tasks/workspace-create-params.ts | 61 +- mobile/src/tasks/worktree-create-retry.ts | 46 ++ mobile/src/transport/protocol-compat.test.ts | 13 + src/main/runtime/rpc/schemas.test.ts | 33 + src/main/runtime/runtime-rpc.ts | 6 + .../smart-workspace-command-value.ts | 56 +- .../smart-workspace-source-results.ts | 192 +----- .../folder-workspace-composer-helpers.ts | 62 +- .../hooks/composer-branch-selection.test.ts | 15 + .../src/hooks/composer-branch-selection.ts | 3 + src/renderer/src/hooks/fork-push-warning.ts | 22 +- src/renderer/src/hooks/useComposerState.ts | 106 ++-- .../src/lib/github-work-item-identity.ts | 22 +- src/renderer/src/lib/gitlab-links.ts | 140 +--- .../src/lib/linear-linked-work-item.ts | 20 +- .../src/lib/linked-work-item-provider.ts | 54 +- .../src/lib/work-item-link-query-bounds.ts | 12 +- .../src/lib/work-item-lookup-text.test.ts | 1 + src/renderer/src/lib/work-item-lookup-text.ts | 25 +- src/renderer/src/store/slices/worktrees.ts | 24 +- src/shared/composer-branch-selection.ts | 44 ++ src/shared/new-workspace/fork-push-warning.ts | 16 + src/shared/new-workspace/github-links.ts | 50 ++ .../github-work-item-identity.ts | 20 + src/shared/new-workspace/gitlab-links.ts | 138 ++++ .../smart-workspace-command-value.ts | 54 ++ .../smart-workspace-source-results.ts | 190 ++++++ .../work-item-link-query-bounds.ts | 10 + .../new-workspace/work-item-lookup-text.ts | 30 + .../new-workspace/workspace-source.test.ts | 65 ++ src/shared/new-workspace/workspace-source.ts | 195 ++++++ .../worktree-create-retry-policy.test.ts | 19 + .../worktree-create-retry-policy.ts | 18 + src/shared/protocol-compat.test.ts | 22 + 70 files changed, 5224 insertions(+), 996 deletions(-) create mode 100644 mobile/pnpm-workspace.yaml create mode 100644 mobile/src/components/SetupHookTrustDrawer.tsx create mode 100644 mobile/src/components/SmartSourceModeIcon.tsx create mode 100644 mobile/src/components/SmartWorkspaceAdvancedFields.tsx create mode 100644 mobile/src/components/SmartWorkspaceSourceDrawer.tsx create mode 100644 mobile/src/components/SmartWorkspaceSourceField.tsx create mode 100644 mobile/src/components/SmartWorkspaceSourceRow.tsx create mode 100644 mobile/src/tasks/blank-workspace-create.test.ts create mode 100644 mobile/src/tasks/blank-workspace-create.ts create mode 100644 mobile/src/tasks/composer-linked-work-item.test.ts create mode 100644 mobile/src/tasks/composer-linked-work-item.ts create mode 100644 mobile/src/tasks/composer-source-base-resolve.ts create mode 100644 mobile/src/tasks/mobile-composer-source-types.ts create mode 100644 mobile/src/tasks/mobile-smart-source-modes.test.ts create mode 100644 mobile/src/tasks/mobile-smart-source-modes.ts create mode 100644 mobile/src/tasks/mobile-tasks-capability.ts create mode 100644 mobile/src/tasks/smart-source-fan-out.test.ts create mode 100644 mobile/src/tasks/smart-source-fan-out.ts create mode 100644 mobile/src/tasks/smart-source-paste-intent.test.ts create mode 100644 mobile/src/tasks/smart-source-paste-intent.ts create mode 100644 mobile/src/tasks/smart-source-search-requests.test.ts create mode 100644 mobile/src/tasks/smart-source-search-requests.ts create mode 100644 mobile/src/tasks/source-workspace-create.test.ts create mode 100644 mobile/src/tasks/source-workspace-create.ts create mode 100644 mobile/src/tasks/use-mobile-composer-source.ts create mode 100644 mobile/src/tasks/use-smart-workspace-source.ts create mode 100644 mobile/src/tasks/work-item-lookup-text.test.ts create mode 100644 mobile/src/tasks/work-item-lookup-text.ts create mode 100644 mobile/src/tasks/worktree-create-retry.ts create mode 100644 mobile/src/transport/protocol-compat.test.ts create mode 100644 src/shared/new-workspace/fork-push-warning.ts create mode 100644 src/shared/new-workspace/github-links.ts create mode 100644 src/shared/new-workspace/github-work-item-identity.ts create mode 100644 src/shared/new-workspace/gitlab-links.ts create mode 100644 src/shared/new-workspace/smart-workspace-command-value.ts create mode 100644 src/shared/new-workspace/smart-workspace-source-results.ts create mode 100644 src/shared/new-workspace/work-item-link-query-bounds.ts create mode 100644 src/shared/new-workspace/work-item-lookup-text.ts create mode 100644 src/shared/new-workspace/workspace-source.test.ts create mode 100644 src/shared/new-workspace/workspace-source.ts create mode 100644 src/shared/new-workspace/worktree-create-retry-policy.test.ts create mode 100644 src/shared/new-workspace/worktree-create-retry-policy.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 01a3cc95acd..b8db4e3a292 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1428,6 +1428,7 @@ export function HostScreen({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={worktrees} onVisibleChange={(visible) => { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index fea23f01764..c61d01559cd 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -61,6 +61,7 @@ import { } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { filterWorkspaceAgents, isWorkspaceAgentEnabled, @@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3 const MAX_RENDERED_PR_DIFF_LINES = 400 const GITLAB_PER_PAGE = 50 const LINEAR_LIMIT = 50 -const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' // Why: task detail drawers can launch child sheets; children must layer above // the still-mounted parent while its dismissal animation/state remains alive. const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38c5..d28e9ac1d40 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1935,48 +1935,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==} @@ -2049,48 +2057,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==} @@ -2552,36 +2568,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==} @@ -4881,24 +4903,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==} @@ -7025,22 +7051,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7050,7 +7076,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7085,12 +7111,12 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7105,42 +7131,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -8604,7 +8630,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8771,7 +8797,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9529,24 +9555,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -9561,7 +9587,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/hammerjs@2.0.46': {} @@ -9639,7 +9665,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9659,7 +9685,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9676,6 +9702,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9685,6 +9720,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9714,6 +9753,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -9972,7 +10026,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -10273,7 +10327,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10282,7 +10336,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10678,7 +10732,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: @@ -10776,11 +10830,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10804,7 +10858,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10827,7 +10881,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10838,7 +10892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11515,7 +11569,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -12087,7 +12141,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12242,7 +12296,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12287,7 +12341,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -14008,6 +14062,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 00000000000..35f983a345a --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + xcode>uuid: 11.1.1 diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7eaa..75b18fcd1fb 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,21 +10,19 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' -import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, normalizeSetupHookTrust, - trustedOrcaHooksWithSetupApproval, + persistSetupHookTrustApproval, wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' @@ -49,6 +47,24 @@ import { refreshMobileNewWorkspaceDialogSelectedRepo, resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { id: string @@ -56,6 +72,9 @@ type Repo = { path: string badgeColor?: string connectionId?: string | null + kind?: 'git' | 'folder' + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null } type SetupDecision = 'inherit' | 'run' | 'skip' @@ -91,13 +110,11 @@ type CreateOptions = { approvedSetupContentHash?: string } -type SetupTrustPrompt = { - repoId: string - repoName: string - scriptContent: string - contentHash: string - previouslyApproved: boolean -} +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' + +// Why: iOS cannot reliably present a second native modal until the first drawer's +// exit commits; one extra frame keeps transitions sequential on slower devices. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] @@ -124,6 +141,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -133,6 +151,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -157,6 +176,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -168,25 +188,28 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -200,12 +223,40 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) - // Why: matches the desktop UI — the input shows a generic "Workspace name" - // placeholder, not the suggested creature. The creature name is only used - // as a server-bound fallback when the user submits with a blank field, so - // it's recomputed lazily inside handleCreate() to stay fresh against - // existingWorktreePaths at submission time. + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } + + // The Smart source picker owns the workspace name AND the linked-source + // selection: typing names the workspace and drives source search, and picking + // a source resolves the base/branch/push metadata (matching desktop). The + // creature-name fallback is only computed lazily at submit for a blank name. + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -242,6 +293,25 @@ function NewWorktreeModalContent({ } const selectedAgent = selectedAgentResolution.selectedAgent + const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true + const sourceAvailability: SmartModeAvailabilityInput = { + textOnly: selectedRepo != null && !selectedRepoIsGit, + tasksSupported, + hasRepo: selectedRepo != null, + githubAvailable: availableProviders.includes('github'), + gitlabAvailable: availableProviders.includes('gitlab'), + linearAvailable: availableProviders.includes('linear') + } + const pasteRepos = useMemo( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -298,27 +368,68 @@ function NewWorktreeModalContent({ }) void (async () => { - try { - const [settingsResponse, uiResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - setRuntimeSettings(result.settings) - } - if (uiResponse.ok) { - const result = (uiResponse as RpcSuccess).result as { - ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) - } - } catch { - // Non-critical; repo.list owns the visible loading state. + // Why: settle each RPC independently so a flaky availability probe (e.g. a + // linear.status timeout, which rejects rather than resolving {ok:false}) + // can't discard the already-resolved critical settings/ui results. + const probes = Promise.allSettled([ + client.sendRequest('status.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const okResult = (entry: PromiseSettledResult): RpcSuccess | null => + entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null + // Why: hydrate settings/trust the moment their own RPCs settle — gating them + // on the probes (a first-open preflight.check can take seconds) widens the + // window where an already-trusted setup hook spuriously re-prompts on create. + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return } + + const settingsResult = okResult(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: RuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = okResult(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [statusRes, preflightRes, linearRes] = await probes + if (stale) { + return + } + // Tasks is an additive RPC surface, so older paired desktops without the + // capability fall back to branch + blank sources only. + const statusResult = okResult(statusRes) + const capabilities = + (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] + setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) + const glabInstalled = + (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab + ?.installed === true + const linearConnected = + (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + // Drop filterAvailableTaskProviders' forced 'github' fallback when the user + // hid GitHub; the Branch tab always guarantees at least one tab remains. + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) })() return () => { stale = true @@ -486,31 +597,11 @@ function NewWorktreeModalContent({ } } - async function persistSetupHookTrust( - repoId: string, - contentHash: string, - alwaysTrust: boolean - ): Promise { - if (!client) { - return - } - const next = trustedOrcaHooksWithSetupApproval({ - trust: trustedOrcaHooks, - repoId, - contentHash, - alwaysTrust - }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } - setTrustedOrcaHooks(next) - } - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo) { + if (!client || !selectedRepo || createInFlightRef.current) { return } + createInFlightRef.current = true setCreating(true) setError('') @@ -555,24 +646,9 @@ function NewWorktreeModalContent({ // server invent one. The pre-flight basename dedupe is only a hint; // the authoritative collision is checked server-side against git // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = name.trim() + const trimmedName = composer.name.trim() const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) - // Why: mirrors src/renderer/src/store/slices/worktrees.ts - // (createWorktree retry loop). Server-side checks (Branch X already - // exists locally / on a remote / already has PR #N) can fire even - // after the pre-flight basename dedupe — branches outlive worktrees - // in git, and remote branches/PRs aren't visible from worktree.ps. - // Retry up to 25 times by appending -2, -3, ... before surfacing - // the error. The desktop applies this to user-typed names too, so - // mobile follows suit for parity. - const retryablePatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i - ] - const candidateFor = (attempt: number): string => - attempt === 0 ? baseName : `${baseName}-${attempt + 1}` let setupDecision: SetupDecision = 'inherit' if (setupCommand) { if (options.setupOverride) { @@ -602,44 +678,46 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } - let lastError: string | null = null - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = candidateFor(attempt) - const params: Record = { - repo: `id:${selectedRepo.id}`, - startupCommand: command, - setupDecision, - name: candidateName - } - if (selectedAgent.id !== '__blank__') { - params.createdWithAgent = selectedAgent.id - } - if (note.trim()) { - params.comment = note.trim() - } - - const response = await client.sendRequest('worktree.create', params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - onClose() - onCreated(result.worktree.id, candidateName) - return - } - - lastError = response.error.message - if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { - break - } + const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined + const trimmedNote = note.trim() || undefined + const createSelection = composer.createSelection + const result = createSelection + ? await createWorkspaceFromComposerSource({ + client, + selection: createSelection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { + choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', + startupCommand: command + }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: composer.isNameAutoManaged + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + startupCommand: command, + createdWithAgentId, + comment: trimmedNote, + setupDecision + }) + if ('error' in result) { + setError(result.error) + return } - setError(lastError ?? 'Failed to create workspace') + onClose() + onCreated(result.worktreeId, result.name) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { + createInFlightRef.current = false setCreating(false) } } @@ -670,15 +748,74 @@ function NewWorktreeModalContent({ ) function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat the delayed name-field focus; suppressing it - // prevents the keyboard from reopening under the picker drawer. - setNameAutoFocusEnabled(false) + // Why: picker taps can beat an open soft keyboard; dismissing it prevents the + // keyboard from reopening under the picker drawer. Keyboard.dismiss() } + function handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + composer.handleClearSmartNameSelection() + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client, + trust: trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + transitionDrawer('form') + await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void handleCreate({ setupOverride: 'skip' }) + } + return ( <> - + Create Workspace @@ -702,7 +839,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -720,6 +857,18 @@ function NewWorktreeModalContent({ + setError('')} + onOpenDrawer={() => transitionDrawer('source')} + /> + + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -763,28 +912,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -814,6 +941,11 @@ function NewWorktreeModalContent({ {showAdvanced && ( <> + + Note - {/* Sub-modals for pickers — rendered outside the main modal so they - layer on top and scroll without touch conflicts. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state prevents overlapping native modals from swallowing iOS taps. */} + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onClose={() => transitionDrawer('form')} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onSelect={(item) => handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> - setSetupTrustPrompt(null)} - > - {setupTrustPrompt ? ( - - - - {setupTrustPrompt.previouslyApproved - ? `${setupTrustPrompt.repoName}'s setup script changed` - : `Run setup from ${setupTrustPrompt.repoName}?`} - - - This repository's orca.yaml runs before the workspace starts. Only run it if you - trust this repository. - - - - - - {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} - - {setupTrustPrompt.scriptContent} - - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - false - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Run hooks - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - true - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Always trust and run - - - { - setSetupTrustPrompt(null) - void handleCreate({ setupOverride: 'skip' }) - }} - > - Don't run - - - - ) : null} - + void approveSetupTrust(false)} + onAlwaysTrust={() => void approveSetupTrust(true)} + onDontRun={skipSetupTrust} + onClose={closeSetupTrust} + /> ) } @@ -1164,6 +1228,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', @@ -1247,52 +1317,6 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, - trustHeader: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - trustScriptBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md, - marginBottom: spacing.md - }, - trustScriptLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.sm - }, - trustScriptText: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - trustActionGroup: { - backgroundColor: colors.bgPanel, - borderRadius: radii.input, - overflow: 'hidden' - }, - trustActionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md - }, - trustActionText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '500' - }, - trustActionSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..9a062692d17 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SetupHookTrustDrawer.tsx b/mobile/src/components/SetupHookTrustDrawer.tsx new file mode 100644 index 00000000000..6095aaa174f --- /dev/null +++ b/mobile/src/components/SetupHookTrustDrawer.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type Props = { + visible: boolean + prompt: SetupTrustPrompt | null + busy: boolean + onRunOnce: () => void + onAlwaysTrust: () => void + onDontRun: () => void + onClose: () => void +} + +// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace +// create that would run an untrusted setup script. Extracted from NewWorktreeModal +// to keep that file focused; the async persist/create logic stays with the caller. +export function SetupHookTrustDrawer({ + visible, + prompt, + busy, + onRunOnce, + onAlwaysTrust, + onDontRun, + onClose +}: Props) { + return ( + + {prompt ? ( + + + + {prompt.previouslyApproved + ? `${prompt.repoName}'s setup script changed` + : `Run setup from ${prompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you trust + this repository. + + + + + + {prompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {prompt.scriptContent} + + + + + + Run hooks + + + + + Always trust and run + + + + Don't run + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx new file mode 100644 index 00000000000..3d066369ed6 --- /dev/null +++ b/mobile/src/components/SmartSourceModeIcon.tsx @@ -0,0 +1,18 @@ +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes' +import { TaskProviderLogo } from './TaskProviderLogo' + +// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes, +// lucide glyphs for the neutral modes. +export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) { + if (icon.type === 'provider') { + return + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 00000000000..4af0ad682b5 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -0,0 +1,102 @@ +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + composer: MobileComposerSource + selectedRepoIsGit: boolean +} + +// The Advanced-section source controls: the editable Name appears once a source +// pill is shown (the field itself is no longer the name input); the branch-name +// override and reuse toggle mirror the desktop composer's advanced branch fields. +export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) { + const selection = composer.smartNameSelection + const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') + return ( + <> + {selection ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reuseRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + reuseLabel: { + flex: 1, + fontSize: 13, + color: colors.textSecondary + }, + reuseSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..c5f1a6c47db --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + MR_STATE_FILTER_OPTIONS, + resolveAvailableSmartModes, + resolveDefaultSmartMode, + SMART_MODE_OPTIONS, + type SmartModeAvailabilityInput, + type SmartModeOption +} from '../tasks/mobile-smart-source-modes' +import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types' +import { + lookupGitHubItemByOwnerRepo, + type PasteRepoCandidate +} from '../tasks/smart-source-paste-intent' +import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { SmartSourceModeIcon } from './SmartSourceModeIcon' +import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow' + +type Props = { + visible: boolean + client: RpcClient | null + composer: MobileComposerSource + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + onRepoChange: (repoId: string) => void + onClose: () => void +} + +export function SmartWorkspaceSourceDrawer({ + visible, + client, + composer, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + onRepoChange, + onClose +}: Props) { + const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability]) + const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('opened') + // Why: read latest availability inside the open effect without making it a + // reactive dep (the object is recreated each render), so re-seeding happens + // only on open, not on every availability recompute. + const availabilityRef = useRef(availability) + availabilityRef.current = availability + + // Reset to the default mode each time the drawer opens. + useEffect(() => { + if (visible) { + setMode(resolveDefaultSmartMode(availabilityRef.current)) + } + }, [visible]) + + // Snap the chosen mode back into the available set if availability changes. + const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text') + + // Linear searches without a repo; every other provider/branch search needs a + // connected repo-backed target. + const searchEnabled = visible && (effectiveMode === 'linear' || sshReady) + + const { + rows, + loading, + error, + needsGitHubRemote, + emptyHint, + crossRepoPrompt, + dismissCrossRepoPrompt + } = useSmartWorkspaceSource({ + client, + enabled: searchEnabled, + mode: effectiveMode, + query: composer.name, + repoId, + githubAvailable: availability.githubAvailable, + gitlabAvailable: availability.gitlabAvailable, + linearAvailable: availability.linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + }) + + function closeSoon(): void { + setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS) + } + + function handleSelectRow(row: SourceRow): void { + switch (row.kind) { + case 'use-name': + composer.setName(row.name) + break + case 'create-branch': + composer.handleSmartCreateBranch(row.name) + break + case 'github': + composer.handleSmartGitHubItemSelect(row.item) + break + case 'gitlab': + composer.handleSmartGitLabItemSelect(row.item) + break + case 'branch': + composer.handleSmartBranchSelect(row.refName, row.localBranchName) + break + case 'linear': + composer.handleSmartLinearIssueSelect(row.issue) + break + } + onClose() + } + + async function handleAcceptCrossRepo(): Promise { + if (!client || !crossRepoPrompt) { + return + } + const { link, matchingRepo } = crossRepoPrompt + try { + const item = await lookupGitHubItemByOwnerRepo( + client, + matchingRepo.id, + link.slug, + link.number, + link.type + ) + if (item) { + onRepoChange(matchingRepo.id) + composer.handleSmartGitHubItemSelect(item) + onClose() + } + } catch { + dismissCrossRepoPrompt() + } + } + + const showEmpty = + !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0 + + return ( + + + Name or 'Create From' + + Done + + + + + + + {SMART_MODE_OPTIONS.filter((option: SmartModeOption) => + availableModes.includes(option.id) + ).map((option) => { + const selected = option.id === effectiveMode + const tint = selected ? colors.textPrimary : colors.textSecondary + return ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + done: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.accentBlue + }, + search: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.sm + }, + tabRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + tabSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + tabText: { + fontSize: 13, + color: colors.textSecondary + }, + tabTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.sm + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + chipSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + chipText: { + fontSize: 12, + color: colors.textSecondary + }, + chipTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + crossRepo: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm + }, + crossRepoText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm + }, + crossRepoDismiss: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + crossRepoDismissText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoSwitch: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.textSecondary + }, + crossRepoSwitchText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + notice: { + fontSize: 12, + color: colors.textMuted, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + errorNotice: { + fontSize: 12, + color: colors.statusRed, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + list: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + maxHeight: 420, + flexGrow: 0 + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + empty: { + paddingVertical: spacing.lg, + textAlign: 'center', + color: colors.textMuted, + fontSize: 13 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 00000000000..eb08ddca66a --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,145 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + composer: MobileComposerSource + label: string + disabled?: boolean + onBeforeOpen?: () => void + onOpenDrawer: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + composer, + label, + disabled, + onBeforeOpen, + onOpenDrawer +}: Props) { + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + onOpenDrawer() + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url).catch(() => {})} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + disabled: { + opacity: 0.55 + }, + inputText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + inputPlaceholder: { + color: colors.textMuted + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + pillLabel: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 00000000000..ee601c2ebde --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -0,0 +1,134 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + row: SourceRow + onPress: () => void +} + +type RowContent = { + icon: React.ReactNode + title: string + subtitle?: string + status?: string +} + +function resolveRowContent(row: SourceRow): RowContent { + switch (row.kind) { + case 'use-name': + return { + icon: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + width: 18, + alignItems: 'center' + }, + copy: { + flex: 1, + minWidth: 0 + }, + title: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + subtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1 + }, + pill: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pillText: { + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'capitalize' + } +}) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts new file mode 100644 index 00000000000..1d35d29f758 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createBlankWorkspace } from './blank-workspace-create' + +type Call = { method: string; params: unknown } + +function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params }) + const result = script(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('createBlankWorkspace', () => { + it('assembles exactly the params the modal historically sent, omitting empty extras', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + method: 'worktree.create', + params: { + repo: 'id:repo-1', + startupCommand: undefined, + setupDecision: 'inherit', + name: 'octopus' + } + }) + const params = calls[0]?.params as Record + expect('createdWithAgent' in params).toBe(false) + expect('comment' in params).toBe(false) + }) + + it('includes createdWithAgent and comment only when provided', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + startupCommand: 'claude', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run' + }) + + expect(calls[0]?.params).toMatchObject({ + repo: 'id:repo-2', + name: 'manatee', + startupCommand: 'claude', + setupDecision: 'run', + createdWithAgent: 'claude', + comment: 'spike' + }) + }) + + it('retries with a numeric suffix on a branch-collision error', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktree: { id: 'wt-3' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + const retryParams = calls[1]?.params as Record + expect(retryParams.name).toBe('octopus-2') + }) + + it('retries on the bare older-runtime collision message', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists.') + } + return { worktree: { id: 'wt-4' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + }) + + it('surfaces a non-collision error without retrying', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('SSH connection is not available'), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'skip' + }) + + expect(result).toEqual({ error: 'SSH connection is not available' }) + expect(calls).toHaveLength(1) + }) +}) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts new file mode 100644 index 00000000000..014af558198 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorkspaceCreateSetupDecision } from './workspace-create-params' + +// The blank/named create path, extracted from NewWorktreeModal so the modal keeps +// only the UI-coupled setup-trust flow. Assembles worktree.create params and +// applies the shared name-collision retry. +export async function createBlankWorkspace(args: { + client: RpcClient + repoId: string + baseName: string + startupCommand: string | undefined + createdWithAgentId: TuiAgent | undefined + comment: string | undefined + setupDecision: WorkspaceCreateSetupDecision +}): Promise { + return createWorktreeWithNameRetry({ + client: args.client, + baseName: args.baseName, + buildParams: (name) => { + const params: Record = { + repo: `id:${args.repoId}`, + startupCommand: args.startupCommand, + setupDecision: args.setupDecision, + name + } + if (args.createdWithAgentId) { + params.createdWithAgent = args.createdWithAgentId + } + if (args.comment) { + params.comment = args.comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 00000000000..d08d1388a0e --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' + +describe('linked work item builders', () => { + it('maps a GitHub PR into a linked work item', () => { + const linked = buildGitHubLinkedWorkItem({ + type: 'pr', + number: 42, + title: 'Fix bug', + url: 'https://github.com/o/r/pull/42', + repoId: 'repo-1' + }) + expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' }) + }) + + it('maps a GitLab MR into a linked work item', () => { + const linked = buildGitLabLinkedWorkItem({ + type: 'mr', + number: 7, + title: 'Add feature', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + repoId: 'repo-2' + }) + expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' }) + }) + + it('maps a Linear issue with identifier, workspace, and org key', () => { + const linked = buildLinearLinkedWorkItem({ + identifier: 'ENG-9', + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + workspaceId: 'ws-1' + }) + expect(linked).toMatchObject({ + provider: 'linear', + type: 'issue', + number: 0, + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + }) + }) +}) + +describe('shouldApplyAutoName', () => { + it('applies when the name is empty or the previous auto-name', () => { + expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) + }) + + it('applies when the name is a lookup query (URL / #N)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) + }) + + it('keeps a deliberately typed name', () => { + expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe( + false + ) + }) +}) + +describe('resolveWorkItemAutoName', () => { + it('slugifies the title subject', () => { + expect( + resolveWorkItemAutoName({ + type: 'issue', + number: 3, + title: 'Fix the Login Bug', + provider: 'github' + }) + ).toBe('fix-the-login-bug') + }) +}) + +describe('buildSmartNameSelection', () => { + const base = (over: Record) => ({ + provider: 'github' as const, + type: 'pr' as const, + number: 12, + title: 'T', + url: 'u', + ...over + }) + + it('maps GitHub PR / issue kinds and numbers the label', () => { + expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({ + kind: 'github-pr', + label: '#12 T', + url: 'u' + }) + expect( + buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined }) + ).toMatchObject({ kind: 'github-issue' }) + }) + + it('maps GitLab MR / issue kinds', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-mr' }) + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-issue' }) + }) + + it('maps Linear with a bare title label', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }), + baseBranch: undefined + }) + ).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' }) + }) + + it('falls back to a branch pill', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({ + kind: 'branch', + label: 'main' + }) + }) + + it('returns null when nothing is selected', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull() + }) +}) + +describe('resolveComposerCreateSelection', () => { + const baseCreateArgs = { + branch: null, + reuseEligibleBranch: null, + reuseSelectedBranch: false, + branchCreateIntent: false, + name: '' + } + + it('prefers a linked work item and passes resolved base fields', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 5, + title: 'T', + url: 'u', + repoId: 'repo-1' + }, + base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' } + }) + expect(selection).toMatchObject({ + kind: 'work-item', + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'pr-5' + }) + }) + + it('marks reuse when the eligible branch is toggled on', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: { baseBranch: 'feature', branchNameOverride: 'feature' }, + branch: { refName: 'feature', localBranchName: 'feature' }, + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true + }) + expect(selection).toEqual({ + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + }) + }) + + it('returns a new-branch selection when create-branch intent is set', () => { + expect( + resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: {}, + branchCreateIntent: true, + name: 'feature/login' + }) + ).toEqual({ kind: 'new-branch', branchName: 'feature/login' }) + }) + + it('returns null with no work item, no branch base, and no intent', () => { + expect( + resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} }) + ).toBeNull() + }) +}) + +describe('resolveComposerBranchPick', () => { + it('auto-names and enables reuse for an unused local branch', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: [] + }) + expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' }) + expect(pick).toMatchObject({ + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true, + name: 'feature' + }) + }) + + it('does not reuse a branch already checked out elsewhere', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: ['refs/heads/feature'] + }) + expect(pick.reuseEligibleBranch).toBeNull() + expect(pick.reuseSelectedBranch).toBe(false) + expect(pick.base.branchNameOverride).toBeUndefined() + }) +}) + +// Keep the exported type aliases referenced so the module surface stays covered. +export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 00000000000..ed2bc4dbf2d --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,153 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' +import { + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export function buildGitHubLinkedWorkItem(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitHubWorkspaceSource(item) +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitLabWorkspaceSource(item) +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + return buildLinearWorkspaceSource(issue) +} + +// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name +// replaces the current field only when it's empty, still the last auto-name, or +// a lookup query — never a name the user deliberately typed. +export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { + return shouldApplyWorkspaceSourceAutoName(args) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getWorkspaceSourceName({ ...item, url: '' }).seedName +} + +export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { + return getLinearIssueWorkspaceName(issue) +} + +// Derives the pill descriptor from the linked item (or a plain branch base), +// mirroring desktop's smartNameSelection memo. +export function buildSmartNameSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + baseBranch: string | undefined +}): SmartNameSelection | null { + return buildWorkspaceSourceSelection(args) as SmartNameSelection | null +} + +// Derives the create-time selection from composer state: a linked work item wins +// (carrying its resolved base/push fields), else a picked branch, else null (a +// name-only/blank create). +export function resolveComposerCreateSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + base: { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + branch: { refName: string; localBranchName: string } | null + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + branchCreateIntent: boolean + name: string +}): MobileComposerCreateSelection | null { + const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args + if (linkedWorkItem) { + return { + kind: 'work-item', + item: linkedWorkItem, + baseBranch: base.baseBranch, + compareBaseRef: base.compareBaseRef, + pushTarget: base.pushTarget, + branchNameOverride: base.branchNameOverride + } + } + if (branch && base.baseBranch) { + return { + kind: 'branch', + baseBranch: base.baseBranch, + refName: branch.refName, + localBranchName: branch.localBranchName, + reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName, + branchNameOverride: base.branchNameOverride + } + } + if (args.branchCreateIntent && args.name.trim()) { + return { kind: 'new-branch', branchName: args.name.trim() } + } + return null +} + +export type ComposerBranchPick = { + base: { baseBranch: string; branchNameOverride?: string } + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + name?: string + lastAutoName?: string +} + +// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse +// eligibility/default + the auto-name to apply, from the shared branch helpers. +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveSharedComposerBranchPick(args) + return { + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 00000000000..73c7eaa1839 --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,76 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { GitHubPrStartPoint } from '../../../src/shared/types' + +// The resolved start point for a linked PR/MR: the base branch to create from +// plus the optional review-compare ref, push target, and exact branch name. +export type ComposerHostedBase = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> + +type HostedBaseResult = ComposerHostedBase | { error: string } + +// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's +// select-time resolution. The runtime returns a soft { error } payload rather +// than an RPC error for provider failures. +export async function resolveComposerPrBase(args: { + client: RpcClient + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${repoId}`, + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + return result +} + +// Resolves a GitLab MR's base via worktree.resolveMrBase. +export async function resolveComposerMrBase(args: { + client: RpcClient + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${repoId}`, + mrIid, + ...(sourceBranch ? { sourceBranch } : {}), + ...(targetBranch ? { targetBranch } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 00000000000..d36215189af --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,64 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask +// superset): the one work item a Smart selection pins the workspace to. Linear +// items carry the workspace/org routing the runtime needs to relink the issue. +export type MobileLinkedWorkItem = Omit & { + provider: Exclude +} + +export type SmartNameSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + +// The pill descriptor the field renders once a source is selected. Same shape +// as desktop's `SmartWorkspaceNameSelection`. +export type SmartNameSelection = Omit & { + kind: SmartNameSelectionKind +} + +// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default +// is 'opened' (Open). +export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' + +// The resolved selection the create flow consumes. Work-item selections carry +// the base/push fields the composer resolved at select time; branch selections +// carry the ref + reuse intent. +export type MobileComposerCreateSelection = + | { + kind: 'work-item' + item: MobileLinkedWorkItem + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + | { + kind: 'branch' + baseBranch: string + refName: string + localBranchName: string + reuse: boolean + branchNameOverride?: string + } + // A brand-new branch created by name (no ref picked); the branch is created off + // the repo's default base and the typed name is kept verbatim as the branch. + | { kind: 'new-branch'; branchName: string } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 00000000000..dc726569095 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MR_STATE_FILTER, + MR_STATE_FILTER_OPTIONS, + normalizeSmartMode, + resolveAvailableSmartModes, + resolveDefaultSmartMode +} from './mobile-smart-source-modes' + +const fullyAvailable = { + textOnly: false, + tasksSupported: true, + hasRepo: true, + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true +} + +describe('resolveAvailableSmartModes', () => { + it('lists every mode in desktop order when all are available', () => { + expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([ + 'smart', + 'github', + 'linear', + 'gitlab', + 'branches', + 'text' + ]) + }) + + it('collapses to Name for a non-git (text-only) repo', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text']) + }) + + it('drops provider + smart modes when the tasks RPC surface is missing', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([ + 'branches', + 'text' + ]) + }) + + it('gates provider tabs on their availability and a selected repo', () => { + expect( + resolveAvailableSmartModes({ + ...fullyAvailable, + githubAvailable: false, + gitlabAvailable: false + }) + ).toEqual(['smart', 'linear', 'branches', 'text']) + expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([ + 'smart', + 'linear', + 'text' + ]) + }) +}) + +describe('resolveDefaultSmartMode', () => { + it('defaults to smart for a git repo with search', () => { + expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart') + }) + + it('defaults to text for a non-git repo', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text') + }) + + it('defaults to branches for a git repo without the tasks surface', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches') + }) +}) + +describe('normalizeSmartMode', () => { + it('keeps a valid mode and snaps an unavailable one back to default', () => { + expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab') + expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe( + 'smart' + ) + }) +}) + +describe('MR state filters', () => { + it('exposes Open/Merged/Closed/All with an Open default', () => { + expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all']) + expect(DEFAULT_MR_STATE_FILTER).toBe('opened') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 00000000000..911219d28c4 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -0,0 +1,93 @@ +import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types' + +// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand +// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand +// icons. +export type SmartModeIcon = + | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' } + | { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' } + +export type SmartModeOption = { + id: SmartNameMode + label: string + icon: SmartModeIcon +} + +// Order + labels + icons mirror desktop getSmartWorkspaceNameModes(): +// Smart · GitHub · Linear · GitLab · Branch · Name. +export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [ + { id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } }, + { id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } }, + { id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } }, + { id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } }, + { id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } }, + { id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } } +] + +export type SmartModeAvailabilityInput = { + textOnly: boolean + tasksSupported: boolean + hasRepo: boolean + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean +} + +// Faithful port of the desktop availableModes filter. Non-git repos collapse to +// the Name tab; provider tabs gate on availability + a selected repo + the tasks +// RPC surface; branches only need a git repo (new-branch-by-name works without +// the search capability). +export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] { + if (input.textOnly) { + return ['text'] + } + return SMART_MODE_OPTIONS.filter((option) => { + switch (option.id) { + case 'smart': + return input.tasksSupported + case 'github': + return input.tasksSupported && input.hasRepo && input.githubAvailable + case 'gitlab': + return input.tasksSupported && input.hasRepo && input.gitlabAvailable + case 'linear': + return input.tasksSupported && input.linearAvailable + case 'branches': + return input.hasRepo + case 'text': + return true + } + }).map((option) => option.id) +} + +// Default mode when the picker opens: 'smart' for a git repo when search is +// available, else the first available mode (branches for git without tasks, +// 'text' for non-git). +export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode { + const available = resolveAvailableSmartModes(input) + if (available.includes('smart')) { + return 'smart' + } + return available[0] ?? 'text' +} + +// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a +// non-git folder), mirroring desktop's snap-to-available effect. +export function normalizeSmartMode( + mode: SmartNameMode, + input: SmartModeAvailabilityInput +): SmartNameMode { + const available = resolveAvailableSmartModes(input) + return available.includes(mode) ? mode : resolveDefaultSmartMode(input) +} + +export type MrStateFilterOption = { id: MrStateFilter; label: string } + +// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'. +export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [ + { id: 'opened', label: 'Open' }, + { id: 'merged', label: 'Merged' }, + { id: 'closed', label: 'Closed' }, + { id: 'all', label: 'All' } +] + +export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened' diff --git a/mobile/src/tasks/mobile-tasks-capability.ts b/mobile/src/tasks/mobile-tasks-capability.ts new file mode 100644 index 00000000000..f8498bfd968 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-capability.ts @@ -0,0 +1,5 @@ +// Runtime capability the desktop advertises when it supports the mobile Tasks RPC +// surface (github/gitlab/linear work items + repo.searchRefs). Older paired +// desktops omit it, so mobile must degrade to blank/new-branch sources only. +// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts. +export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index b09bdd0d4b3..0e2d91bb7ac 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { isSetupHookTrusted, normalizeSetupHookTrust, + persistSetupHookTrustApproval, trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { it('trusts a setup script only when the approved hash matches', () => { @@ -71,6 +73,27 @@ describe('setup hook trust', () => { }) }) + it('persists and returns the approved trust state', async () => { + let persisted: unknown + const client = { + sendRequest: async (_method: string, params: unknown) => { + persisted = params + return { ok: true, result: null } + } + } as unknown as RpcClient + + const next = await persistSetupHookTrustApproval({ + client, + trust: {}, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false + }) + + expect(persisted).toEqual({ trustedOrcaHooks: next }) + expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true) + }) + it('detects previous setup approval and ignores incomplete trust payloads', () => { expect( wasSetupHookPreviouslyApproved( diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 847420f1e86..04c52152f45 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,5 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { contentHash: string @@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: { return { ...args.trust, [args.repoId]: nextRepo } } +export async function persistSetupHookTrustApproval(args: { + client: RpcClient + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean +}): Promise { + const next = trustedOrcaHooksWithSetupApproval(args) + const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + return next +} + export function normalizeSetupHookTrust( setupTrust: SetupHookTrust | null | undefined ): SetupHookTrust | null { diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 00000000000..cffdc287e22 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch } from './smart-source-fan-out' + +type Call = { method: string; params: Record } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = byMethod[method] + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const smartArgs = { + mode: 'smart' as const, + query: 'bug', + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true, + mrStateFilter: 'opened' as const, + linearWorkspaceId: null +} + +describe('fanOutSmartSearch', () => { + it('fans out to every provider in smart mode and stamps repoId', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] }, + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] }, + 'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(calls.map((c) => c.method).sort()).toEqual([ + 'github.listWorkItems', + 'gitlab.listWorkItems', + 'linear.searchIssues', + 'repo.searchRefs' + ]) + expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' }) + expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' }) + expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' }) + expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }]) + expect(result.error).toBe('') + }) + + it('swallows a single provider failure in smart mode (best-effort)', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': new Error('gh down'), + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [] }, + 'repo.searchRefs': { refDetails: [] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(result.error).toBe('') + expect(result.gitlabItems).toHaveLength(1) + }) + + it('surfaces the error for a single-provider mode', async () => { + const calls: Call[] = [] + const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls) + const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client }) + expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems']) + expect(result.error).toBe('gl boom') + }) + + it('only searches branches in smart mode when the query is non-empty', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, query: '', client }) + expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs') + }) + + it('skips GitHub in smart mode when GitHub is unavailable', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) + expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') + }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 00000000000..e2217597ab9 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,141 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import { + isSmartWorkspaceSourceQueryWithinLimit, + type SmartNameMode +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + searchBranches, + searchGitHubItems, + searchGitLabItems, + searchLinearIssues +} from './smart-source-search-requests' + +export type SmartFanOutResult = { + githubItems: GitHubWorkItem[] + gitlabItems: GitLabWorkItem[] + linearIssues: LinearIssue[] + branches: BaseRefSearchResult[] + needsGitHubRemote: boolean + error: string +} + +const EMPTY: Omit = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] +} + +function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean { + return githubAvailable && (mode === 'smart' || mode === 'github') +} + +function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean { + return gitlabAvailable && (mode === 'smart' || mode === 'gitlab') +} + +function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean { + return linearAvailable && (mode === 'smart' || mode === 'linear') +} + +function shouldSearchBranches(mode: SmartNameMode, query: string): boolean { + return mode === 'branches' || (mode === 'smart' && query.trim().length > 0) +} + +type FanOutArgs = { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined +} + +// Runs every provider search the active mode needs, concurrently. Smart mode is +// best-effort (a single provider failure never blocks the others); single-provider +// modes surface the failure. No cross-provider ranking/dedup — the shared row +// builder concatenates in provider order. +export async function fanOutSmartSearch(args: FanOutArgs): Promise { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } + const { + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter + } = args + const isSmart = mode === 'smart' + const tasks = { + github: + shouldSearchGitHub(mode, githubAvailable) && repoId + ? searchGitHubItems(client, repoId, query) + : null, + gitlab: + shouldSearchGitLab(mode, gitlabAvailable) && repoId + ? searchGitLabItems(client, repoId, query, mrStateFilter) + : null, + linear: shouldSearchLinear(mode, linearAvailable) + ? searchLinearIssues(client, query, args.linearWorkspaceId) + : null, + branches: + shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null + } + const [github, gitlab, linear, branches] = await Promise.allSettled([ + tasks.github ?? Promise.resolve([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + let needsGitHubRemote = false + let error = '' + const fail = (reason: unknown) => { + if (!isSmart) { + error = reason instanceof Error ? reason.message : 'Search failed' + } + } + if (github.status === 'rejected') { + if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) { + needsGitHubRemote = true + } else { + fail(github.reason) + } + } + if (gitlab.status === 'rejected') { + fail(gitlab.reason) + } + if (linear.status === 'rejected') { + fail(linear.reason) + } + if (branches.status === 'rejected') { + fail(branches.reason) + } + + return { + ...EMPTY, + githubItems: github.status === 'fulfilled' ? github.value : [], + gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [], + linearIssues: linear.status === 'fulfilled' ? linear.value : [], + branches: branches.status === 'fulfilled' ? branches.value : [], + needsGitHubRemote, + error + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 00000000000..f7c724e7637 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + findRepoMatchingSlugForPaste, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' + +describe('resolvePasteIntent', () => { + it('classifies a GitHub issue/PR URL as a github-link', () => { + expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ + kind: 'github-link', + link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + }) + }) + + it('classifies a bare #number as a github-number', () => { + expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 }) + }) + + it('classifies a GitLab MR URL as a gitlab-link', () => { + const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8') + expect(intent?.kind).toBe('gitlab-link') + if (intent?.kind === 'gitlab-link') { + expect(intent.link).toMatchObject({ number: 8, type: 'mr' }) + } + }) + + it('returns null for plain search text', () => { + expect(resolvePasteIntent('login bug')).toBeNull() + }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).toBeNull() + }) +}) + +describe('deriveRepoSlug', () => { + it('prefers the upstream identity', () => { + expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({ + owner: 'up', + repo: 'stream' + }) + }) + + it('parses an SSH remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('parses an HTTPS remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('returns null when no slug can be derived', () => { + expect(deriveRepoSlug({})).toBeNull() + }) +}) + +describe('findRepoMatchingSlug', () => { + const repos: PasteRepoCandidate[] = [ + { id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } }, + { id: 'b', displayName: 'B', slug: null } + ] + + it('matches case-insensitively', () => { + expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a') + }) + + it('returns null when no repo matches', () => { + expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() + }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + let calls = 0 + const client = { + sendRequest: async () => { + calls += 1 + return { + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + } + } + } as unknown as RpcClient + const cache = new Map() + + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache) + ).resolves.toBeNull() + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache) + ).resolves.toBeNull() + expect(calls).toBe(1) + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 00000000000..72c23a3737f --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,178 @@ +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + normalizeGitHubLinkQuery, + parseGitHubIssueOrPRLink, + type GitHubIssueOrPRLink, + type RepoSlug +} from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' + +// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived +// best-effort from the repo's remote metadata. +export type PasteRepoCandidate = { + id: string + displayName: string + slug: RepoSlug | null +} + +export type GitHubPasteIntent = + | { kind: 'github-link'; link: GitHubIssueOrPRLink } + | { kind: 'github-number'; number: number } + +export type GitLabPasteIntent = { + kind: 'gitlab-link' + link: NonNullable> +} + +export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null + +// Pure: classify pasted text into a work-item lookup intent. A slug-bearing +// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; +// a GitLab issue/MR URL becomes 'gitlab-link'. +export function resolvePasteIntent(query: string): PasteIntent { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } + const trimmed = query.trim() + if (!trimmed) { + return null + } + const ghLink = parseGitHubIssueOrPRLink(trimmed) + if (ghLink) { + return { kind: 'github-link', link: ghLink } + } + const normalizedGh = normalizeGitHubLinkQuery(trimmed) + if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) { + return { kind: 'github-number', number: normalizedGh.directNumber } + } + const glLink = parseGitLabIssueOrMRLink(trimmed) + if (glLink) { + return { kind: 'gitlab-link', link: glLink } + } + return null +} + +// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted +// cross-repo URL can be matched to a locally known repo. +export function deriveRepoSlug(repo: { + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null +}): RepoSlug | null { + if (repo.upstream?.owner && repo.upstream.repo) { + return { owner: repo.upstream.owner, repo: repo.upstream.repo } + } + const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' + const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) + if (match) { + return { owner: match[1], repo: match[2] } + } + return null +} + +function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { + if (!a || !b) { + return false + } + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +export function findRepoMatchingSlug( + repos: readonly PasteRepoCandidate[], + slug: RepoSlug +): PasteRepoCandidate | null { + return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null +} + +export async function findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + if (!response.ok && response.error.code === 'method_not_found') { + // Why: RPC availability is host-wide; avoid repeating an unsupported + // probe for every repo or on the next paste attempt. + repos.forEach((candidate) => cache.set(candidate.id, null)) + return null + } + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitHubItemByOwnerRepo( + client: RpcClient, + repoId: string, + slug: RepoSlug, + number: number, + type: 'issue' | 'pr' +): Promise { + const response = await client.sendRequest('github.workItemByOwnerRepo', { + repo: `id:${repoId}`, + owner: slug.owner, + ownerRepo: slug.repo, + number, + type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitLabItemByPath( + client: RpcClient, + repoId: string, + link: NonNullable> +): Promise { + const response = await client.sendRequest('gitlab.workItemByPath', { + repo: `id:${repoId}`, + host: link.slug.host, + path: link.slug.path, + iid: link.number, + type: link.type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitLabWorkItem | null + return item ? { ...item, repoId } : null +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 00000000000..ecf029feb70 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests' + +type Call = { method: string; params: Record } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('scopeGitHubQuery', () => { + it('passes the raw trimmed query so BOTH issues and PRs are returned', () => { + // Empty stays empty (runtime lists recent issues + PRs); no forced is:issue. + expect(scopeGitHubQuery('')).toBe('') + expect(scopeGitHubQuery(' login bug ')).toBe('login bug') + }) + + it('preserves an explicit is:pr / is:issue scope the user typed', () => { + expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth') + expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth') + }) +}) + +describe('searchLinearIssues', () => { + it('lists assigned issues for an empty query (desktop default)', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, '', null) + expect(calls[0]!.method).toBe('linear.listIssues') + expect(calls[0]!.params).toMatchObject({ filter: 'assigned' }) + }) + + it('searches when a query is present', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, 'bug', 'ws-1') + expect(calls[0]!.method).toBe('linear.searchIssues') + expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 00000000000..5875cd4d642 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -0,0 +1,119 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' + +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const BRANCH_LIMIT = 20 + +// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's +// parseTaskQuery defaults scope 'all', and an empty query lists recent items of +// both types. So pass the raw trimmed query straight through (an explicit +// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty +// so the runtime lists recent issues + PRs. +export function scopeGitHubQuery(query: string): string { + return query.trim() +} + +export async function searchGitHubItems( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest('github.listWorkItems', { + repo: `id:${repoId}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubQuery(query) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // Stamp repoId so the shared row builder + create flow can attribute each item + // to the searched repo (the runtime omits it, like the desktop fetcher). + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchGitLabItems( + client: RpcClient, + repoId: string, + query: string, + state: MrStateFilter +): Promise { + const response = await client.sendRequest('gitlab.listWorkItems', { + repo: `id:${repoId}`, + state, + page: 1, + perPage: GITLAB_PER_PAGE, + query: query.trim() || undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { + items: GitLabWorkItem[] + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + throw new Error(envelope.error.message) + } + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchLinearIssues( + client: RpcClient, + query: string, + linearWorkspaceId: string | null | undefined +): Promise { + const trimmed = query.trim() + const response = trimmed + ? await client.sendRequest('linear.searchIssues', { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + : await client.sendRequest('linear.listIssues', { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the + // row builder/create flow read (id/identifier/title/url/state/team) are a subset. + return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] +} + +export async function searchBranches( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest( + 'repo.searchRefs', + { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + return ( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts new file mode 100644 index 00000000000..d23e0c66317 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' + +type Call = { method: string; params: Record } + +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const agent = { choice: 'blank' as const, startupCommand: undefined } + +const baseArgs = { + targetRepoId: 'repo-1', + setupDecision: 'inherit' as const, + agent, + workspaceName: undefined, + note: undefined +} + +describe('createWorkspaceFromComposerSource', () => { + it('creates a GitHub issue workspace linking the issue to its own repo', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" slug. + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-9', + linkedIssue: 7, + displayName: 'Bug' + }) + }) + + it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'pr', + number: 3, + title: 'Feat', + url: 'u', + repoId: 'repo-1' + }, + baseBranch: 'main', + compareBaseRef: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' }, + branchNameOverride: 'feat-3' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.create']) + expect(calls[0]!.params).toMatchObject({ + linkedPR: 3, + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feat-3', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' } + }) + }) + + it('resolves a PR base as a fallback when the selection carries none', async () => { + const calls: Call[] = [] + const client = fakeClient( + (method) => + method === 'worktree.resolvePrBase' + ? { baseBranch: 'develop' } + : { worktree: { id: 'wt-3' } }, + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create']) + expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 }) + }) + + it('creates a Linear workspace with workspace + org routing', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) + + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect('error' in result).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.params).toMatchObject({ + baseBranch: 'feature', + branchNameOverride: 'feature' + }) + }) + + it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + name: 'feature/login', + branchNameOverride: 'feature/login' + }) + }) + + it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-1' + } + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'my-name', + nameIsAutoManaged: false + }) + expect(calls[0]!.params.displayName).toBeUndefined() + expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + }) + + it('creates a new branch off a ref, bumping the branch on collision', async () => { + const calls: Call[] = [] + const client = fakeClient( + (_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' }) + expect(calls).toHaveLength(2) + expect(calls[1]!.params).toMatchObject({ + baseBranch: 'main', + branchNameOverride: 'topic-2', + name: 'topic-2' + }) + }) +}) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts new file mode 100644 index 00000000000..d16cc174142 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.ts @@ -0,0 +1,250 @@ +import type { RpcClient } from '../transport/rpc-client' +import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem +} from './mobile-composer-source-types' +import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' +import { + buildTaskWorkspaceCreateParams, + type WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem +} from './workspace-create-params' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' + +// The agent bundle the modal already resolved: the choice drives +// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch +// command is used for branch sources (which have no work-item URL to seed the draft). +export type WorkspaceCreateAgentBundle = { + choice: WorkspaceAgentChoice + startupCommand: string | undefined +} + +export type CreateWorkspaceFromComposerArgs = { + client: RpcClient + selection: MobileComposerCreateSelection + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +} + +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + if (args.selection.kind === 'branch') { + return createBranchWorkspace({ ...args, selection: args.selection }) + } + if (args.selection.kind === 'new-branch') { + return createNewBranchWorkspace({ ...args, selection: args.selection }) + } + return createWorkItemWorkspace({ ...args, selection: args.selection }) +} + +function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { + if (item.provider === 'github') { + return { + provider: 'github', + source: { + type: item.type === 'pr' ? 'pr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + if (item.provider === 'gitlab') { + return { + provider: 'gitlab', + source: { + type: item.type === 'mr' ? 'mr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + return { + provider: 'linear', + source: { + identifier: item.linearIdentifier ?? '', + title: item.title, + url: item.url, + ...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}), + ...(item.linearOrganizationUrlKey + ? { organizationUrlKey: item.linearOrganizationUrlKey } + : {}) + } + } +} + +async function createWorkItemWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) + + // The composer resolves PR/MR base at select time; only re-resolve as a + // fallback when a linked PR/MR reached create without one. + let baseBranch = selection.baseBranch + let compareBaseRef = selection.compareBaseRef + let pushTarget = selection.pushTarget + let branchNameOverride = selection.branchNameOverride + if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) { + const repoId = item.repoId ?? targetRepoId + const resolved = + item.type === 'pr' + ? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null) + : await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null) + if (resolved) { + baseBranch = resolved.baseBranch + compareBaseRef = resolved.compareBaseRef + pushTarget = resolved.pushTarget + branchNameOverride = resolved.branchNameOverride ?? branchNameOverride + } + } + + const params = buildTaskWorkspaceCreateParams({ + item: taskItem, + targetRepoId, + setupDecision, + agent: agent.choice, + workspaceName, + note, + baseBranch, + compareBaseRef, + branchNameOverride, + pushTarget, + nameIsAutoManaged: args.nameIsAutoManaged + }) + // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base + // so collisions still append -2, -3, ... like the blank path does. + const baseName = String(params.name) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (name) => ({ ...params, name }) + }) +} + +async function createBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + const applyCommon = (params: Record): Record => { + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + + if (selection.reuse) { + // Reusing a fixed existing branch: branchNameOverride is pinned to the reused + // branch, so a branch collision can't be cleared by suffixing the display + // name — fail fast instead of burning the retry budget. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ + repo: `id:${targetRepoId}`, + name, + setupDecision, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, + startupCommand: agent.startupCommand + }) + }) + } + + // New branch off the selected ref. The retry base is the branch name so a + // collision bumps the branch itself. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.branchNameOverride || selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + baseBranch: selection.baseBranch, + startupCommand: agent.startupCommand + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + // A brand-new branch off the repo's default base. The typed name is kept as the + // git branch (via branchNameOverride) so a slash like `feature/login` survives; + // the runtime sanitizes the worktree folder from the same name. The retry base is + // the branch name so a collision bumps the branch (and folder) together. + return createWorktreeWithNameRetry({ + client, + baseName: selection.branchName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 00000000000..6785e2fe30b --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,330 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' +import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' +import type { RpcClient } from '../transport/rpc-client' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveLinearAutoName, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' +import { + resolveComposerMrBase, + resolveComposerPrBase, + type ComposerHostedBase +} from './composer-source-base-resolve' +import type { + ComposerBaseState, + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +const EMPTY_BASE: ComposerBaseState = {} + +export type UseMobileComposerSourceArgs = { + client: RpcClient | null + selectedRepoId: string | null + worktreeBranches?: readonly string[] + onError?: (message: string) => void +} + +export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { + const { client, selectedRepoId, worktreeBranches = [], onError } = args + const [name, setNameState] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " row is picked, so the typed name (which + // may contain slashes) is kept verbatim as the git branch (folder is sanitized). + const [branchCreateIntent, setBranchCreateIntent] = useState(false) + + const lastAutoNameRef = useRef('') + const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) + // Guards async base resolution: only the latest selection applies its result. + const resolveTokenRef = useRef(0) + + const setName = useCallback((value: string) => setNameState(value), []) + + const applyAutoName = useCallback((suggested: string, currentName: string) => { + if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, []) + + const clearBaseAndBranch = useCallback(() => { + branchSelectionRef.current = null + setBranchCreateIntent(false) + setBase(EMPTY_BASE) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + // Why: a superseding selection bumps the resolve token, so an in-flight base + // resolve's token-gated finally can no longer clear this — reset it here so + // resolvingBase never sticks true after switching sources. + setResolvingBase(false) + }, []) + + // Applies an async PR/MR base resolution guarded by the current token so only + // the latest selection wins; failures clear the base and surface the error. + const runBaseResolve = useCallback( + (token: number, resolve: Promise) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + setForkPushWarning(getForkPushWarning(result)) + }) + .catch((error: unknown) => { + if (resolveTokenRef.current !== token) { + return + } + setBase(EMPTY_BASE) + onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.') + }) + .finally(() => { + if (resolveTokenRef.current === token) { + setResolvingBase(false) + } + }) + }, + [onError] + ) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem) => { + const token = (resolveTokenRef.current += 1) + const identity = resolveGitHubWorkItemIdentity(item) + // Resolve the PR base against the item's OWN repo — a cross-repo accept + // switches repos then selects synchronously, so selectedRepoId is stale. + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitHubLinkedWorkItem({ + type: identity.type, + number: identity.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }), + name + ) + clearBaseAndBranch() + if (identity.type !== 'pr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerPrBase({ + client, + repoId, + prNumber: identity.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem) => { + const token = (resolveTokenRef.current += 1) + // Resolve the MR base against the item's OWN repo (see the GitHub handler). + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitLabLinkedWorkItem({ + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ + type: item.type, + number: item.number, + title: item.title, + provider: 'gitlab' + }), + name + ) + clearBaseAndBranch() + if (item.type !== 'mr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerMrBase({ + client, + repoId, + mrIid: item.number, + ...(item.branchName ? { sourceBranch: item.branchName } : {}), + ...(item.baseRefName ? { targetBranch: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) + const suggested = resolveLinearAutoName(issue) + const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + if ( + suggested && + (identifierTyped || + shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + clearBaseAndBranch() + }, + [clearBaseAndBranch, name] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string, localBranchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + setForkPushWarning(null) + setBranchCreateIntent(false) + setResolvingBase(false) + const pick = resolveComposerBranchPick({ + refName, + localBranchName, + currentName: name, + lastAutoName: lastAutoNameRef.current, + worktreeBranches + }) + setReuseEligibleBranch(pick.reuseEligibleBranch) + setReuseSelectedBranch(pick.reuseSelectedBranch) + setBase(pick.base) + branchSelectionRef.current = { refName, localBranchName } + if (pick.name !== undefined) { + setNameState(pick.name) + lastAutoNameRef.current = pick.lastAutoName ?? '' + } + }, + [name, worktreeBranches] + ) + + // Picking "Create branch ": name the workspace and mark a new-branch + // intent so the typed (possibly slashy) name is kept verbatim as the git branch. + const handleSmartCreateBranch = useCallback( + (branchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setNameState(branchName) + lastAutoNameRef.current = branchName + setBranchCreateIntent(true) + }, + [clearBaseAndBranch] + ) + + const handleClearSmartNameSelection = useCallback(() => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setResolvingBase(false) + if (name === lastAutoNameRef.current) { + setNameState('') + lastAutoNameRef.current = '' + } + }, [clearBaseAndBranch, name]) + + const handleBranchNameOverrideChange = useCallback( + (value: string) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: base.pushTarget, + forkPushWarning + }) + setBase({ + ...base, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + resolveComposerCreateSelection({ + linkedWorkItem, + base, + branch: branchSelectionRef.current, + reuseEligibleBranch, + reuseSelectedBranch, + branchCreateIntent, + name + }), + [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] + ) + + // Auto-managed until the user edits the name away from the last derived value; + // desktop suppresses the workspace displayName once the name is user-edited. + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + + return { + name, + setName, + linkedWorkItem, + branchNameOverride: base.branchNameOverride, + handleBranchNameOverrideChange, + reuseEligibleBranch, + reuseSelectedBranch, + setReuseSelectedBranch, + forkPushWarning, + resolvingBase, + isNameAutoManaged, + smartNameSelection, + createSelection, + handleSmartGitHubItemSelect, + handleSmartGitLabItemSelect, + handleSmartLinearIssueSelect, + handleSmartBranchSelect, + handleSmartCreateBranch, + handleClearSmartNameSelection + } +} + +export type MobileComposerSource = ReturnType diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 00000000000..1d0c88dafd5 --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + buildSmartWorkspaceSourceRows, + getSmartWorkspaceEmptyHint, + type SmartNameMode, + type SmartWorkspaceSourceRow +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + findRepoMatchingSlugForPaste, + lookupGitHubItemByNumber, + lookupGitHubItemByOwnerRepo, + lookupGitLabItemByPath, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +const DEBOUNCE_MS = 200 +const RESULT_LIMIT = 36 + +export type SmartCrossRepoPrompt = { + link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + matchingRepo: PasteRepoCandidate +} + +export type UseSmartWorkspaceSourceArgs = { + client: RpcClient | null + enabled: boolean + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId?: string | null + repos: readonly PasteRepoCandidate[] +} + +const EMPTY_FAN: SmartFanOutResult = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [], + needsGitHubRemote: false, + error: '' +} + +type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null } + +export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { + const { + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + } = args + const [fan, setFan] = useState(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(null) + // Why: preserve results across keystrokes (debounce) but drop them the moment + // the mode/repo changes so one provider's rows never render under another tab. + const scopeRef = useRef('') + const dismissedPasteRef = useRef('') + const repoSlugCacheRef = useRef>(new Map()) + + useEffect(() => { + if (!client || !enabled || mode === 'text') { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setLoading(false) + setCrossRepoPrompt(null) + return + } + const scope = `${mode}:${repoId ?? ''}` + const scopeChanged = scopeRef.current !== scope + scopeRef.current = scope + if (scopeChanged) { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setCrossRepoPrompt(null) + } + setLoading(true) + let stale = false + const timer = setTimeout(() => { + void runSmartSearch({ + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos, + dismissedPasteRef, + repoSlugCache: repoSlugCacheRef.current + }) + .then((result) => { + if (stale) { + return + } + setFan(result.fan) + setPaste(result.paste) + setCrossRepoPrompt(result.crossRepoPrompt) + setLoading(false) + }) + .catch(() => { + if (!stale) { + setLoading(false) + } + }) + }, DEBOUNCE_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [ + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + ]) + + const rows = useMemo( + () => + buildSmartWorkspaceSourceRows({ + branches: fan.branches, + githubItems: paste.github ? [paste.github] : fan.githubItems, + gitlabAvailable, + gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems, + linearAvailable, + linearIssues: fan.linearIssues, + mode, + resultLimit: RESULT_LIMIT, + value: query + }), + [fan, gitlabAvailable, linearAvailable, mode, paste, query] + ) + + const dismissCrossRepoPrompt = useCallback(() => { + dismissedPasteRef.current = query.trim() + setCrossRepoPrompt(null) + }, [query]) + + return { + rows, + loading, + error: fan.error, + needsGitHubRemote: fan.needsGitHubRemote, + emptyHint: getSmartWorkspaceEmptyHint(mode), + crossRepoPrompt, + dismissCrossRepoPrompt + } +} + +async function runSmartSearch(args: { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined + repos: readonly PasteRepoCandidate[] + dismissedPasteRef: { current: string } + repoSlugCache: Map +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args + const fan = await fanOutSmartSearch(args) + const paste: PasteResolved = { github: null, gitlab: null } + let crossRepoPrompt: SmartCrossRepoPrompt | null = null + + const intent = + mode === 'branches' || dismissedPasteRef.current === query.trim() + ? null + : resolvePasteIntent(query) + if (intent && repoId) { + try { + if (intent.kind === 'github-number') { + paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) + } else if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + crossRepoPrompt = { link: intent.link, matchingRepo } + } else { + paste.github = await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ) + } + } else if (intent.kind === 'gitlab-link') { + paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) + } + } catch { + // Best-effort paste resolution; fall back to the fan-out results. + } + } + return { fan, paste, crossRepoPrompt } +} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 00000000000..bd37f46d606 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isWorkItemLookupText } from './work-item-lookup-text' + +describe('isWorkItemLookupText', () => { + it('treats references as lookup text, not names', () => { + expect(isWorkItemLookupText('#42')).toBe(true) + expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) + expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) + expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(false) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 00000000000..b130292e8d3 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1 @@ +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce30..e53c7965116 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a11828..c6667470279 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +65,12 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName?: string note?: string baseBranch?: string + compareBaseRef?: string branchNameOverride?: string + pushTarget?: WorkspaceCreateGitPushTarget sparseCheckout?: WorkspaceCreateSparseCheckout hostedStartPoint?: WorkspaceCreateHostedStartPoint + nameIsAutoManaged?: boolean }): WorkspaceCreateParams { const { item, @@ -78,22 +80,41 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName, note, baseBranch, + compareBaseRef, branchNameOverride, + pushTarget, sparseCheckout, - hostedStartPoint + hostedStartPoint, + nameIsAutoManaged = true } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget + // Why: desktop only sends displayName while the name is still auto-derived; a + // user-edited name suppresses it so the runtime keeps the user's chosen name. + const sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} const common = { setupDecision, activate: true, ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), - ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}), ...(sparseCheckout ? { sparseCheckout } : {}), ...(comment ? { comment } : {}) } @@ -103,7 +124,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedIssue: item.source.number } @@ -116,7 +137,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedGitLabIssue: item.source.number } @@ -130,8 +151,12 @@ export function buildTaskWorkspaceCreateParams(args: { draft: workspaceName, fallback: item.source.identifier.toLowerCase() }), - displayName: item.source.title, + ...displayName, linkedLinearIssue: item.source.identifier, + ...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}), + ...(item.source.organizationUrlKey + ? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey } + : {}), ...common } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts new file mode 100644 index 00000000000..4eb749f5c23 --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -0,0 +1,46 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' + +// Why: server-side collision checks (branch already exists locally / on a remote +// / already has PR #N) can fire even after a pre-flight basename dedupe — +// branches outlive worktrees in git, and remote branches/PRs aren't visible from +// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree +// loop in src/renderer/src/store/slices/worktrees.ts. +export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } + +// Creates a worktree, retrying with a numeric suffix on a name-collision error. +// buildParams receives the candidate name so callers can assemble source-specific +// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear +// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass +// maxAttempts: 1 to fail fast instead of burning the full retry budget. +export async function createWorktreeWithNameRetry(args: { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number +}): Promise { + const { client, baseName, buildParams } = args + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + let lastError: string | null = null + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) + const response = await client.sendRequest('worktree.create', buildParams(candidateName), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + return { worktreeId: result.worktree.id, name: candidateName } + } + lastError = response.error.message + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { + break + } + } + return { error: lastError ?? 'Failed to create workspace' } +} diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 00000000000..d70ffd61bb1 --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23d5..010ef2bea11 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index ace969138c4..67261b221b3 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -232,11 +232,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.updatePRState', 'github.repoSlug', 'github.workItem', + // Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart + // picker resolve a pasted github.com URL that points at a different repo. + 'github.workItemByOwnerRepo', 'github.workItemDetails', 'gitlab.createIssue', 'gitlab.addIssueComment', 'gitlab.addMRComment', 'gitlab.listWorkItems', + // Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact + // issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.) + 'gitlab.workItemByPath', 'gitlab.mergeMR', 'gitlab.resolveMRDiscussion', 'gitlab.todos', diff --git a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts index 58d0e4289d3..273c92b8dbe 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-command-value.ts @@ -1,54 +1,2 @@ -export type SmartWorkspaceCommandRowKind = - | 'use-name' - | 'create-branch' - | 'github' - | 'gitlab' - | 'branch' - | 'linear' - -export type SmartWorkspaceCommandRow = { - kind: SmartWorkspaceCommandRowKind - value: string -} - -export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null - -export function resolveSmartWorkspaceCommandValue({ - currentValue, - rows, - isQueryStale, - sourceIntent -}: { - currentValue: string - rows: readonly SmartWorkspaceCommandRow[] - isQueryStale: boolean - sourceIntent: SmartWorkspaceSourceIntent -}): string { - if (rows.length === 0) { - return currentValue - } - - if (isQueryStale) { - const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') - return typedTextRow?.value ?? '' - } - - if (sourceIntent === 'github') { - const githubRow = rows.find((row) => row.kind === 'github') - if (githubRow) { - return githubRow.value - } - } else if (sourceIntent === 'gitlab') { - const gitlabRow = rows.find((row) => row.kind === 'gitlab') - if (gitlabRow) { - return gitlabRow.value - } - } else if (sourceIntent === 'linear') { - const linearRow = rows.find((row) => row.kind === 'linear') - if (linearRow) { - return linearRow.value - } - } - - return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-command-value' diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts index abbbfb04062..afa2d10651f 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.ts @@ -1,190 +1,2 @@ -import type { - BaseRefSearchResult, - GitHubWorkItem, - GitLabWorkItem, - LinearCollectionResult, - LinearIssue -} from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' - -export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' - -export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048 - -export type SmartWorkspaceSourceRow = - | { kind: 'use-name'; value: string; name: string } - | { kind: 'create-branch'; value: string; name: string } - | { kind: 'github'; value: string; item: GitHubWorkItem } - | { kind: 'gitlab'; value: string; item: GitLabWorkItem } - | { kind: 'branch'; value: string; refName: string; localBranchName: string } - | { kind: 'linear'; value: string; issue: LinearIssue } - -type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult | null | undefined - -const EMPTY_HINT_BY_MODE: Record = { - smart: 'Start typing to create a name or find a source.', - github: 'Start typing to search GitHub PRs and issues.', - gitlab: 'Start typing to search GitLab MRs and issues.', - branches: 'No matching branches.', - linear: 'Start typing to search Linear issues.', - text: '' -} - -export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { - return EMPTY_HINT_BY_MODE[mode] -} - -export function isSmartWorkspaceSourceQueryWithinLimit( - query: string, - maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES -): boolean { - return !isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function getBranchSearchRequest({ - branchesEnabled, - disabled, - textOnly, - mode, - selectedRepoId, - query, - limit -}: { - branchesEnabled?: boolean - disabled: boolean - textOnly: boolean - mode: SmartNameMode - selectedRepoId: string | null - query: string - limit: number -}): { repoId: string; query: string; limit: number } | null { - if ( - branchesEnabled === false || - disabled || - textOnly || - !isSmartWorkspaceSourceQueryWithinLimit(query) || - !selectedRepoId - ) { - return null - } - const trimmedQuery = query.trim() - const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) - if (!shouldSearchBranches) { - return null - } - return { repoId: selectedRepoId, query: trimmedQuery, limit } -} - -export function getVisibleBranchResults({ - branches, - mode, - resultRepoId, - resultQuery, - selectedRepoId, - value -}: { - branches: BaseRefSearchResult[] - mode: SmartNameMode - resultRepoId: string | null - resultQuery: string | null - selectedRepoId: string | null - value: string -}): BaseRefSearchResult[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const currentQuery = value.trim() - if (mode !== 'branches' && mode !== 'smart') { - return [] - } - if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { - return [] - } - return branches -} - -export function buildSmartWorkspaceSourceRows({ - branches, - githubItems, - gitlabAvailable, - gitlabItems, - linearAvailable, - linearIssues, - mode, - resultLimit, - value -}: { - branches: BaseRefSearchResult[] - githubItems: GitHubWorkItem[] - gitlabAvailable: boolean - gitlabItems: GitLabWorkItem[] - linearAvailable: boolean - linearIssues: LinearIssueSourceInput - mode: SmartNameMode - resultLimit: number - value: string -}): SmartWorkspaceSourceRow[] { - if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { - return [] - } - const trimmed = value.trim() - const nextRows: SmartWorkspaceSourceRow[] = [] - if (trimmed && mode === 'smart') { - nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) - } - if (mode === 'text') { - return nextRows - } - if (mode === 'smart' || mode === 'github') { - nextRows.push( - ...githubItems.map((item) => ({ - kind: 'github' as const, - value: `github-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { - nextRows.push( - ...gitlabItems.map((item) => ({ - kind: 'gitlab' as const, - value: `gitlab-${item.repoId}-${item.type}-${item.number}`, - item - })) - ) - } - const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) - if (shouldShowBranches) { - const branchExactMatch = branches.some( - (branch) => branch.refName === trimmed || branch.localBranchName === trimmed - ) - if (trimmed && mode === 'branches' && !branchExactMatch) { - nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) - } - nextRows.push( - ...branches.map((branch) => ({ - kind: 'branch' as const, - value: `branch-${branch.refName}`, - refName: branch.refName, - localBranchName: branch.localBranchName - })) - ) - } - if (linearAvailable && (mode === 'smart' || mode === 'linear')) { - // Why: mixed-version runtime responses may briefly carry the paginated - // collection shape into this render path; rendering must stay recoverable. - const resolvedLinearIssues = Array.isArray(linearIssues) - ? linearIssues - : Array.isArray(linearIssues?.items) - ? linearIssues.items - : [] - nextRows.push( - ...resolvedLinearIssues.map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - ) - } - return nextRows.slice(0, resultLimit + 1) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../../shared/new-workspace/smart-workspace-source-results' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index b9a527d174c..c6b920bd9d8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -1,9 +1,12 @@ -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { - getLinkedWorkItemProvider, - getLinkedWorkItemWorkspaceName, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider +} from '../../../../shared/new-workspace/workspace-source' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoExecutionHostId, @@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask( if (!item) { return null } - const provider = getLinkedWorkItemProvider(item) + const provider = getWorkspaceSourceProvider(item) return { provider, type: item.type, @@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask( export function getSmartNameSelection( linkedWorkItem: LinkedWorkItemSummary | null ): SmartWorkspaceNameSelection | null { - if (!linkedWorkItem) { - return null - } - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const kind: SmartWorkspaceNameSelection['kind'] = - provider === 'linear' - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } + return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null } export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null { - return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null) + return getWorkspaceSourceName(item).displayName || null } export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'github', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitHubWorkspaceSource(item) } export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitLabWorkspaceSource(item) } export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - return buildLinearIssueLinkedWorkItem(issue) + return buildLinearWorkspaceSource(issue) } export function getFolderWorkspacePrimaryActionLabel(): string { diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index cf4e8fb7c61..e19aad2a013 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getComposerRepoWorktreeBranches, isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, @@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => { }) }) +describe('getComposerRepoWorktreeBranches', () => { + it('supplies only the selected repo branches to reuse eligibility', () => { + expect( + getComposerRepoWorktreeBranches( + [ + { repoId: 'repo-a', branch: 'feature/a' }, + { repoId: 'repo-b', branch: 'feature/b' } + ], + 'repo-a' + ) + ).toEqual(['feature/a']) + }) +}) + describe('resolveComposerReuseOverride', () => { it('keeps the selection override for a reusable (non-busy) local branch', () => { expect( diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index 02f2dfce79f..935c1689e84 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,9 +1,12 @@ export { isBranchCheckedOutInWorktrees, + getComposerRepoWorktreeBranches, + resolveComposerBranchPick, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, resolveComposerManualBranchNameChange, resolveComposerReuseOverride, + type ComposerBranchPick, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/fork-push-warning.ts b/src/renderer/src/hooks/fork-push-warning.ts index 27ab7af8073..a528541421a 100644 --- a/src/renderer/src/hooks/fork-push-warning.ts +++ b/src/renderer/src/hooks/fork-push-warning.ts @@ -1,21 +1 @@ -import type { GitHubPrStartPoint } from '../../../shared/types' - -export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = - 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' - -// Why: only warn for fork PRs where the push target points away from origin and -// whose author left "Allow edits from maintainers" off. That's the one case -// where our push to the contributor's fork can be rejected by GitHub. Returns -// the warning text to show, or null when no warning applies. -export function getForkPushWarning( - result: Pick -): string | null { - if ( - result.maintainerCanModify === false && - result.pushTarget !== undefined && - result.pushTarget.remoteName !== 'origin' - ) { - return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING - } - return null -} +export * from '../../../shared/new-workspace/fork-push-warning' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 922f1262830..eecc7a6c974 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' +import { + buildWorkspaceSourceSelection, + shouldApplyWorkspaceSourceAutoName +} from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -164,12 +168,10 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { - isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchReuse, - resolveComposerBranchSelection, + resolveComposerBranchPick, resolveComposerManualBranchNameChange, - resolveComposerReuseOverride + getComposerRepoWorktreeBranches } from './composer-branch-selection' import { isCurrentComposerDropOwner } from './composer-drop-owner' import { @@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // name or it silently becomes a slugified-URL workspace name. if ( suggestedName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = titleName?.seedName ?? suggestedName if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleSmartBranchSelect = useCallback( (refName: string, localBranchName: string): void => { smartGitHubPrStartPointSelectionRef.current = null - const selection = resolveComposerBranchSelection({ + const selection = resolveComposerBranchPick({ refName, localBranchName, currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId) }) setBaseBranch(selection.baseBranch) setCompareBaseRef(undefined) @@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Note: worktreesByRepo only covers visible worktrees; a branch busy only // in a hidden external worktree falls through to the backend conflict // check, which rejects it with a clear "already exists locally" error. - const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( - localBranchName, - (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) - ) - const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = - resolveComposerBranchReuse({ - refName, - localBranchName, - selectionProducedOverride: selection.branchNameOverride !== undefined, - branchCheckedOutElsewhere - }) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection setReuseEligibleBranch(nextReuseEligibleBranch) setReuseSelectedBranch(defaultReuse) setBranchNameOverridePreservesNameEdits(defaultReuse) - const effectiveOverride = resolveComposerReuseOverride({ - refName, - localBranchName, - branchNameOverride: selection.branchNameOverride, - branchCheckedOutElsewhere - }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' - setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) } else { - setBranchNameOverride(effectiveOverride) - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' } }, [name, worktreesByRepo, repoId] @@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const suggestedName = getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue) if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (isProjectGroupTarget) { return getFolderSmartNameSelection(linkedWorkItem) } - if (linkedWorkItem) { - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const isLinear = provider === 'linear' - const kind: SmartWorkspaceNameSelection['kind'] = isLinear - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - isLinear || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } - } - if (baseBranch) { - return { kind: 'branch', label: baseBranch } - } - return null + return buildWorkspaceSourceSelection({ + linkedWorkItem, + baseBranch + }) as SmartWorkspaceNameSelection | null }, [baseBranch, isProjectGroupTarget, linkedWorkItem]) const handleOpenAgentSettings = useCallback((): void => { diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts index c9b24e316ff..3f964159cd2 100644 --- a/src/renderer/src/lib/github-work-item-identity.ts +++ b/src/renderer/src/lib/github-work-item-identity.ts @@ -1,20 +1,2 @@ -import { parseGitHubIssueOrPRLink } from '@/lib/github-links' - -export type GitHubWorkItemIdentity = { - type: 'issue' | 'pr' - number: number -} - -export function resolveGitHubWorkItemIdentity(item: { - type: 'issue' | 'pr' - number: number - url?: string | null -}): GitHubWorkItemIdentity { - const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null - if (link) { - // Why: stale cached work-item payloads can disagree with a pasted URL. The - // URL path is the user-visible intent, so it decides issue-vs-PR launches. - return { type: link.type, number: link.number } - } - return { type: item.type, number: item.number } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/github-work-item-identity' diff --git a/src/renderer/src/lib/gitlab-links.ts b/src/renderer/src/lib/gitlab-links.ts index 1bd36c13712..d2e537d7a85 100644 --- a/src/renderer/src/lib/gitlab-links.ts +++ b/src/renderer/src/lib/gitlab-links.ts @@ -1,138 +1,2 @@ -import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' - -// Why: GitLab project paths can include nested groups, and the host may -// be self-hosted (gitlab.example.com), so the URL pattern uses the -// project-internal `/-/` separator as the GitLab-specific signal rather -// than locking to gitlab.com. Anything matching `//-/(issues| -// work_items|merge_requests)/` is treated as a GitLab item URL -// regardless of host. Modern GitLab emits issue URLs as -// `/-/work_items/`; treat that as an issue work item, same as the -// legacy `/-/issues/` form. -const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i -const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i - -export type ProjectSlug = { - /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ - host: string - /** Full GitLab project path including any nested groups. */ - path: string -} - -export type GitLabLinkQuery = { - query: string - directNumber: number | null - tooLarge?: boolean -} - -/** - * Parse a GitLab issue or MR reference from plain input. Accepts: - * - bare numbers ("42") - * - hash-prefixed numbers ("#42") - * - exclamation-prefixed numbers ("!42") — GitLab convention for MRs - * - full GitLab URLs (any host) for issues or merge_requests - */ -export function parseGitLabIssueOrMRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - // Why: GitLab references issues with `#` and MRs with `!` in markdown - // and copy-paste contexts. Accept both prefixes so users can drop in - // either form. - const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return Number.parseInt(numeric, 10) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_RE.exec(url.pathname) - if (!match) { - return null - } - // Why: the basic pattern matches plain GitHub URLs too (e.g. - // /owner/repo/issues/123). Require the `/-/` separator that's - // unique to GitLab to avoid mis-classifying a GitHub URL. - if (!url.pathname.includes('/-/')) { - return null - } - return Number.parseInt(match[1], 10) -} - -/** - * Parse a GitLab URL into project path + iid + type. Returns null for - * anything that isn't a recognizable GitLab issue or merge-request URL. - */ -export function parseGitLabIssueOrMRLink(input: string): { - slug: ProjectSlug - number: number - type: 'issue' | 'mr' -} | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname) - if (!match) { - return null - } - - const path = match[1] - // Why: a project path needs at least one slash (group/project). A - // single-segment path is the user/group root, not a project. - if (!path.includes('/')) { - return null - } - - return { - slug: { host: url.host, path }, - type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue', - number: Number.parseInt(match[3], 10) - } -} - -/** - * Normalize link-picker input so both raw issue/MR numbers and full - * GitLab URLs resolve to a usable query + direct-number lookup. - */ -export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery { - if (isWorkItemLinkQueryTooLarge(raw)) { - return { query: '', directNumber: null, tooLarge: true } - } - const trimmed = raw.trim() - if (!trimmed) { - return { query: '', directNumber: null } - } - - const direct = parseGitLabIssueOrMRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { - return { query: trimmed, directNumber: direct } - } - - const link = parseGitLabIssueOrMRLink(trimmed) - if (!link) { - return { query: trimmed, directNumber: null } - } - - // Why: any GitLab issue/MR URL is accepted by number regardless of - // project slug, mirroring the GitHub-side behavior — fork checkouts - // can legitimately target an upstream's issue numbers. - return { - query: trimmed, - directNumber: link.number - } -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/gitlab-links' diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 530a3c01028..5bb212f2b48 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,6 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links' +import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem( } export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) - return { - type: 'issue', - provider: 'linear', - // Why: Linear issue prose must not enter prompt metadata; keep only the - // string identifier/link and leave numeric issue metadata empty. - number: 0, - title: issue.title, - url: issue.url, - linearIdentifier: issue.identifier, - ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey - ? { - linearOrganizationUrlKey: organizationUrlKey - } - : {}) - } + return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/linked-work-item-provider.ts b/src/renderer/src/lib/linked-work-item-provider.ts index 7ce731622ff..62918eac598 100644 --- a/src/renderer/src/lib/linked-work-item-provider.ts +++ b/src/renderer/src/lib/linked-work-item-provider.ts @@ -1,50 +1,4 @@ -import type { LinkedWorkItemSummary } from './new-workspace' - -// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern -// GitLab emits issue URLs as `/-/work_items/` as well as the legacy -// `/-/issues/`. Recognize both forms. -const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i - -export function isGitLabIssueUrl(url: string): boolean { - try { - return GL_ISSUE_PATH_RE.test(new URL(url).pathname) - } catch { - return GL_ISSUE_PATH_RE.test(url) - } -} - -function isJiraIssueUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - /\.atlassian\.net$/i.test(parsed.hostname) || - /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) - ) - } catch { - return false - } -} - -export function getLinkedWorkItemProvider( - item: LinkedWorkItemSummary -): NonNullable { - if (item.provider) { - return item.provider - } - if (item.linearIdentifier) { - return 'linear' - } - if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { - return 'jira' - } - if (item.type === 'mr') { - return 'gitlab' - } - if (isGitLabIssueUrl(item.url)) { - return 'gitlab' - } - if (item.number === 0 && !item.url.includes('github.com')) { - return 'linear' - } - return 'github' -} +export { + getWorkspaceSourceProvider as getLinkedWorkItemProvider, + isGitLabIssueUrl +} from '../../../shared/new-workspace/workspace-source' diff --git a/src/renderer/src/lib/work-item-link-query-bounds.ts b/src/renderer/src/lib/work-item-link-query-bounds.ts index 6cbb407da84..831c942ef0c 100644 --- a/src/renderer/src/lib/work-item-link-query-bounds.ts +++ b/src/renderer/src/lib/work-item-link-query-bounds.ts @@ -1,10 +1,2 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' - -export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024 - -export function isWorkItemLinkQueryTooLarge( - query: string, - maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} +// Re-export shim: the implementation moved to src/shared so mobile can share it. +export * from '../../../shared/new-workspace/work-item-link-query-bounds' diff --git a/src/renderer/src/lib/work-item-lookup-text.test.ts b/src/renderer/src/lib/work-item-lookup-text.test.ts index 3ced0ea2372..fbdf4779eab 100644 --- a/src/renderer/src/lib/work-item-lookup-text.test.ts +++ b/src/renderer/src/lib/work-item-lookup-text.test.ts @@ -37,5 +37,6 @@ describe('isWorkItemLookupText', () => { expect(isWorkItemLookupText('fix-2')).toBe(false) expect(isWorkItemLookupText('terminal scrollbar polish')).toBe(false) expect(isWorkItemLookupText('https://example.com/some/page')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) }) }) diff --git a/src/renderer/src/lib/work-item-lookup-text.ts b/src/renderer/src/lib/work-item-lookup-text.ts index e8c412d7eac..8ef5ae53099 100644 --- a/src/renderer/src/lib/work-item-lookup-text.ts +++ b/src/renderer/src/lib/work-item-lookup-text.ts @@ -1,24 +1 @@ -import { getSmartGitHubSubmitIntent } from './smart-github-submit' -import { parseGitLabIssueOrMRLink } from './gitlab-links' - -const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i - -/** - * Why: text typed into the smart name field to *find* a work item — a GitHub - * or GitLab URL, "#123", or a linear.app link — is a lookup query, never a - * deliberate workspace name. Selection handlers use this to decide that the - * resolved item's title-derived name may replace the field content; otherwise - * the pasted URL silently survives behind the selection pill and the - * workspace gets a slugified-URL name. - */ -export function isWorkItemLookupText(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) { - return false - } - return ( - getSmartGitHubSubmitIntent(trimmed) !== null || - parseGitLabIssueOrMRLink(trimmed) !== null || - LINEAR_ISSUE_URL_RE.test(trimmed) - ) -} +export * from '../../../shared/new-workspace/work-item-lookup-text' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index dfd32303e25..55611abee4a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,11 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../../shared/new-workspace/worktree-create-retry-policy' import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, @@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator options ) => { const automationProvenanceRequest = options?.automationProvenanceRequest - const retryableConflictPatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /^Branch ".+" already exists\./i, - /already has pr #\d+/i - ] - const nextCandidateName = (current: string, attempt: number): string => - attempt === 0 ? current : `${current}-${attempt + 1}` - try { - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = nextCandidateName(name, attempt) + for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(name, attempt) // Why: older runtimes may still reject exact PR branch overrides on // collision, so the renderer retries both branch and worktree names. const candidateBranchNameOverride = branchNameOverride - ? nextCandidateName(branchNameOverride, attempt) + ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) : undefined try { // Why: Manual sort is user-authored order. Stamp new workspaces @@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator return result } catch (error) { const message = error instanceof Error ? error.message : String(error) - const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message)) - if (!shouldRetry || attempt === 24) { + const shouldRetry = isRetryableWorktreeCreateConflict(message) + if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { throw error } } diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index 0ee66f0a28e..9342f18bfd7 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -50,6 +50,15 @@ export function isBranchCheckedOutInWorktrees( return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName) } +export function getComposerRepoWorktreeBranches( + worktrees: readonly { repoId: string; branch: string }[], + repoId: string | null +): string[] { + return repoId + ? worktrees.filter((worktree) => worktree.repoId === repoId).map((worktree) => worktree.branch) + : [] +} + /** * Issue #5181: decide whether a picked branch row is an existing LOCAL branch * that can be reused (checked out) instead of branched off, and whether reuse @@ -99,6 +108,41 @@ export function resolveComposerReuseOverride(args: { return args.branchNameOverride } +export type ComposerBranchPick = ComposerBranchSelection & { + reuseEligibleBranch: string | null + defaultReuse: boolean +} + +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveComposerBranchSelection(args) + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + args.localBranchName, + args.worktreeBranches + ) + const reuse = resolveComposerBranchReuse({ + refName: args.refName, + localBranchName: args.localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + return { + ...selection, + branchNameOverride: resolveComposerReuseOverride({ + refName: args.refName, + localBranchName: args.localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }), + ...reuse + } +} + /** * The branch-name override to apply when creating a worktree from the composer. * diff --git a/src/shared/new-workspace/fork-push-warning.ts b/src/shared/new-workspace/fork-push-warning.ts new file mode 100644 index 00000000000..5c3a7c29e68 --- /dev/null +++ b/src/shared/new-workspace/fork-push-warning.ts @@ -0,0 +1,16 @@ +import type { GitHubPrStartPoint } from '../types' + +export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = + 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' + +// Why: this is the one fork target where Orca can prepare the workspace but a +// later push may still be rejected by GitHub permissions. +export function getForkPushWarning( + result: Pick +): string | null { + return result.maintainerCanModify === false && + result.pushTarget !== undefined && + result.pushTarget.remoteName !== 'origin' + ? FORK_PUSH_NO_MAINTAINER_EDIT_WARNING + : null +} diff --git a/src/shared/new-workspace/github-links.ts b/src/shared/new-workspace/github-links.ts new file mode 100644 index 00000000000..dc6cbd050ab --- /dev/null +++ b/src/shared/new-workspace/github-links.ts @@ -0,0 +1,50 @@ +import { + type GitHubIssueOrPRLink, + parseGitHubIssueOrPRLink, + parseGitHubIssueOrPRNumber +} from '../github-links' +import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' + +export * from '../github-links' + +const HTTP_URL_PREFIX_RE = /^https?:\/\//i + +export type GitHubLinkQuery = { + query: string + directNumber: number | null + directLink?: GitHubIssueOrPRLink + tooLarge?: boolean +} + +/** + * Normalizes link-picker input so both raw issue/PR numbers and full GitHub + * URLs resolve to a usable query + direct-number lookup. + */ +export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { + if (isWorkItemLinkQueryTooLarge(raw)) { + return { query: '', directNumber: null, tooLarge: true } + } + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', directNumber: null } + } + + const direct = parseGitHubIssueOrPRNumber(trimmed) + if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) { + return { query: trimmed, directNumber: direct } + } + + const link = parseGitHubIssueOrPRLink(trimmed) + if (!link) { + return { query: trimmed, directNumber: null } + } + + // Why: any GitHub-shaped issue/pull URL is accepted by number regardless of + // slug, since fork checkouts can legitimately target upstream issues whose + // slug differs from the origin remote. + return { + query: trimmed, + directNumber: link.number, + directLink: link + } +} diff --git a/src/shared/new-workspace/github-work-item-identity.ts b/src/shared/new-workspace/github-work-item-identity.ts new file mode 100644 index 00000000000..933a436daa4 --- /dev/null +++ b/src/shared/new-workspace/github-work-item-identity.ts @@ -0,0 +1,20 @@ +import { parseGitHubIssueOrPRLink } from './github-links' + +export type GitHubWorkItemIdentity = { + type: 'issue' | 'pr' + number: number +} + +export function resolveGitHubWorkItemIdentity(item: { + type: 'issue' | 'pr' + number: number + url?: string | null +}): GitHubWorkItemIdentity { + const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null + if (link) { + // Why: stale cached work-item payloads can disagree with a pasted URL. The + // URL path is the user-visible intent, so it decides issue-vs-PR launches. + return { type: link.type, number: link.number } + } + return { type: item.type, number: item.number } +} diff --git a/src/shared/new-workspace/gitlab-links.ts b/src/shared/new-workspace/gitlab-links.ts new file mode 100644 index 00000000000..1bd36c13712 --- /dev/null +++ b/src/shared/new-workspace/gitlab-links.ts @@ -0,0 +1,138 @@ +import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' + +// Why: GitLab project paths can include nested groups, and the host may +// be self-hosted (gitlab.example.com), so the URL pattern uses the +// project-internal `/-/` separator as the GitLab-specific signal rather +// than locking to gitlab.com. Anything matching `//-/(issues| +// work_items|merge_requests)/` is treated as a GitLab item URL +// regardless of host. Modern GitLab emits issue URLs as +// `/-/work_items/`; treat that as an issue work item, same as the +// legacy `/-/issues/` form. +const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i +const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i + +export type ProjectSlug = { + /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ + host: string + /** Full GitLab project path including any nested groups. */ + path: string +} + +export type GitLabLinkQuery = { + query: string + directNumber: number | null + tooLarge?: boolean +} + +/** + * Parse a GitLab issue or MR reference from plain input. Accepts: + * - bare numbers ("42") + * - hash-prefixed numbers ("#42") + * - exclamation-prefixed numbers ("!42") — GitLab convention for MRs + * - full GitLab URLs (any host) for issues or merge_requests + */ +export function parseGitLabIssueOrMRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + // Why: GitLab references issues with `#` and MRs with `!` in markdown + // and copy-paste contexts. Accept both prefixes so users can drop in + // either form. + const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return Number.parseInt(numeric, 10) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + const match = GL_ITEM_PATH_RE.exec(url.pathname) + if (!match) { + return null + } + // Why: the basic pattern matches plain GitHub URLs too (e.g. + // /owner/repo/issues/123). Require the `/-/` separator that's + // unique to GitLab to avoid mis-classifying a GitHub URL. + if (!url.pathname.includes('/-/')) { + return null + } + return Number.parseInt(match[1], 10) +} + +/** + * Parse a GitLab URL into project path + iid + type. Returns null for + * anything that isn't a recognizable GitLab issue or merge-request URL. + */ +export function parseGitLabIssueOrMRLink(input: string): { + slug: ProjectSlug + number: number + type: 'issue' | 'mr' +} | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname) + if (!match) { + return null + } + + const path = match[1] + // Why: a project path needs at least one slash (group/project). A + // single-segment path is the user/group root, not a project. + if (!path.includes('/')) { + return null + } + + return { + slug: { host: url.host, path }, + type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue', + number: Number.parseInt(match[3], 10) + } +} + +/** + * Normalize link-picker input so both raw issue/MR numbers and full + * GitLab URLs resolve to a usable query + direct-number lookup. + */ +export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery { + if (isWorkItemLinkQueryTooLarge(raw)) { + return { query: '', directNumber: null, tooLarge: true } + } + const trimmed = raw.trim() + if (!trimmed) { + return { query: '', directNumber: null } + } + + const direct = parseGitLabIssueOrMRNumber(trimmed) + if (direct !== null && !trimmed.startsWith('http')) { + return { query: trimmed, directNumber: direct } + } + + const link = parseGitLabIssueOrMRLink(trimmed) + if (!link) { + return { query: trimmed, directNumber: null } + } + + // Why: any GitLab issue/MR URL is accepted by number regardless of + // project slug, mirroring the GitHub-side behavior — fork checkouts + // can legitimately target an upstream's issue numbers. + return { + query: trimmed, + directNumber: link.number + } +} diff --git a/src/shared/new-workspace/smart-workspace-command-value.ts b/src/shared/new-workspace/smart-workspace-command-value.ts new file mode 100644 index 00000000000..58d0e4289d3 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-command-value.ts @@ -0,0 +1,54 @@ +export type SmartWorkspaceCommandRowKind = + | 'use-name' + | 'create-branch' + | 'github' + | 'gitlab' + | 'branch' + | 'linear' + +export type SmartWorkspaceCommandRow = { + kind: SmartWorkspaceCommandRowKind + value: string +} + +export type SmartWorkspaceSourceIntent = 'github' | 'gitlab' | 'linear' | null + +export function resolveSmartWorkspaceCommandValue({ + currentValue, + rows, + isQueryStale, + sourceIntent +}: { + currentValue: string + rows: readonly SmartWorkspaceCommandRow[] + isQueryStale: boolean + sourceIntent: SmartWorkspaceSourceIntent +}): string { + if (rows.length === 0) { + return currentValue + } + + if (isQueryStale) { + const typedTextRow = rows.find((row) => row.kind === 'use-name' || row.kind === 'create-branch') + return typedTextRow?.value ?? '' + } + + if (sourceIntent === 'github') { + const githubRow = rows.find((row) => row.kind === 'github') + if (githubRow) { + return githubRow.value + } + } else if (sourceIntent === 'gitlab') { + const gitlabRow = rows.find((row) => row.kind === 'gitlab') + if (gitlabRow) { + return gitlabRow.value + } + } else if (sourceIntent === 'linear') { + const linearRow = rows.find((row) => row.kind === 'linear') + if (linearRow) { + return linearRow.value + } + } + + return rows.some((row) => row.value === currentValue) ? currentValue : rows[0].value +} diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts new file mode 100644 index 00000000000..68041990910 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -0,0 +1,190 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearCollectionResult, + LinearIssue +} from '../types' +import { isClipboardTextByteLengthOverLimit } from '../clipboard-text' + +export type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text' + +export const SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES = 2048 + +export type SmartWorkspaceSourceRow = + | { kind: 'use-name'; value: string; name: string } + | { kind: 'create-branch'; value: string; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'gitlab'; value: string; item: GitLabWorkItem } + | { kind: 'branch'; value: string; refName: string; localBranchName: string } + | { kind: 'linear'; value: string; issue: LinearIssue } + +type LinearIssueSourceInput = LinearIssue[] | LinearCollectionResult | null | undefined + +const EMPTY_HINT_BY_MODE: Record = { + smart: 'Start typing to create a name or find a source.', + github: 'Start typing to search GitHub PRs and issues.', + gitlab: 'Start typing to search GitLab MRs and issues.', + branches: 'No matching branches.', + linear: 'Start typing to search Linear issues.', + text: '' +} + +export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string { + return EMPTY_HINT_BY_MODE[mode] +} + +export function isSmartWorkspaceSourceQueryWithinLimit( + query: string, + maxBytes = SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES +): boolean { + return !isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +export function getBranchSearchRequest({ + branchesEnabled, + disabled, + textOnly, + mode, + selectedRepoId, + query, + limit +}: { + branchesEnabled?: boolean + disabled: boolean + textOnly: boolean + mode: SmartNameMode + selectedRepoId: string | null + query: string + limit: number +}): { repoId: string; query: string; limit: number } | null { + if ( + branchesEnabled === false || + disabled || + textOnly || + !isSmartWorkspaceSourceQueryWithinLimit(query) || + !selectedRepoId + ) { + return null + } + const trimmedQuery = query.trim() + const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0) + if (!shouldSearchBranches) { + return null + } + return { repoId: selectedRepoId, query: trimmedQuery, limit } +} + +export function getVisibleBranchResults({ + branches, + mode, + resultRepoId, + resultQuery, + selectedRepoId, + value +}: { + branches: BaseRefSearchResult[] + mode: SmartNameMode + resultRepoId: string | null + resultQuery: string | null + selectedRepoId: string | null + value: string +}): BaseRefSearchResult[] { + if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { + return [] + } + const currentQuery = value.trim() + if (mode !== 'branches' && mode !== 'smart') { + return [] + } + if (!selectedRepoId || resultRepoId !== selectedRepoId || resultQuery !== currentQuery) { + return [] + } + return branches +} + +export function buildSmartWorkspaceSourceRows({ + branches, + githubItems, + gitlabAvailable, + gitlabItems, + linearAvailable, + linearIssues, + mode, + resultLimit, + value +}: { + branches: BaseRefSearchResult[] + githubItems: GitHubWorkItem[] + gitlabAvailable: boolean + gitlabItems: GitLabWorkItem[] + linearAvailable: boolean + linearIssues: LinearIssueSourceInput + mode: SmartNameMode + resultLimit: number + value: string +}): SmartWorkspaceSourceRow[] { + if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { + return [] + } + const trimmed = value.trim() + const nextRows: SmartWorkspaceSourceRow[] = [] + if (trimmed && mode === 'smart') { + nextRows.push({ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }) + } + if (mode === 'text') { + return nextRows + } + if (mode === 'smart' || mode === 'github') { + nextRows.push( + ...githubItems.map((item) => ({ + kind: 'github' as const, + value: `github-${item.repoId}-${item.type}-${item.number}`, + item + })) + ) + } + if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) { + nextRows.push( + ...gitlabItems.map((item) => ({ + kind: 'gitlab' as const, + value: `gitlab-${item.repoId}-${item.type}-${item.number}`, + item + })) + ) + } + const shouldShowBranches = mode === 'branches' || (mode === 'smart' && trimmed.length > 0) + if (shouldShowBranches) { + const branchExactMatch = branches.some( + (branch) => branch.refName === trimmed || branch.localBranchName === trimmed + ) + if (trimmed && mode === 'branches' && !branchExactMatch) { + nextRows.push({ kind: 'create-branch', value: `create-branch-${trimmed}`, name: trimmed }) + } + nextRows.push( + ...branches.map((branch) => ({ + kind: 'branch' as const, + value: `branch-${branch.refName}`, + refName: branch.refName, + localBranchName: branch.localBranchName + })) + ) + } + if (linearAvailable && (mode === 'smart' || mode === 'linear')) { + // Why: mixed-version runtime responses may briefly carry the paginated + // collection shape into this render path; rendering must stay recoverable. + const resolvedLinearIssues = Array.isArray(linearIssues) + ? linearIssues + : Array.isArray(linearIssues?.items) + ? linearIssues.items + : [] + nextRows.push( + ...resolvedLinearIssues.map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + ) + } + return nextRows.slice(0, resultLimit + 1) +} diff --git a/src/shared/new-workspace/work-item-link-query-bounds.ts b/src/shared/new-workspace/work-item-link-query-bounds.ts new file mode 100644 index 00000000000..1e16d95ce0e --- /dev/null +++ b/src/shared/new-workspace/work-item-link-query-bounds.ts @@ -0,0 +1,10 @@ +import { isClipboardTextByteLengthOverLimit } from '../clipboard-text' + +export const WORK_ITEM_LINK_QUERY_MAX_BYTES = 2 * 1024 + +export function isWorkItemLinkQueryTooLarge( + query: string, + maxBytes = WORK_ITEM_LINK_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +} diff --git a/src/shared/new-workspace/work-item-lookup-text.ts b/src/shared/new-workspace/work-item-lookup-text.ts new file mode 100644 index 00000000000..78e660a0d82 --- /dev/null +++ b/src/shared/new-workspace/work-item-lookup-text.ts @@ -0,0 +1,30 @@ +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from './github-links' +import { parseGitLabIssueOrMRLink } from './gitlab-links' + +const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/[^/\s]+\/issue\/[^/\s]+(?:\/\S*)?$/i +const GITHUB_ITEM_URL_IN_TEXT_RE = + /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/(?:issues|pull)\/\d+[^\s]*/i +const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?]+$/ + +function hasGitHubLookup(value: string): boolean { + if (parseGitHubIssueOrPRNumber(value) !== null || parseGitHubIssueOrPRLink(value) !== null) { + return true + } + const embedded = GITHUB_ITEM_URL_IN_TEXT_RE.exec(value)?.[0] + return embedded + ? parseGitHubIssueOrPRLink(embedded.replace(TRAILING_URL_PUNCTUATION_RE, '')) !== null + : false +} + +/** Lookup references may be replaced by an auto-name; deliberate names may not. */ +export function isWorkItemLookupText(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + return ( + hasGitHubLookup(trimmed) || + parseGitLabIssueOrMRLink(trimmed) !== null || + LINEAR_ISSUE_URL_RE.test(trimmed) + ) +} diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts new file mode 100644 index 00000000000..93e46c4db17 --- /dev/null +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider, + shouldApplyWorkspaceSourceAutoName, + shouldPreserveWorkspaceSourceOnRepoChange +} from './workspace-source' + +describe('workspace source policy', () => { + const linear = buildLinearWorkspaceSource({ + identifier: 'ENG-42', + title: 'Ship mobile parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity', + workspaceId: 'workspace-1' + }) + + it('builds one Linear identity for desktop and mobile create flows', () => { + expect(linear).toMatchObject({ + provider: 'linear', + number: 0, + linearIdentifier: 'ENG-42', + linearWorkspaceId: 'workspace-1', + linearOrganizationUrlKey: 'acme' + }) + expect(getWorkspaceSourceName(linear)).toEqual({ + seedName: 'eng-42-ship-mobile-parity', + displayName: 'ENG-42 Ship mobile parity' + }) + }) + + it('preserves global work-item sources across repo changes', () => { + expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'github', + type: 'issue', + number: 1, + title: 'Repo scoped', + url: 'https://github.com/o/r/issues/1' + }) + ).toBe(false) + }) + + it('shares provider inference, selection labels, and auto-name gates', () => { + const legacyGitLab = { + type: 'issue' as const, + number: 7, + title: 'Self hosted', + url: 'https://gitlab.example.com/g/p/-/work_items/7' + } + expect(getWorkspaceSourceProvider(legacyGitLab)).toBe('gitlab') + expect(buildWorkspaceSourceSelection({ linkedWorkItem: legacyGitLab })).toMatchObject({ + kind: 'gitlab-issue', + label: '#7 Self hosted' + }) + expect(shouldApplyWorkspaceSourceAutoName({ currentName: '#42', lastAutoName: 'old' })).toBe( + true + ) + expect( + shouldApplyWorkspaceSourceAutoName({ currentName: 'my workspace', lastAutoName: 'old' }) + ).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts new file mode 100644 index 00000000000..b8c3048e9c5 --- /dev/null +++ b/src/shared/new-workspace/workspace-source.ts @@ -0,0 +1,195 @@ +import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links' +import type { FolderWorkspaceLinkedTask, LinearIssue } from '../types' +import { + getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName, + type WorkspaceIntentWorkItem +} from '../workspace-name' +import { isWorkItemLookupText } from './work-item-lookup-text' + +export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider'] + +export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & { + linearWorkspaceId?: string + linearOrganizationUrlKey?: string +} + +export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'github' + type: 'issue' | 'pr' +} + +export type GitLabWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'gitlab' + type: 'issue' | 'mr' +} + +export type LinearWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'linear' + type: 'issue' +} + +export type WorkspaceSourceItemLike = Omit & { + provider?: WorkspaceSourceProvider +} + +export type WorkspaceSourceSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + | 'jira' + +export type WorkspaceSourceSelection = { + kind: WorkspaceSourceSelectionKind + label: string + url?: string +} + +const GITLAB_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i + +export function isGitLabIssueUrl(url: string): boolean { + try { + return GITLAB_ISSUE_PATH_RE.test(new URL(url).pathname) + } catch { + return GITLAB_ISSUE_PATH_RE.test(url) + } +} + +function isJiraIssueUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + /\.atlassian\.net$/i.test(parsed.hostname) || + /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) + ) + } catch { + return false + } +} + +export function getWorkspaceSourceProvider(item: WorkspaceSourceItemLike): WorkspaceSourceProvider { + if (item.provider) { + return item.provider + } + if (item.linearIdentifier) { + return 'linear' + } + if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { + return 'jira' + } + if (item.type === 'mr' || isGitLabIssueUrl(item.url)) { + return 'gitlab' + } + if (item.number === 0 && !item.url.includes('github.com')) { + return 'linear' + } + return 'github' +} + +export function buildGitHubWorkspaceSource(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId?: string +}): GitHubWorkspaceSource { + return { provider: 'github', ...item } +} + +export function buildGitLabWorkspaceSource(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId?: string +}): GitLabWorkspaceSource { + return { provider: 'gitlab', ...item } +} + +export function buildLinearWorkspaceSource( + issue: Pick +): LinearWorkspaceSource { + const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + return { + provider: 'linear', + type: 'issue', + // Why: Linear uses a string identifier; numeric issue metadata must stay empty. + number: 0, + title: issue.title, + url: issue.url, + linearIdentifier: issue.identifier, + ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), + ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}) + } +} + +export function shouldApplyWorkspaceSourceAutoName(args: { + currentName: string + lastAutoName: string +}): boolean { + return ( + !args.currentName.trim() || + args.currentName === args.lastAutoName || + isWorkItemLookupText(args.currentName) + ) +} + +function toWorkspaceIntentItem(item: WorkspaceSourceItemLike): WorkspaceIntentWorkItem { + return { ...item, provider: getWorkspaceSourceProvider(item) } +} + +export function getWorkspaceSourceName(item: WorkspaceSourceItemLike): { + seedName: string + displayName: string +} { + const normalized = toWorkspaceIntentItem(item) + const resolved = getLinkedWorkItemWorkspaceName(normalized) + return { + seedName: resolved?.seedName ?? getLinkedWorkItemSuggestedName(normalized), + displayName: resolved?.displayName ?? item.title.trim() + } +} + +export function buildWorkspaceSourceSelection(args: { + linkedWorkItem: WorkspaceSourceItemLike | null + baseBranch?: string +}): WorkspaceSourceSelection | null { + const { linkedWorkItem, baseBranch } = args + if (!linkedWorkItem) { + return baseBranch ? { kind: 'branch', label: baseBranch } : null + } + const provider = getWorkspaceSourceProvider(linkedWorkItem) + const kind: WorkspaceSourceSelectionKind = + provider === 'linear' + ? 'linear' + : provider === 'jira' + ? 'jira' + : provider === 'gitlab' + ? linkedWorkItem.type === 'mr' + ? 'gitlab-mr' + : 'gitlab-issue' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } +} + +export function shouldPreserveWorkspaceSourceOnRepoChange( + item: WorkspaceSourceItemLike | null +): boolean { + if (!item) { + return false + } + const provider = getWorkspaceSourceProvider(item) + return provider === 'linear' || provider === 'jira' +} diff --git a/src/shared/new-workspace/worktree-create-retry-policy.test.ts b/src/shared/new-workspace/worktree-create-retry-policy.test.ts new file mode 100644 index 00000000000..47cc5e79ee6 --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from './worktree-create-retry-policy' + +describe('client worktree create retry policy', () => { + it('uses the same suffix sequence for every client', () => { + expect(getClientWorktreeCreateCandidate('feature', 0)).toBe('feature') + expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2') + }) + + it('retries only known branch and review conflicts', () => { + expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch already has PR #42')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Permission denied')).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts new file mode 100644 index 00000000000..295dd3c7839 --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.ts @@ -0,0 +1,18 @@ +export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25 + +// Why: mixed-version runtimes can still return these legacy conflicts instead +// of performing their own suffix retry, so every client needs one policy. +const RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS = [ + /already exists locally/i, + /already exists on a remote/i, + /^Branch ".+" already exists\./i, + /already has pr #\d+/i +] + +export function getClientWorktreeCreateCandidate(value: string, attempt: number): string { + return attempt === 0 ? value : `${value}-${attempt + 1}` +} + +export function isRetryableWorktreeCreateConflict(message: string): boolean { + return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message)) +} diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts index 79b70c2d09a..8b9734c7786 100644 --- a/src/shared/protocol-compat.test.ts +++ b/src/shared/protocol-compat.test.ts @@ -45,6 +45,28 @@ describe('evaluateCompat', () => { expect(verdict).toEqual({ kind: 'ok' }) }) + it('allows desktop protocol 3 to roll out before mobile protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 2, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 3, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + + it('allows mobile protocol 3 to roll out before desktop protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 3, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + it('blocks with mobile-too-old when desktop requires a newer mobile', () => { const verdict = evaluateCompat({ mobileProtocolVersion: MOBILE_V, From f90cd6ebc9cf6b326432d8133fd28edd2c9a910e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 13 Jul 2026 18:44:32 -0400 Subject: [PATCH 09/52] fix(cli): preserve WSL cwd through the Windows bridge (#6965) (#7640) * fix(cli): preserve WSL cwd through the Windows bridge (#6965) # Conflicts: # src/cli/index.test.ts # src/cli/index.ts * fix(cli): preserve bridge exit codes (#6965) * fix(cli): harden WSL cwd bridge compatibility * chore(cli): align cwd tests with main * fix(cli): repair deleted WSL cwd before path conversion * chore: preserve main formatting after merge --------- Co-authored-by: Brennan Benson Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/cli/selectors.ts | 15 ++++------- src/main/cli/wsl-cli-installer.test.ts | 33 +++++++++++++++++++++-- src/main/cli/wsl-cli-scripts.ts | 36 ++++++++++++++++++++------ src/shared/cross-platform-path.test.ts | 33 +++++++++++++++++++++++ src/shared/cross-platform-path.ts | 21 +++++++++------ 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 93623967e57..008ab2eb764 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,4 +1,4 @@ -import { isAbsolute, relative, resolve as resolvePath } from 'node:path' +import { resolve as resolvePath } from 'node:path' import type { ComputerAppQuery, RuntimeWorktreeListResult, @@ -43,14 +43,6 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) ) } -function isWithinPath(parentPath: string, childPath: string): boolean { - if (isPathInsideOrEqual(parentPath, childPath)) { - return true - } - const relativePath = relative(parentPath, childPath) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient @@ -65,7 +57,10 @@ export async function resolveCurrentWorktreeSelector( let enclosingPathLength = -1 for (const worktree of worktrees.result.worktrees) { const worktreePath = resolvePath(worktree.path) - if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) { + if ( + !isPathInsideOrEqual(worktreePath, currentPath) || + worktreePath.length <= enclosingPathLength + ) { continue } enclosingWorktree = worktree diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 6324e75174e..547679df46f 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -194,8 +194,15 @@ describe('WslCliInstaller', () => { ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toBeDefined() expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") expect(installCommand).toContain('rm -f "$legacy_command_path"') + // Why: the new bridge accepts the old launcher's positional arguments, so + // publishing it first keeps interrupted upgrades usable. + const bridgePublishIndex = installCommand?.indexOf('mv -f "$bridge_tmp"') ?? -1 + const launcherPublishIndex = installCommand?.indexOf('mv -f "$command_tmp"') ?? -1 + expect(bridgePublishIndex).toBeGreaterThan(-1) + expect(bridgePublishIndex).toBeLessThan(launcherPublishIndex) expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) @@ -296,12 +303,34 @@ describe('WslCliInstaller', () => { 'Orca WSL CLI requires Windows interop and could not find powershell.exe.' ) expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File') - expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"') + expect(launcher).toContain('ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {') + expect(launcher).toContain('ORCA_WSL_CWD=/') + expect(launcher).toContain('cd /') + expect(launcher).toContain('ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")') + expect(launcher.indexOf('ORCA_WSL_CWD=$(pwd -P')).toBeLessThan( + launcher.indexOf('ORCA_BRIDGE_PS1_WIN=$(wslpath') + ) + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') + expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') + expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') + expect(bridge).toContain('[string]$WslCwd') expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') + expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') + expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') + const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') + const invocationFailureBranch = bridge.indexOf('if (-not $?)') + expect(nullExitCodeBranch).toBeGreaterThan(-1) + // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; + // checking the native status first preserves that specific exit code. + expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) + expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') - expect(bridge).toContain('exit 1') + expect(bridge).toContain('$exitCode = 1') + expect(bridge).toContain('exit $exitCode') }) it('wraps WSL bash scripts as a single encoded command line', () => { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 136a2a682c9..3297b9be9c1 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -20,34 +20,54 @@ else echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 exit 1 fi +# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and +# help usable, and repair cwd before any WSL interop tool tries to resolve it. +ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { + ORCA_WSL_CWD=/ + cd / +} ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") -exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} +[CmdletBinding(PositionalBinding=$false)] param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$true, Position=0)] [string]$OrcaLauncher, + [string]$WslCwd, + [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ForwardArgs ) +$exitCode = 0 try { + if ([string]::IsNullOrEmpty($WslCwd)) { + Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_CWD = $WslCwd + } + Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) & $OrcaLauncher @ForwardArgs - if (-not $?) { - exit 1 - } if ($null -eq $LASTEXITCODE) { - exit 0 + if (-not $?) { + $exitCode = 1 + } else { + $exitCode = 0 + } + } else { + $exitCode = $LASTEXITCODE } - exit $LASTEXITCODE } catch { Write-Error $_ - exit 1 + $exitCode = 1 } +exit $exitCode ` } diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 085bf56825c..15777065bc0 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -29,6 +29,39 @@ describe('cross-platform path containment', () => { expect(isPathInsideOrEqual('\\\\Server\\Share\\Repo', '\\\\server\\share\\repo2')).toBe(false) }) + it('treats WSL UNC aliases as the same case-sensitive filesystem', () => { + expect( + isPathInsideOrEqual( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\src' + ) + ).toBe(true) + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\Src' + ) + ).toBe('Src') + expect( + isPathInsideOrEqual( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src' + ) + ).toBe(false) + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\alice\\repo\\src' + ) + ).toBeNull() + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\Alice\\repo', + '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\line\nbreak' + ) + ).toBe('line\nbreak') + }) + it('resolves POSIX relative paths without using the process cwd', () => { expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe( '/repos/app/worktrees/feature' diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index 7fb3bd8ca14..e82bd9d2461 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -12,6 +12,12 @@ export function normalizeRuntimePathSeparators(value: string): string { export function normalizeRuntimePathForComparison(value: string): string { const normalized = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(value)) + const wslUnc = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i) + if (wslUnc) { + // Why: Windows exposes the same case-sensitive WSL filesystem through two + // UNC aliases, while the distro/server portion remains case-insensitive. + return `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}` + } return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized } @@ -57,16 +63,11 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo } export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { - const normalizedRoot = trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(rootPath)) const normalizedCandidate = trimRuntimePathTrailingSlash( normalizeRuntimePathSeparators(candidatePath) ) - const comparisonRoot = isWindowsAbsolutePathLike(rootPath) - ? normalizedRoot.toLowerCase() - : normalizedRoot - const comparisonCandidate = isWindowsAbsolutePathLike(rootPath) - ? normalizedCandidate.toLowerCase() - : normalizedCandidate + const comparisonRoot = normalizeRuntimePathForComparison(rootPath) + const comparisonCandidate = normalizeRuntimePathForComparison(candidatePath) if (comparisonCandidate === comparisonRoot) { return '' @@ -76,7 +77,11 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string): if (!comparisonCandidate.startsWith(comparisonPrefix)) { return null } - return normalizedCandidate.slice(comparisonPrefix.length) + // WSL comparison keys fold the UNC alias but preserve Linux path casing, so + // their suffix is both aligned across aliases and safe to return directly. + return comparisonRoot.startsWith('//wsl/') + ? comparisonCandidate.slice(comparisonPrefix.length) + : normalizedCandidate.slice(comparisonPrefix.length) } function trimRuntimePathTrailingSlash(value: string): string { From 833724830f50ba857caf980a0f8ea20573e430fa Mon Sep 17 00:00:00 2001 From: Dhilip Subramanian <49802211+sdhilip200@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:47:17 +1200 Subject: [PATCH 10/52] Fix folder workspace Git status path (#8326) * Fix folder workspace Git status path * fix(git): resolve folder-workspace path for all local filesystem ops Extend the folder-workspace suffix stripping beyond git status: every local Git subprocess cwd, the WSL-context probe, the first-work branch/folder rename hooks, and the renderer session placeholders now resolve the synthetic `::workspace:` instance id to its backing folder via splitWorktreeIdForFilesystem. Without this they would spawn Git (or fs ops) against a nonexistent directory (ENOENT). Co-authored-by: Orca * Preserve folder workspace rename instance ids * Read folder workspace suffix from worktree id --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../first-work-branch-rename.test.ts | 13 +++ .../agent-hooks/first-work-branch-rename.ts | 7 +- .../first-work-folder-rename.test.ts | 20 +++++ .../agent-hooks/first-work-folder-rename.ts | 14 +++- src/main/ipc/worktree-folder-rename-target.ts | 5 +- src/main/providers/local-pty-provider.ts | 8 +- .../src/runtime/runtime-git-client.test.ts | 38 +++++++++ .../src/runtime/runtime-git-client.ts | 79 +++++++++++-------- .../store/slices/terminals-hydration.test.ts | 32 ++++++++ src/renderer/src/store/slices/terminals.ts | 15 +++- 10 files changed, 187 insertions(+), 44 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 6247ee9f45c..c1f46a602fb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -185,6 +185,19 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(REPO_ID) }) + it('runs Git against the backing folder for a folder-workspace instance id', async () => { + // Why: instance ids carry a synthetic `::workspace:` suffix that is not + // a real directory. The Git cwd must resolve to the folder or `rev-parse` + // spawns against a nonexistent path (ENOENT). + const instanceId = `${WORKTREE_ID}::workspace:123e4567-e89b-12d3-a456-426614174000` + const { deps } = makeDeps({ resolveWorktreeIdForTab: () => instanceId }) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + }) + it('skips when no worktree can be resolved for the tab', async () => { const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined }) await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 1619cf9a9ef..c6e7fb8d10e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -4,7 +4,7 @@ // owns the orchestration: gate on the signal, enforce the safety guardrails, // summarize the prompt via the configured agent, and rename. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { @@ -188,7 +188,10 @@ async function runAutoRename( } const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs + // carry a synthetic `::workspace:` suffix that is not a real directory, + // so resolve to the backing folder or Git spawns against a nonexistent cwd. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return stop('unresolved repo or worktree id') } diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index f495f91b5e7..e9a88921943 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -8,6 +8,7 @@ import { const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings const OLD_ID = 'repo1::/ws/cunner' +const FOLDER_WORKSPACE_ID = 'repo1::/ws/cunner::workspace:12345678-1234-1234-1234-123456789abc' function makeDeps(overrides: Partial = {}): FirstWorkFolderRenameDeps { return { @@ -50,6 +51,25 @@ describe('renameWorktreeFolderOnFirstWork', () => { ) }) + it('preserves the folder-workspace instance suffix in the migrated identity', async () => { + const deps = makeDeps() + const result = await renameWorktreeFolderOnFirstWork( + FOLDER_WORKSPACE_ID, + 'worktree-creation-spinner', + deps + ) + expect(result).toBe(true) + expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith( + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith( + 'repo1', + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + }) + it('skips (no move) when the destination already exists', async () => { const deps = makeDeps({ pathExists: vi.fn(async () => true) }) expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false) diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 8cc8f029b2e..c7050310834 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -6,7 +6,11 @@ // best-effort and local-only — remote/Windows/locked/dest-taken all degrade to // "folder kept" without disturbing the rename that already succeeded. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../shared/worktree-id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target' @@ -35,7 +39,10 @@ export async function renameWorktreeFolderOnFirstWork( deps: FirstWorkFolderRenameDeps ): Promise { const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic + // `::workspace:` suffix to the backing folder; identity is migrated + // separately via the untouched worktreeId. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return false } @@ -43,6 +50,9 @@ export async function renameWorktreeFolderOnFirstWork( repoId: repo.id, repoPath: repo.path, oldWorktreePath: parsed.worktreePath, + worktreeIdSuffix: worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + ? `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${worktreeId.split(FOLDER_WORKSPACE_INSTANCE_SEPARATOR).at(-1)}` + : undefined, newLeaf, settings: deps.getSettings(), platform: process.platform, diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 9e52fc15fa7..5725b4efd75 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -26,6 +26,7 @@ export function planWorktreeFolderRename(args: { repoId: string repoPath: string oldWorktreePath: string + worktreeIdSuffix?: string newLeaf: string settings: WorktreePathSettings platform: NodeJS.Platform @@ -49,6 +50,8 @@ export function planWorktreeFolderRename(args: { return { oldPath: args.oldWorktreePath, newPath, - newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}` + // Why: folder workspaces can have multiple live instances backed by the + // same folder path, so preserve any synthetic instance suffix when re-keying. + newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}${args.worktreeIdSuffix ?? ''}` } } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 90ccae11ea6..3d9962fcb48 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -15,7 +15,7 @@ import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { parseWslPath, isWslAvailable } from '../wsl' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { injectHistoryEnv, updateHistFileForFallback, @@ -154,7 +154,11 @@ function runPtyCleanup(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { - const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined + // Why: strip any synthetic `::workspace:` folder-workspace suffix so WSL + // detection parses the real path, not a nonexistent identifier. + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 72b020df4ec..64c12946a4b 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -104,6 +104,44 @@ describe('runtime git client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('uses the backing folder path for local folder-workspace status', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, + worktreePath: `/home/user::workspace:${workspaceId}` + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/home/user', + connectionId: undefined + }) + }) + + it('uses the backing folder path for other local folder-workspace git ops', async () => { + // Why: status is not the only command run as a subprocess cwd. Every local + // op (diff, submodule status, upstream, stage, …) must strip the synthetic + // `::workspace:` suffix or Git spawns against a nonexistent directory. + gitDiff.mockResolvedValue({ hunks: [] }) + gitSubmoduleStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: `folder-repo::/home/user::workspace:${workspaceId}`, + worktreePath: `/home/user::workspace:${workspaceId}` + } + + await getRuntimeGitDiff(context, { filePath: 'a.ts', staged: false }) + await getRuntimeGitSubmoduleStatus(context, 'sub') + + expect(gitDiff).toHaveBeenCalledWith(expect.objectContaining({ worktreePath: '/home/user' })) + expect(gitSubmoduleStatus).toHaveBeenCalledWith( + expect.objectContaining({ worktreePath: '/home/user' }) + ) + }) + it('forwards includeIgnored to local git status only when enabled', async () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index a1768bef5ca..972e2b7fdcb 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -22,7 +22,7 @@ import type { HostedReviewProvider } from '../../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree-id' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' @@ -72,6 +72,17 @@ export type RuntimeGitContext = { connectionId?: string } +// Why: folder-workspace instance IDs carry a synthetic `::workspace:` +// suffix for identity, and store placeholders surface that suffix on +// `worktree.path`. Every local Git op runs `worktreePath` as a subprocess cwd, +// so resolve the real filesystem path here or Git spawns against a nonexistent +// directory (ENOENT). Ordinary worktrees keep their parsed path unchanged. +function resolveLocalWorktreePath(context: RuntimeGitContext): string { + return context.worktreeId + ? (splitWorktreeIdForFilesystem(context.worktreeId)?.worktreePath ?? context.worktreePath) + : context.worktreePath +} + export type RuntimeGenerateCommitMessageOverrides = { sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams sourceControlAi?: GlobalSettings['sourceControlAi'] @@ -131,7 +142,7 @@ export async function getRuntimeGitStatus( : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...includeIgnoredArgs, ...upstreamCacheBypassArgs @@ -157,7 +168,7 @@ export async function getRuntimeGitSubmoduleStatus( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.submoduleStatus({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), submodulePath, connectionId: context.connectionId, area @@ -185,7 +196,7 @@ export async function getRuntimeGitIgnoredPaths( } if (target.kind === 'local' || !context.worktreeId) { return window.api.git.checkIgnored({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, paths }) @@ -205,7 +216,7 @@ export async function getRuntimeGitHistory( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.history({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...options }) @@ -224,7 +235,7 @@ export async function getRuntimeGitConflictOperation( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.conflictOperation({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) } @@ -240,7 +251,7 @@ export async function abortRuntimeGitMerge(context: RuntimeGitContext): Promise< const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortMerge({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -257,7 +268,7 @@ export async function abortRuntimeGitRebase(context: RuntimeGitContext): Promise const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.abortRebase({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -277,7 +288,7 @@ export async function getRuntimeGitDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.diff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath: args.filePath, staged: args.staged, compareAgainstHead: args.compareAgainstHead, @@ -299,7 +310,7 @@ export async function getRuntimeGitBranchCompare( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchCompare({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), baseRef, connectionId: context.connectionId }) @@ -319,7 +330,7 @@ export async function getRuntimeGitCommitCompare( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitCompare({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), commitId, connectionId: context.connectionId }) @@ -339,7 +350,7 @@ export async function getRuntimeGitUpstreamStatus( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.upstreamStatus({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -362,7 +373,7 @@ export async function fetchRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fetch({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -386,7 +397,7 @@ export async function syncRuntimeGitForkDefaultBranch( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.syncFork({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, expectedUpstream }) @@ -409,7 +420,7 @@ export async function pullRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.pull({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -433,7 +444,7 @@ export async function fastForwardRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.fastForward({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(pushTarget ? { pushTarget } : {}) }) @@ -457,7 +468,7 @@ export async function rebaseRuntimeGitFromBase( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.rebaseFromBase({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), baseRef, connectionId: context.connectionId }) @@ -478,7 +489,7 @@ export async function pushRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.push({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, ...(args.publish !== undefined ? { publish: args.publish } : {}), ...(args.pushTarget !== undefined ? { pushTarget: args.pushTarget } : {}), @@ -510,7 +521,7 @@ export async function getRuntimeGitBranchDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchDiff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), compare: args.compare, filePath: args.filePath, oldPath: args.oldPath, @@ -537,7 +548,7 @@ export async function getRuntimeGitCommitDiff( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commitDiff({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), commitOid: args.commitOid, parentOid: args.parentOid, filePath: args.filePath, @@ -560,7 +571,7 @@ export async function commitRuntimeGit( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.commit({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), message, connectionId: context.connectionId }) @@ -580,7 +591,7 @@ export async function generateRuntimeCommitMessage( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generateCommitMessage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...(overrides?.sourceControlAiResolvedParams @@ -614,7 +625,7 @@ export async function discoverRuntimeCommitMessageModels( if (target.kind === 'local' || !context.worktreeId) { return window.api.git.discoverCommitMessageModels({ agentId, - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) as Promise } @@ -638,7 +649,7 @@ export async function cancelRuntimeGenerateCommitMessage( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGenerateCommitMessage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -659,7 +670,7 @@ export async function generateRuntimePullRequestFields( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generatePullRequestFields({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...input, @@ -693,7 +704,7 @@ export async function cancelRuntimeGeneratePullRequestFields( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.cancelGeneratePullRequestFields({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId }) return @@ -713,7 +724,7 @@ export async function stageRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.stage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -734,7 +745,7 @@ export async function bulkStageRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkStage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -755,7 +766,7 @@ export async function unstageRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.unstage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -776,7 +787,7 @@ export async function bulkUnstageRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkUnstage({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -797,7 +808,7 @@ export async function bulkDiscardRuntimeGitPaths( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.bulkDiscard({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePaths, connectionId: context.connectionId }) @@ -818,7 +829,7 @@ export async function discardRuntimeGitPath( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { await window.api.git.discard({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), filePath, connectionId: context.connectionId }) @@ -839,7 +850,7 @@ export async function getRuntimeGitRemoteFileUrl( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteFileUrl({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), relativePath: args.relativePath, line: args.line, connectionId: context.connectionId @@ -864,7 +875,7 @@ export async function getRuntimeGitRemoteCommitUrl( const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.remoteCommitUrl({ - worktreePath: context.worktreePath, + worktreePath: resolveLocalWorktreePath(context), sha: args.sha, connectionId: context.connectionId }) diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 60502611a66..62f4c35a3d1 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -197,6 +197,38 @@ describe('hydrateWorkspaceSession', () => { ]) }) + it('strips the synthetic workspace suffix from folder-workspace instance placeholders', () => { + const store = createTestStore() + const workspaceUuid = '123e4567-e89b-12d3-a456-426614174000' + const worktreeId = `folder-repo::/home/user::workspace:${workspaceUuid}` + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'folder-repo', + activeWorktreeId: worktreeId, + activeTabId: 'folder-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'folder-tab', worktreeId, ptyId: 'folder-session' })] + } + } + + store.getState().hydrateWorkspaceSession(session, { + runtimeHostIdByWorkspaceSessionKey: { + [worktreeWorkspaceKey(worktreeId)]: 'runtime:env-1' + } + }) + + // Why: `id` keeps the `::workspace:` identity suffix, but `path` and + // `displayName` must resolve to the real folder so Git and other filesystem + // callers never spawn against a nonexistent cwd. + expect(store.getState().worktreesByRepo['folder-repo']).toEqual([ + expect.objectContaining({ id: worktreeId, path: '/home/user', displayName: 'user' }) + ]) + expect(store.getState().repos).toEqual([ + expect.objectContaining({ id: 'folder-repo', path: '/home/user' }) + ]) + }) + it('avoids duplicate repo placeholders when a same-id local repo is already loaded', () => { const store = createTestStore() const worktreeId = 'same-repo::/srv/remote-wt' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 817597d4132..be20477bc78 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -34,7 +34,10 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree-id' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' @@ -168,7 +171,11 @@ function buildRuntimeSessionPlaceholders({ } const worktreeId = workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey - const parsed = splitWorktreeId(worktreeId) + // Why: folder-workspace instance IDs carry a synthetic `::workspace:` + // suffix. The placeholder's `id` keeps it for identity, but `path`/display + // must be the real folder path so Git and other filesystem callers do not + // spawn against a nonexistent cwd — matching the authoritative worktree. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!parsed) { continue } @@ -3263,7 +3270,9 @@ export const createTerminalSlice: StateCreator if (existing) { continue } - const path = splitWorktreeId(worktreeId)?.worktreePath ?? '' + // Why: strip the synthetic `::workspace:` folder-workspace suffix + // so the placeholder path is a real cwd; `id` above keeps it for identity. + const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' // Why: SSH worktree paths may use backslash separators on Windows remotes. const displayName = path.split(/[/\\]/).pop() || path const placeholder: Worktree = { From 398fc79c4f3e5836b87d5f0cc82db0e2cbc26f6c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:13:29 -0700 Subject: [PATCH 11/52] Show markdown front-matter by default in the rich editor and preview (#8623) Flip the default so the front-matter banner is visible unless the user explicitly hides it. The per-file visibility map now stores only hide overrides (false) instead of show overrides: read defaults resolve to true, the setter writes false / deletes on show, session persistence preserves the real value, and hydration keeps false entries while dropping legacy true entries back to the visible default. Co-authored-by: Orca --- .../src/components/editor/EditorPanel.tsx | 3 +- .../src/components/editor/MarkdownPreview.tsx | 7 ++-- .../src/lib/workspace-session.test.ts | 10 ++--- src/renderer/src/lib/workspace-session.ts | 8 ++-- src/renderer/src/store/slices/editor.test.ts | 42 +++++++++---------- src/renderer/src/store/slices/editor.ts | 24 ++++++----- .../src/store/slices/settings.test.ts | 4 +- .../slices/store-session-cascades.test.ts | 42 +++++++++++++++++-- 8 files changed, 90 insertions(+), 50 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 21b116ea246..43a9276b15e 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -346,8 +346,9 @@ function EditorPanelInner({ activeMarkdownContent && extractFrontMatter(activeMarkdownContent) ) + // Why: front-matter shows by default; the map only carries per-file hide overrides. const isMarkdownFrontmatterVisible = - markdownFrontmatterVisible[markdownDocumentStateFileId] ?? false + markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true const isMarkdownTableOfContentsVisible = markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 38d146c1312..f47b3610f3c 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -619,12 +619,11 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) - // Why: front matter is hidden by default (#4468) and controlled from the - // markdown preview actions menu, keeping metadata out of the reading surface - // unless the user explicitly asks for it. + // Why: front matter shows by default and is toggled off from the markdown + // preview actions menu; the store map only carries per-file hide overrides. const toggleableSourceFileId: string | null = sourceFileId ?? null const frontmatterVisible = toggleableSourceFileId - ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false) + ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true) : true const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index 2e24a297132..ee2884ac400 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -176,18 +176,18 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.browserTabsByWorktree?.['wt-1'][0].loading).toBe(false) }) - it('persists front-matter visibility only for restored editor files', () => { + it('persists front-matter hide overrides only for restored editor files', () => { const payload = buildWorkspaceSessionPayload( createSnapshot({ markdownFrontmatterVisible: { - '/tmp/demo.ts': true, - '/tmp/demo.diff': true, - '/tmp/closed.md': true + '/tmp/demo.ts': false, + '/tmp/demo.diff': false, + '/tmp/closed.md': false } }) ) - expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true }) + expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': false }) }) it('does not persist empty split groups from transient simulator tab creation', () => { diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 21a11eb0eec..f0acc22e620 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -188,10 +188,12 @@ export function buildEditorSessionData( WorkspaceVisibleTabType > const allEditFileIds = new Set(Object.values(editFileIdsByWorktree).flatMap((ids) => [...ids])) + // Why: preserve the actual value so per-file hide overrides survive restart; + // the map only ever carries `false` entries (visible is the default). const persistedMarkdownFrontmatterVisible = Object.fromEntries( - Object.keys(markdownFrontmatterVisible ?? {}) - .filter((fileId) => allEditFileIds.has(fileId)) - .map((fileId) => [fileId, true]) + Object.entries(markdownFrontmatterVisible ?? {}).filter(([fileId]) => + allEditFileIds.has(fileId) + ) ) return { diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 0859e675615..ac54883ebe3 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1482,7 +1482,7 @@ describe('createEditorSlice markdown view state', () => { }, { preview: true } ) - store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false) store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { @@ -1512,7 +1512,7 @@ describe('createEditorSlice markdown view state', () => { worktreeId: 'wt-1', language: 'markdown' }) - store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false) store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { @@ -1520,7 +1520,7 @@ describe('createEditorSlice markdown view state', () => { }) expect(store.getState().markdownFrontmatterVisible).toEqual({ - '/repo/docs/README.md': true + '/repo/docs/README.md': false }) expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/docs/README.md': true @@ -1573,28 +1573,28 @@ describe('createEditorSlice editor view mode', () => { }) describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { - it('stores visible=true as an explicit entry keyed by fileId', () => { + it('stores hidden=false as an explicit entry keyed by fileId', () => { const store = createEditorStore() - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) - - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) - }) - - it('deletes the entry when visibility resets to hidden', () => { - const store = createEditorStore() - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) + }) + + it('deletes the entry when visibility resets to visible', () => { + const store = createEditorStore() + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + expect(store.getState().markdownFrontmatterVisible).toEqual({}) }) - it('is a no-op when hiding a file that was never shown', () => { + it('is a no-op when showing a file that was never hidden', () => { const store = createEditorStore() const before = store.getState().markdownFrontmatterVisible - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) expect(store.getState().markdownFrontmatterVisible).toBe(before) }) @@ -1608,7 +1608,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { language: 'markdown', mode: 'edit' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeFile('/repo/notes.md') @@ -1630,11 +1630,11 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { worktreeId: 'wt-1', language: 'markdown' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeFile('/repo/notes.md') - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) store.getState().closeFile('markdown-preview::/repo/notes.md') @@ -1662,7 +1662,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { }, { sourceFileId: '/repo/notes.md' } ) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().openFile( { @@ -1675,7 +1675,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { { preview: true } ) - expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false }) }) it('drops the visibility flag when all files are closed', () => { @@ -1687,7 +1687,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { language: 'markdown', mode: 'edit' }) - store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true) + store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false) store.getState().closeAllFiles() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 8e9e86c0d93..7e825ec6f77 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1405,11 +1405,11 @@ export const createEditorSlice: StateCreator = (s markdownFrontmatterVisible: {}, setMarkdownFrontmatterVisible: (fileId, visible) => set((s) => { - // Why: default is hidden. Writing `false` explicitly when no entry exists - // would grow the record unnecessarily; delete instead so the shape stays - // minimal and hydration round-trips cleanly — same trade-off as - // setEditorViewMode above. - if (!visible) { + // Why: default is visible. Writing `true` explicitly when no entry exists + // would grow the record unnecessarily; delete instead so the map only + // carries hide overrides and hydration round-trips cleanly — same + // trade-off as setEditorViewMode above. + if (visible) { if (!(fileId in s.markdownFrontmatterVisible)) { return s } @@ -1417,7 +1417,7 @@ export const createEditorSlice: StateCreator = (s delete next[fileId] return { markdownFrontmatterVisible: next } } - return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } + return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: false } } }), // Markdown table of contents visibility @@ -4496,24 +4496,26 @@ export const createEditorSlice: StateCreator = (s const nextActiveTabType = nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal' const openFileIds = new Set(openFiles.map((file) => file.id)) - const visibleFrontmatterEntries = new Map() + // Why: visible is the default, so only restore per-file hide overrides + // (`false`); legacy `true` entries collapse back to the default. + const hiddenFrontmatterEntries = new Map() for (const [persistedFileId, visible] of Object.entries( persistedMarkdownFrontmatterVisible )) { - if (!visible) { + if (visible) { continue } if (openFileIds.has(persistedFileId)) { - visibleFrontmatterEntries.set(persistedFileId, true) + hiddenFrontmatterEntries.set(persistedFileId, false) } for (const migrations of Object.values(editorFileIdMigrationsByWorktree)) { const migratedFileId = migrations.get(persistedFileId) if (migratedFileId && openFileIds.has(migratedFileId)) { - visibleFrontmatterEntries.set(migratedFileId, true) + hiddenFrontmatterEntries.set(migratedFileId, false) } } } - const markdownFrontmatterVisible = Object.fromEntries(visibleFrontmatterEntries) + const markdownFrontmatterVisible = Object.fromEntries(hiddenFrontmatterEntries) return { openFiles, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index dd113fa668a..ad4c4983b58 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -242,7 +242,7 @@ describe('createSettingsSlice runtime switching', () => { editorDrafts: { '/env-1/repo/stale.md': 'stale' }, markdownViewMode: { '/env-1/repo/stale.md': 'rich' }, editorViewMode: { '/env-1/repo/stale.md': 'changes' }, - markdownFrontmatterVisible: { '/env-1/repo/stale.md': true }, + markdownFrontmatterVisible: { '/env-1/repo/stale.md': false }, editorCursorLine: { '/env-1/repo/stale.md': 4 }, showDotfilesByWorktree: { 'repo-env-1::/env-1/repo': false }, gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] }, @@ -300,7 +300,7 @@ describe('createSettingsSlice runtime switching', () => { expect(store.getState().markdownViewMode).toEqual({ '/env-1/repo/stale.md': 'rich' }) expect(store.getState().editorViewMode).toEqual({ '/env-1/repo/stale.md': 'changes' }) expect(store.getState().markdownFrontmatterVisible).toEqual({ - '/env-1/repo/stale.md': true + '/env-1/repo/stale.md': false }) expect(store.getState().editorCursorLine).toEqual({ '/env-1/repo/stale.md': 4 }) expect(store.getState().showDotfilesByWorktree).toEqual({ 'repo-env-1::/env-1/repo': false }) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index f938dfecdb0..2a9a3de8e94 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -2079,7 +2079,7 @@ describe('hydrateEditorSession', () => { }, activeFileIdByWorktree: { [wt]: '/path/wt1/src/index.ts' }, activeTabTypeByWorktree: { [wt]: 'editor' }, - markdownFrontmatterVisible: { '/path/wt1/README.md': true } + markdownFrontmatterVisible: { '/path/wt1/README.md': false } }) const s = store.getState() @@ -2088,7 +2088,7 @@ describe('hydrateEditorSession', () => { expect(s.openFiles[0].mode).toBe('edit') expect(s.openFiles[0].isDirty).toBe(false) expect(s.openFiles[1].isPreview).toBe(true) - expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': true }) + expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': false }) expect(s.activeFileId).toBe('/path/wt1/src/index.ts') expect(s.activeTabType).toBe('editor') }) @@ -2167,10 +2167,46 @@ describe('hydrateEditorSession', () => { [FLOATING_TERMINAL_WORKTREE_ID]: filePath }, activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }, + markdownFrontmatterVisible: { [filePath]: false } + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: false }) + }) + + it('drops legacy visible=true front-matter entries so upgraded sessions fall back to the visible default', () => { + const store = createTestStore() + const filePath = '/orca/userData/floating-workspace/note.md' + const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null) + + store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + + store.getState().hydrateEditorSession({ + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + filePath, + relativePath: 'note.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + }, + activeFileIdByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: filePath + }, + activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }, + // Pre-flip sessions stored `true` for the (then non-default) visible state. markdownFrontmatterVisible: { [filePath]: true } }) - expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: true }) + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + expect(fileId in store.getState().markdownFrontmatterVisible).toBe(false) }) it('falls back to the floating workspace file id when duplicate paths are owner-qualified', () => { From 469018ec78f15d525101a070b029c68ca75f3064 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:15:44 -0700 Subject: [PATCH 12/52] fix(sidebar): decouple agent-list expansion from the child-worktrees toggle (#8527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-list expansion (compact "N agents" summary + per-parent lineage folds) lived in local useState on WorktreeCardAgents, so the WorktreeCard remount triggered by the child-worktrees toggle (a virtual-row key flip between item and lineage-group) and by virtualizer scroll-recycle wiped it — folding one section reset the other. Lift it into a session-scoped, LRU-bounded per-worktree module cache (useWorktreeAgentExpansionState), mirroring pr-comments-list-selection. Adds unit + integration + e2e regression tests. Reviewed for regressions and performance. --- pr-evidence/1-before-both-collapsed.png | Bin 0 -> 12865 bytes pr-evidence/2-agents-expanded.png | Bin 0 -> 16861 bytes ...-children-toggle-agents-still-expanded.png | Bin 0 -> 13369 bytes ...ktreeCardAgents.expansion-remount.test.tsx | 223 +++++++ .../components/sidebar/WorktreeCardAgents.tsx | 45 +- ....lineage-agent-expansion-coupling.test.tsx | 581 ++++++++++++++++++ .../worktree-card-agents-expansion-state.ts | 130 ++++ .../worktree-lineage-agent-expansion.spec.ts | 112 ++++ 8 files changed, 1072 insertions(+), 19 deletions(-) create mode 100644 pr-evidence/1-before-both-collapsed.png create mode 100644 pr-evidence/2-agents-expanded.png create mode 100644 pr-evidence/3-after-children-toggle-agents-still-expanded.png create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts create mode 100644 tests/e2e/worktree-lineage-agent-expansion.spec.ts diff --git a/pr-evidence/1-before-both-collapsed.png b/pr-evidence/1-before-both-collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..e8b721a239b256ce0689814cb357099d1e4ca698 GIT binary patch literal 12865 zcmeI3XEdB~yY8hBqK!`U-n)n{7%hZo2|=_WMDLwoFnW#XqD3Upd+)stiQcb{LI(x74;e2>~oHd5$dG7yxU)S%tC*+liJoe)!k5N!iuoV@c>L@7pNKsJk zi=f{JuQ(bud!nE~P!yq3nr^9EX&CjdM=!cuzsH$<${Bvn%4qY#)Evs9!C1z!L&R0c z(qng98pk!2F(f>chUC`b#@&|)lp{NG=AK8S8H zko=VY{h(g8ol#<`?Yx9~0;eu<83#0-<~{t^k2gh?*3&K$;nbXWMvzOMuf#d5_7>>ZxlEN?_(9w=!qF{eYn=lh z6a7JEchy2E91crNx^^bZ*7v)pxz%&Jr)`@k(PK_~{Dy6QeN$WQ``olu+V{5HoY$xo zo+j#wCcR=G`fxM&3y!OR{Zjj6+54^{Q>`b{Tn3F-e~X$L_dCh$<+rD+G6XC}f~kGF zrD>q=nCir1I~*i$uN;Tc#q*StS`WHo=pWD?2$Ah8tT_&bL2?L|YL_~~iXxg-jq7{j zm~AJDX(4pnaRpSHb-Rta<>qSRh5C|`3b!}cHI@P%M;rKzFLH8nuFjUDB(IM&jvT-r zq0tR)vX{Two+vR%9Qpb(Wh7TAC8hTIWO}_n>9Ci*2+I9PdIj~d#UL6jUsQSpLF+vX zMf67u473!{(Qv!x()jGxI-KZ4y?RZ@jj#%^^QjMJt@lU+fDR0K~v+M7(C4&l#k*9JWQDjD~`RM$2 zOfAFX_|K{>X9qfiUvzz~+~L|l*!hWC02;QHQC|Y*XrAi&JG<+PW6R&)O+(7_n{F;F zoqS$@V$))~I`89d;tAC*c>6SgghTsPp(`x_M2Wj>SB!(pPdj=x?{&CcH7`eH zgz?}J4%A|CXn(njTd%s2I&bEFcG;f#)SRpZO5JzC?~y-)+v=w`k4d;F2(kJ-^O$n5ioqK| zDTBu=3{lBZRrvO;U3GiU;&#^U5K36FoO3!dtd3P4XxbZJb3CT2rUt>73ngN&H12pz zpnf2Bv61~g!`mfA(Av(}a_py0qnB`kTDBavUaBl1L#deKnzD3@u1(Eqs^cIAo&7c@ zcl{nxo_a9`Zx8#~O!J)L?a`>JfyXA}V_NcU0mkBlqs6x1C<%Q>&Z0Z!dYWoz#CEJTw^n#kGjOqpQNflfl0(+!*4*7~^W z!U>E~JE<{~^2sg1i9QtFtuM2luIx#rew=Bi!Y5I0)E2Zwhp67?9};t%EH(WVR~MS2 zoF?MD+!+BL3w^L0yWodkELrwTf#C3_vm|F|lDo>cb!pMVzNzQ-y3Tu#*E(A+omdvu zFd@8*azNeXW%zi!Dps7oR5+v4EU#)M()~NMMvZwD&cK95pyv*;+BwBnSHC#gG%*rz z33AOMffK#;nVS8imczRsl~N1Jmj-_Vi;OP^RQ^P}&il0EVHsT1n9Lr@>rK^9>~A^+ z>|Mn@PjW7mE)aP1OfQpvN#3_-`YJ~!g-*tkjm(yRQ}Fiqc-&yV-kpDwfJxcy`1hOL zhSRxcb8Z1Dv)-q(BpQ!gUS8w<0ljKEOBU0?I6HqEwJkR&UYX!ys1QaN3Adc>H`AGF zdltb8lbWSa_UIP(^|WVJ6Fjd`ds@iOPMdBc(4tSbCd-;`Z!T&0us*7ARWJB^U!F|z z9vp%$dUlv`tCuMqST*Mn)(_hIpY{*}ou}4Q0$i`6sKl!umzfPbrW2nA6<=f071^EA zDoiI=*a&*x-T@J8WIcu>Yg#MU^;Nk)C*ds52tI+dZ;($U=V-gJ(c*X6Fk-w8>xtsU z)z1bvx*k@iajG{(0#A-%OK!>TmU*I18$51%hFp@{jEpAK8^i>X%i;XRR-G})g4Ur! z!hgPH^Ux$no-G7`5{%Z$OLIwfuG-VzZMtnF3iBTIwfyxBudiy>W%|>bcOjy=%^UiT zufX^*S&Z_%qJJtc$iXm!>CR)|5pYDr`!bSnpjdeGjlMIH`^1}8lgvzEa11Pr*|feB z<~;I32#3b0N{}Jb9eP2@BtfH`OEX z!Qh6{4G#QKK5StVX)X`Z86WSJfh`)!K+dfky{IwFH#0nUgq4G}S3%Fk9pxQkxKuz+ z2vHe2mbWLe?qdzScOryq?SEFf8S=d$2wWIIXHrVwP+a=+`$q_@HC6i)=PPuhe#@c; zGszy+dcx6*WG%9A#Sz22N)q--IXA?7v6>T=4LOcVC$&;goxg18)#(zxc2HX$xaAQ& zW~^5gS63C!7gB5+rRFvsJM%syDHDDa#-1JPR{T8YjY2rrFgIK6a!P~g^j;jQlJXnc zhtlXFYnArPol;}Z;Xw}Q(fyCxcwf{qa*Y0^`ICKkI4HUsjVRED5ka#^u)5n-X*q_C zinWlg^U~4KYGs7Ee7qeuazS^`O5v!TKvlV5rT-92dpwlv4{}a>JyIe`ye4Q;G&sVI zHy?dHL4}(?9H0$5I*uXDp4O-woy^7Q%KVosjKa$a2CW-Iry|}(A&_`5V2b5s6^*Cp+%==M;Ts>DEfWT*viY7q9JJB0|N=ck(GQ>#8qz z>M(Mv-`kz9e(p*Ymt$JqrAM+FZ>^+z?qu?Y;-rsu+@SyBnR~CUI>n@dI8Iwyyjs7^ z6ia67l)b&Yu^Pi*eby{Q%A1Z|5zONzEBc{psAUANb%8XclcU(GJQO3yb+v>+CC0hL z7=2r!w)xjdERWlCiOD19zT!-Bj6gUJuW51~jiq5RP6&SQ*Reo$bLrlxmLLk|#qIo! zJPRRAtV!N2KYl45r=Mg_kv^5EGK$}_X!zo7t1|H=(h15w!x^i^B072LrV>SlpZ1Cn z-DBpAsQAU)@C@xxtdj@ zfJFU();jb2H3o(O1Ci-d7Z2C%DaNMwWtbLq2!iX`55%8=B>wfPc^^y%0=GoasC>F2 zDSK>N6$i5xl8k{wfgn~`5CPcP@=+3hf0pgk{w*{cNY1_f@WJJvn?6dwcJ2y18;glz zqa!RHVbpgve}3irDMVTkjkMV_^q+K++XCgA17>wm}#v?K8pK*j5itM*ON zJE8KXt5t47>0_U(eR&tSV>X8GVju}(pb#ilpntN|L%lCHC){tr{{SnyD&b_*xUuh| z3`PY{2^q)qGAljFml-$z)wAB3lPODU2fpu4pmS*4((rML())nYlK{g}s7(q+Wig1A zcU^xtxEvol0{oSF?hAd_S!aPq@Rq;hMNMnTmRyZz#vc&PKfRL6R`RC$=2vRRS*k;U zLaC^+Fy{j%N&0ZeR7Ox+?B!ES>vtny25SCzjZKilnIY!R_r~M=U=_@w&FQM5iu}(8 zKEf(Ng4I@&r4t^jy`S*XU;g-5LZ_e!i%X>Y2x?($?Q^TJ;S5koLQPR-r zwLX}ybG6No@P^b6n3KVt{4v=*dHr28!~~b_kk06uV<7`6+-M3Wyh5`6XBIW%?Wu}X z#ldwj{mC>4nG-VU2XXs4!bq5zP1UQ3*!}fb7?Kb7o@x>QTpu9)GSE-jaB}DAmJsx7 zgK3_>wca4d#(uovMqn8s<)(B)zI6+B=|k-C7K{|7U}g(Idmhet?lSI_Zc=cNM`nR? znXPkW-v6bcS|OgS;vY~HIKBRSo2V8j<-q&a#`Gz!eziuhgs!h;^r%(!Y(MAz$pYG@ z?1)L&hnE^QFoGaMXZy>+*yLvSU~3rfHCQNU5i`-ngbYwk_f`z%FAs6zT)xXiJkcag znq(Nd6X{NB@J5QpH20~e$*~(F&}hs+_-C4<(oJW?2G1DW5(t%-9M53gkYvR|cHWei z%b|X9zFGd!L5RRHl3jT2R28TB*=92&cjB97!P|-_0_M-<@5`S8b2D;C+b&HTZyN~3 z&5nsvh#leKxL*(E=VVT_8&43Iodk)V-MxFbK}z4`oZ!0I&jKUlhHmA&Z;p$67b{*x z<{D0}Fq^l338^Cu`=MR3G@L2J8u2Q0@saWsXkCkOj(9O$X>D7($y6)DL4%RfFX6*!^T?9v3v=Mv z3a;;2TOzLs=`o!^=wMXm#^mkL?G>(^Nn97fy^-0Ag#g@SN*E|UZ5}fizgcERqI*cT zkwO5?HZU(n2Ec)azpM|vmFh%LxG%3xY?cj4TmnU$_7+d8bBHpGYES7Wwgy711ldLH zJM$en1OtW>9-qS>FYVQ5Z~xJc;Os-K96DMPT+BRC=w!F?G7^b?VjQQ|i4 z)?3mNv{7RstMGbYve~q|UyM)ig;dK{M^kvr&WLndM4Gzcld^mWftdpxSAY=zgPWGdDoV$qC~_T(nQMAT$0%DC zX3qmh!JI{crEv#_MfmJKTPaqLKvvJQawJjnICAzejtVzb!)W9PE>nt1via?l1?zy+W~Qyk$D&D*OKsasxrHFi3&n6 z@y1SK2ptgj{pAe*GjNf*OK%_c3ioMfJx*24sAz6@UNrEhdMy4-frRd#hl56sh5S;Y zm1U(?lEydMvt+}}0;949CcyEY3QjKbBylI?Hw-7cRJ_5zMDKx8#KnT2sj6ju+yj=^ zKS{f4-Vdd_JxT;`JP&XRd?O)U8r);zrDnD@L19Vy{p~orlLyjMil@$XH*!)ej;(iP zPWGm`&@)!0hqFBFb&N2aCVOkTDr(A>!cKm3?Q^`fI~i*Va}=xnYtdw1t7mJ!#Wk#s zkY?&_m8YO9df%L{e@*@H`@0r6A^>RhZ0WC%j}TeHdeC*v=A=qPoPB&*i#^f`G`e)? zFppebByiMNUOd`AeiX|PFRn2K6S+APUt7Eq|Ai=w+G+(e=X^MXmF&)VCEMe&?xDzvz6K+u*9k(<3WqC{FO?Y*uN# zU~BYKlMrKj8ymFa{;f96gUMLeI!Bj6@}s`0vdSXi3Mk`EiQQ=Ise*E~E1gJtnbW3v z{Nd#L4BE^mPd`K-=sd)?f4MnT*In^byM%6!o}5`y=c3&&SM|U*^N)DIpVO|QuIB~s zO&hymZS%9gKLk6JYK?I!3Job@31!@~G;?C}9kKrO}mVi3pERQnWlqpK8;U=y(+k>(<9W`vU4kEZ5V@gVCZ{ zzbJwEM_JYo=ffO?202-CWnQwu|NM$0O-8EM0nw0aW2 z-E^hS?Y90vSz2$PIlu~k#MfJGIq3@1w{uO{?qeraK4d(Tm>5h#$~4*|r7j6Efu4m> zNTEf(iT0V--(8=XT1Ad%Qi9pZIcu`j#6w;+TB`f?G)rH!D=l^(#GKmyQm;IT!6D?* z`lkJ<{4i04&1I*jiDoGCg>Q`TeFr$v`o_1Q%kY$ldj%-1MOziq*ObN%HD$s ziVkUL+)F=+Yq}{r%_C2xcMWe^Ym~P~Ti1xu{}_wmhb}SyG5+nlvBS(u&Gw7^-Tn{j zABfdKF7Fvn=fahu+_;Hvu+_7MW&a=?$`5ai6L%`mku6kN9{>l*Tsh%#5MB$@t5uv_ zjrl%ru(5#Bwu>2pM!u0Xp`UE`G19a>0KUPkL#6R1X88l7@P?OKO)~q5m64O*>MPkR zh8Qi3Qx(EsxPJgWwviqQ#gQ5SIiu0863uF|QiaK-qy{kRAx!yge}x?R&-vQxx0mlA zR2K6wT}2*It%WSR42kM5 z?xhwUYJ6hj8es=rR7i{P=J$8)ZV0=APH}5MRdrGsrCW*lDXt9j6D`hW26j~iInv%s zgvuCm{tk0z>-y?65ZaJUT@DMtKB4?9v!lPl?4Yf1)!)DDax%fP7$@#XNf957E!#ei z!N?HiW5t|u+UR*|BVL|tp!iO~#ye+YoaIEmdyjsC^oO{(;SICU*>llON=dz-H4`eI zzQKm+#``hUisN;264I^7CgE7zF-f2OSvZs4+im@>Jv&OBt?Q=NHcx<=QPWZ)AO(y* z_=hXM`sRe|Tw^48eyH%3>6*VsYW7dp2$P?)?BJ6%V+nOJNn+3i{n)IAzU!i^O-+qt z7y88#3^^xq$NjGWWxt=vR}43!ChiC)aY=4yw5)XL`(|9pD?86=fo@TNlzrzLz3W`I zdz%lNvS^mM89dXt+6XXKIZhUfjXOdC?3O9o30bkguN)?*hBAndoG_d)cRYz{C>Wtkwt9_6^RI@NpPY>E#gR_QbIR z+oS^k_jQJo9Xi8-hsiBD2jc#9q24)|$l?ldc=U1{Z;x&pyZPJ?V1ls4{JiwgbAdaY ze`lOvggElj_&p0V@}U`CkGRz;ttL;lr_02E_R)jxFLe;u)Y;6|f=HoD2qM-G#~@hi zPty3RoTS9vaFl4`li}hvSMLr07UL=~UF`a`0y`|7crTL$EPzD-`nP9=L-HayQ2~El zLfx!$iS3}H#3mL4nFZRq=jp5~XN-1>Okp35?+*|0vmo(mySaIY)kn>ND2JN zr}`c{Ga&_!Axhy-;EgPzGJhB7x}NPVUYREPA)5eY#8k`Gcbk$*v*`|H*gt;~D0L(| z#JtTdWw_yz9dZDeU~{)_^h2x<%&Bb>+b{VJUxpS#U_WgW!{BGWV;#lhrSa) zs1~>dFJ$QyuY#L4zf8YTJ$e$zA_VL4W(eEg>a=_Z1t-DS$6vKTl?;E2jjR^n6(wSFWp7QU@uTbm}RupH;oDTSTI zRe&U?T<7rLGY)|A(Q3jsUdR!=7OKD^7qprH=gynx1xzyC(f4vBQ|3`PQ|fg!FkjTZ zSG<^q>qEeS{?$-dAMODN99YG1=ev}n(fcy;$o&)e~J>9cV zK8h;h$@KcCHXC1(PqkJL55AB?)7c~QG9_}yGz zMp256jRfOTM-C2&9rnL#@L){Ey*9YLcmv|q$z$reuZwUhaTHHo^rz z#$czwe!u%Q#m4w>Filir@%akHBpZX5TL00HD+b<*D`3P8+#!E&A;{y+(XI7L8J6P$#ej-qO0evI%umN=Kh)4n{%D{>D zVzMeR1(|8GPCX1yR3*+Ct|@d2)w0oCCd zm9Oy#mjLQv8t@2LE+gFW6(3bTaLk-<%bRmwSv7bZ?+LG`I^ry{yo+B2BPbeMC-Ag* zXNmwKr$ zl~MOn;h`*VXS*uCMxSb16Yb+mGbCU$ojjB;Jrg6zORk`L$n&-qlz{z^#Q0y|bgQ^j zKF;A>Lnv?-XX05k{1I_RXZMIerell6FyX^pvw~08e8fMD-g-1&yJ8zDSd0r8Ib8BYS}%sX|vXmsn{uW6A< zQYZo^fpo>qLjplf7HdP{_pN#0A8R-M%I6KG3XdKvGiHr4tE7_ZzXk65E~NrAlBzU) zratR$;NNU|s649su*PFLeM;#UPR@X3k5U>ZKFo*cVUr7n&*6MGvDyZSg8)ej3W~F# zqu4t5hy0$E#dJb~rdSf>7C~6~ekLk5fB`+qzd82hp2=1qUJTC*LSgBh2HF(ziE;~q z4^eo{Aj*prJ5X>eNc~~kbe)hX4DYf-??Zop8Z@N45Q#oLa^@yVhK`=SOP@zFMS^yH zKV}>D`^^9*M>q=)MAfjzyq(f@rUgZx<}nJsdO~wjxKFw2il@T?UZz*Lre-wKlJNhq zm+?OrHCE=xOJ{gM2GdVo(TX=%EdGibp&X|a2#dN)=@xtHb!hlHcV_FXTd^!y4_8Kp zaSSx;-R$#OlopR1VolPmB^kQ8z3wT)SJY{da%*NpY7N4UYggc*O4-~e0Jn9QK;=#R zlhl=!StP)ncRCET$is+db7z2yjV9_cU4rxJzWRw~eOTtlrrFU3QdRs|GX$RzNF>MQ zh-W7oNcwJ6F+i&CaB32t>78~1D3vkwFI`-J^3%T)CQ%JXBT6MxzE{iCfCUx+G)Y6Z z!bmHAwb#bW>V0t(G9>4D-ipZ$)MpbI8FxWD$kJ6RF}bP@f&YGhhE-f~gk9^heet>J ziV@M@&I6PfV21Mm1%o&GDF1_IUv`LyI{$%}>`Yhf{yWSDA9f9Jomw|I^IBj*;&Qji z_v&mPwC>&UqIuT5RDAvWmmkJ{l078@RQeIM*WMHe*Upy09%`O~OCWlqCv{Bau|d0a z1CE{;=pQ-UfI z=_1a5z)%6#*roO5Ug$gwtg+m+XL+fjYQQGCpDG*Wf!O?~X8s7kDA79rkPBx3VNl7o z?+6-r&aVR9ahKo>22-H*fQ+yn$-$_b0Hgix6r6<-bG|qyV$(EqbQnw*Pw8J2n@D14 z!uAEnj(`N2tMpmdw&B?C5!C{gLjs1|9u;tEc`r>lZ64P7Zk2TLELn_xHL4R>1Z^CV z;fJuaYr}qKH}KBqd=+T*VJyerJ>j9%g^5C+@6`c^zb*+9)s}!qVDx3QF0b#%;3wjI z&;B25yc?HH1O4|USOc2GdxsMsL)C6JZs6;Ew=QtHt6HKedF`ZJfL>yiAX%9U>y-q3 zGYCxNP>`2H#qY*4jicdpDn;th({a53wJcqH@@F+eF0>6LCn*7WySQ0b#7iiEEOjhJD>xpFqjk7{g6<7%e4wSeo>*cNG z(rO+-3*ydD8Z{&^DDA2bKWbfrpjf*TXgeMos}3`^y#34R`5R*Bn&*PF`n|W%6oR;m z;g#tb?s&O?2p#1N2}-hJjkotcbW`P%3kIieqwI%b;HKU~q2TsscH}!$&%rMIXtLN} ztjr!MQ~7zFcRqxGnJy@jQglgI&}IhFck%Ro2#g*#__+rpDeJ+hw4hTrUjdegay7>L zt?xeyjX8tE(OyX>`d@wP0j;sy1#HYrZ&?CslJj&i+MW+s7CP1q_#rivsW1~8?0fL0 z6l0Q;v=rE%`*;6Ra+6Nxg!Rl(8X$*krL%;qHrM>S#hk|unT|>^6tl)1v?7I;5H*hL z4lODY=PST^pvG(})tsET@UWE*Y}XUOX3lcHyyxwxN8bA*;)jVz}bHWa;EQ0u>>@*qVoebohn2E8r-D zrJ@Xtu4C-0Zre=CclWLDW@EW3)amRRZfRi|LS|<`NmqOtvWcmynzq}J7IDG^N%&l8x!!a@sP0R$j6(2vOA%6hg7wUBX`sZ z__IMa^ds<6Avw)h8UA4luWGE=NCx_Qo2L5JpEe)X;NIyqf##JaFxM@bbdC>)BuD++ zPbS~zF9K`30k!|QMjQ8V$dEduWs3trhi3q-Z>NPjen)Dw(jf&WNm0rzFcri^{0BJ1 z9aa>a6nK<{f@z(dpji;`Z#c65_-e(k`Z2>k0iaBe>AnBZEqd7TzcM9G75+ zLU0`&d^M(VK-c7+1AIPypmluA#feYkhe{u&Y5V;U{hx#aabUKNL<1K3sQ?YVqK;25UEAI*y5&DC>|W z3}$#F1e+S0tZ$_x-uL!$UXS&lJ<8}Io}`Q)jNS%Xl{!L`MKC2VV#44+WiF5{x%~i6 zOF@{&9{y6VJ2x)FE^=)M5+C7|gk;+};#HvN?)ovLm>dcw^;$O8dOOB&S0K1)E!* ze0y5zu|t7;R@|3I+oktXQ5suo59k_J?nS|4x7}Y@u>LI$etq2j9kWJL!Nu8vWAs>D zGKS0i#r9~3U$;O_Ra86b>u%bE#~w1T|KozBpJrhE+_UIFmRIdy5Af~QKmpufgjo6^ zUc`jPpT#(W{Ms})ZXY^rIH?~4F}?`<+wyN&qO?oA^6=+b)GMMK5i=fQ@(4E^3L(g0 zyhn79Jl}w#l5~4CK5cCPVa|38`u5iL4;tk1hBV=GWxPe1dD{F)7Hx@??T-Us=g)f6 zqf?DgxxWbVwIh6-%mRBL@NH>Jqc`3S0WBoU(9rf7afo5xKHFoQ^P?(jL-Ios;bi$( z1ANqbWw+Ay?dxJk19a1TZza`ZScKqjACFM5Dx4HrI!WQjvIsE<_b0@3kqG!RL$N$u zr2gY&|92Nf|A`X*3%jNN`j!9cHtxUf*uU=B|62r@&P;>}zBDj$_Z@-%&VKm6D)Rq} ziVS_21kBz8KgfBYbl_uDvQ^)EC@8nLdJhsrz!yQ#?w;-g6kh3ux{v%M$KVwdMOhVS Jp|qj@zX2jh+;#u} literal 0 HcmV?d00001 diff --git a/pr-evidence/2-agents-expanded.png b/pr-evidence/2-agents-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3c71f4791f5f892c17c1db81e70715b12ba20e GIT binary patch literal 16861 zcmeIaWl&vV*ewRZgC5+16A13^5(p4HxI4ii!6mr6dvHidaCdhOlHd{?f=h7e%h!Ir zb9;MlJJV_Z^v*B?9NBxHcR#Y8wbpyWl@(>sUlPBBfq_APCo8220|QG41M@-v`2~2y z#i-K@1_le}os_t`d)7fVQX1}unGn?1aW05M{E@P3ad>#2AXt4_v!uS$#BQ3iS+iL# zFL0$G$)xJeusah|lrV$P^9H-FxrxjEv$a=j|sd?o1W zE)iB9KZ_$_MuhSX8A(=Ff=^5WyBiie0ShB8F39mq5KRyq4GkqJ1trSb7i#LlI-6O= z46eOdLqGJ(!lI%o%bT+;$fCsN%b2LRqqeIR7X9Xn-N}tkf4F+f{h1#J9uNO!sq2FW z@yy=Jnhz)OyPy2wgbJy>#t#+pIo}@Z2|;I6$?pXh)gSX}A0_^7C@OBI!Z4RkDW~PQ z-OGmw!{Zhl>*{Fv?s_AZj*ianW-B!Zt0|WnYvJGiY=zlS!cLBteT(ZMB06!c%`A&j zn4V&FsuS{GM;XabjBF7f&kGfdK7O~p2s|7*#n$x*Mn{?D7B_Mc?`ura`_2$LDf?|l zFPg}$6c&9?sPI)3nQ+EoyU(4c#HKUHM*Qeb*3pJJ)_U^`gqP*#Yf2`qDg|%tmKrB< zsCZqpgr{n)r}3F}KK=fn`EWr16}e-|B}OM?`)bq`P*zq}V+qaSbue&W1TS{fO6_ox zxnBg287b|IXGh|`F|q$!oN1rIX*sUz(1y2i88nE#sWOg8Ssl}EL-$`+9p?Y~#{AbW zNxum`=n6ya!m9cS7_)QsVCN zaIwjyOZDT>QSZ_zdYfqYsz-zzM2tH1IBKyCqRX@zs9p$y|;6PEoyF*@lg>M=^ zhA+E=QRS29;|Eb+#Cq=XswcTLck4)e51{j^HIZX4NlHInK-iq`_-C}zZIDx#CUe~J z9kV<~74TOjP~)<49r z{Z!rtLwPPCw;&>7y;BZXmOK^V_O4>I2UFC0!Ma5Hz#F6*2*Cp+a&I<=mDaFtx()W4 z`k(RqxkH=XjvqVWFx^p3W^^5BX=yzM5VzZ=aa3Pxfh#RlDPUXeD0{-y-<>ZVx#HM^UOIRl*x-G0_Cv2}DkzCBR^)cKtR!cl$;C2j zZlTUbvs}yHT)o2J6@euLL2y2Y%NjfH&*xeF*)lacXsnH)&%fW~e)o{rs4uJ?_s&1Z zA0gClTTlB*)Fs&atv~*uzY~49m}C3-}^?&I&lcxs#9 z-#(9V&(=DuinM*lilln<{P%ZFbJgp(&7?0v__P;Cj{xd@)U-22sbPH4rkqyqbWxJ) z+x%}vx8>7bg6xwrO7is3^~v^FrV9$xZG$e?`|NCM#Ja@imx#q^+E^wpG)Fj7*Ay2- z6!|3IsYEm}cUb;SqQ%A$;kEn6AT&HijVvBJoq?EF;ft)zyHrrW9$9c@I<;17uNKV? zu4Fyj#;%^AT#U#5_uN-0&Gg6Z++{X~vK2zF8~?Ia@?RS9#tNN<1y9Zqt%Pf1X;^I$ zxoHN6)%@v7y9FWQ?rU^@)N<^)I{}-?x0R$u=DAA=HO#obTYWOflR!|wadQJDitWMN zFIU7T1e5%+9MkD_alc{!)=Q(eaENH7szt-#?pJ=^*SN$a=Gx76TWQ+=*#d48_3O`1 z*8`$d3!O#Qq7sLz9eyCO>BFOo-SE&-@l`n`VHomp@L3I_R+LA0SG@yGrdv|ap`zqO zS-!V>1Nk-!@HBgS1_>`hyaXYJjL!0c|0azYj4MRLiv zn~6&FOQ1C7NrpOG9n4h;eFeolvxLp#%nX`44_ZVmsEmV|!%fs0KZ_N!iG|ut)6v`u zTfI_>?5_`uRP%g_ScKv#hW&pHdyOooRo`R^-SKQL^Y4gtg-n*Pk}w+Sw_>$-m`zY0)OZnWZo=>)8!0aM)R(?cOek` zQ7mKgwzmI!yI*0sv0zhCv^5wKou-+Gwmo7S*F;r3l$}qcXz6#n()OmO&SV7@?mg&? zL?*+@j63G=-eLR^Z=;DR7hb>5sEj6IRV^F@H8;>?R2||Ph=|_Ul0YlZ@uuiX=qWl% zFUd|1#}|dQS1~KIE`2RfQsD~`PL0FQNFLcQUVMppO2QK{9_SL-H?-?DbfM11fAd7f;%{E*osp1$tulIfQjxr4hzehQkef!ML~+ek3)yoq2A-e+`<`5xJi!45K%hS9g}%91VwZkP1XxQ`^w}L@HaB)_i(?|DBwf>uK~CdT!-hoJRR{Zseb;P8QxE zqtYI$O3rO&K7S*>6Fb!WFvpv);~Y+{*ViNT`Fu1%MXF?>l3FGX%5*ulzrW#KM#oc< zwH)#9WJ!*-dH(Y}?ITEsfrsE)LL%2w(@@x!>0lyrYS^zR9?eA0Ulya+&taW2KzO_V zBAhcGL1v|v!uDfa1cQ{WgQ9$;V&KeN2h7kq#Dn(CvGVzZu;cj+!k|}a)giI#79IiD z0wUdKQg1a6wXzr0ZVZQXL+?;mlQE0ABz2rp4nSRLKglf@G@dWr=g&0~#fN^xCh_qJ zct|pCqrb-C?BmcPbidszX=`NLGYO9hKVz(D4IpBFGA`b{QG129UpH&eL(f0VFhgMI z?Jtowz{yR;W=i{|e@-gwH&1z})%1CC_@iABI~uhxJHz{RhI{!c5G|oE(u7xB7wr^^ z8u9a+w^DT1moRc*n>gY(I0Mhe1)=h28J~8t-8cvly3SEq?^mmp$1=FJiH!rH>$cES zksNZ}Ggu0eD1=68M2OCyRv@%>uQ)Scn8>hEuy-3Jmr#^@zL0RFUL?gk7pk6KSz8AIY&o z85NEh*aI7~1>L6VPng(oDtpNv{Fx#bf--o7%#oO8C{-lpFNz)Y9!d>y26oP`Iqt?n zgx(<~Mx5rRl7!uys$Va1UA&+c9wGB)2rx?1s@Wxdv_}oWR+G-@NOB;T?7R0=?EPRe z^JjC>YKb~O+6qps;6M#BVeJ$%l@sO5mbN(5C?nu$tipHyyM0OGr06&4*=IrZ-lgNx zr>SpOPr?a7pSZr}s*pme@fX6gkTkr0`TZ6MElBG7AHwy62hulh-n<(+t;Q62xzyp; zLFWuJPfv3vQ0znU-VDhLfVVoER1U0d_-c9CYpOE(ma3y=0QT;uAapI;vQnS;wWYZn z#?0XW$ILA-1!BzB+Gf&u3>qm_^%Lf zci9<-iarUw#V56!DjWjX$!aUteLWmUkuT3?_NVCMwdp6jje*#m3xM_S_YI%U?$t%8 z-$uD#Ovjs#HYL|KsNt1nV$X0b5}(;B@F6XA z`}6L^TGasAcg>0w?pZxayMrt~r*$IBtS+*JQ9Npf!mUdiwFni?#H?gSO^$m^Qa;Ds zi99elV5+j3XMn*8jZEk|l-&1gUB~^|$oC2KyT zEli;k7$~M-f8*>={m9_=IYQ|LntD!sK3B`3hbFzI12H~_ASr!swg9G`cPQ*RX$7$A zZ03h<$IW)8&+Wy{K@Bt^G4ah(mFa+VB);&!UuyJh+}!_?H4VC6uK@hnA5Eg>AywI% zJCe#eVDhv1FGI#a(Q(_A0qMeF!>S@Jmjj3pJ>;E!tjE9KE3h;5J@<+L+L0(yC%Nww zGy)JkWh>UDEd7>I^Jf%7DyyOI^~qW-=YGeKBtvbqCrkUaiM-<5L}KobQg_0l%z})* zsrs(-@946nFui=qtUW{Qp8?;V>gC13e2wE;cdDK`CVk&Nd^ZV*+A4S( zz5cF3tIA|3ffg)yVRRWNF=UD%?2X{DInUsicvv}GNPSum8is0`V&p%K8!(+IT_)D1ZVfq#if60sI`cEz( zWUN&it~Q8ft$_REFM5O4>!l_audAamEt`31t!|U3T0`1=D#2H7;=R&eLTNpGE&hP% z^54&vBdVk7PZ%0#GVuus&I7O9Dr>B#i(RFs!>?wc-EW4p>_zihj+*yNC$LGhR}dCL z;0)!*&)OIv!4XFqyxM3W)Or(~v`-#JVpjDD#KGq?HeE zpXl@rAaX9kbnS#4H6>wgJ-j^D1_xniW4|)?&-d>9%Chb#5*2UMich|T@3PIL$$&9f zg6RtMCJu!&z3_RaXhuY0UugtNbO>qz>k5i|otrM(38z1$T-Ap%Fk$f8%tVF5$Jqb< zRW|C4T%{aFOLq2{kS_j?>fOhlO5_u8a`ShTr$$Wrf4s*56na61mZ}`Ri`>v}`;r8G z$FD%x(EAj@0Cj7Qj4AUBKAMoriNMAQ@Dd&%>`SASBeswfTEScyg^+`ays1-f8y@HQ z!_^J`MU>pNYIB%BB4u78MQ>l7D)!DO*+Ss#RH!Covr)WAK9vO|IQKLqPh1dfO{_@7 zVpj&r?!2V8^Zn_7ADGL^Jt4%wBL(ahmrsvZ9qh#NWJ>UZpZ4#LI~>jlKzZyBV5jyb z&UyJDwMmQ7lYl6$Q=*vNr~$w<(<#Fv5)rlYW_0RCK5N^>l;6{xp^mgVQMMqy@Sjws zhh*{1@Mw|8>x~&2i`QV*r7koRm4DOQutdPS%m2b~>=$y^pVT-cIGigHy9F){>c$A# zE=gHZlkgYD)9Oj4k?3nnRa6u$&O9fC)d@2FGwoBHRxwlE6p?u7{Rd3H*~s3={w5KF z?O&D@@ojz~nkYLp?Rc(bX*v_bXD!LMibYV=%{Q+ehEkIjHbOBz8st}bWhdi3Gk~P+ zB=SIw)i=$Mk2;9JPqKa;v|F(BF0N8gM_Pj-UYzUHpJ(X{#k`5s3=0dfC|x`bo;h&_ z!H@Lo+&q~bbOPAD;VZoOL#J-o^KZ|ESZR&L*heta`a+m7MCXs<-^P$?gF32f z-&6!L_ql(7VW91Rl9}0VuF@E^<nvHuxP;g#<0%rgd9sOTGtH`K4527js{h-Z+ zA1^e2W+abzCd{TLD8B*H13lQ0B|r~+dOS@%mG2rq0G(E~1KQz^;c%Ac-?Co{?BCip z6Xdbhz+5g&DN(N1lyFVo8Y()!XP(kiHCGAR1UO0V#CNyjl{FNFy{V##mLmapPB7;b z7SdR#Sy_V#j9IsyN%^Z7;1_?)Mc^@BCu-yO%^|B#7QVyD`ta}XZ)ShCP$AJO$B!Qw zFt7-MDkL3d4ZQ5YMF~+#V3B*YyB{bmR%WMDFA|VAa)Ta)Q>6iV3Ka(B7vG0lNHXMc ztwovGDWgb1+(BG~Z7Hcudip%^0g-=;NmOMtb~J*3)py=JbuAKbn)1TPe6rqjL0!$W ztRh{x2Ka*pu~JgJV3GzD5B8743l!D>R_$tdq$yBDKoO<-7KntI!lYd-5&RO~c{Ju= zi|Wf%8pnrnEeJsnLwER2&!1pNo0MYARc`kaBku(S0zI+1%QIw3D&*e8e|?+aX7!rZ zr{T8-#CV`~rPU#g!R$1#qjAh*J^~04|7Q zZw)7#`iQ}x@>UZ+%R#XMyL0LcQos92TVyB$K^~NBI1LnJJn(K2?Bdh;CG#p1ARC}J>a`~Rr zWU(s*t0|0>vanE&yC9fGS~~7~2VXZ*)ZP{{p8#LC#6pKB`_t=&1KY*RlAZ7L!j#YW zmHf{8a^4bTQ|vk9q&FPrIb~_(Q`}L+gi%sP;Tbi`=UUuYNfsL%$k~j(q`qmtUXKjB zNVS3OGH_mfPt@Xe95Wb_%!l(@VX&R6ApNzM zUtFr&vE2Qb|F4KWJibcL(;V1}lG=kPHMvYac;SiW*nMv_A?&pqI?6cK~*ZT^N)zL^k=IAFO6L{4M|tmOz^7y;xWA9-gFV&aq#@ zeuHeLD8IvGf^ET1TtgLhrF0RWTR?I`5jCW#8*JxQo1D!mmV;_@bG`%U zgkxTZg;ww%m2`x%`)C?_;_LSdmBy0k9Ok2>fbth|J(!!Xu`H;qHXp$t=CT^uW*$jl zm4El{jdt~BLZAHGC{lKpZ3V;*fXGxrUb-DF1ke5b{hffz0+dAvcg=g_7W(lLkfVJ4 zMe-@*hHSahBgu>nfJOfOU9-joR4MQqb3e;vw!#pq+y+>BY+AWW@hUL*k-057kru)3 z3cYz_4fydWihCOmrnw;)(NDX|FI@yQ%QSxI_`g7CDXG3o}&G)RptdLQXrwq8fI zUqkxS+v|OiE;B$=P;Ri-U*Pe)uo}(Y*CeNNp;4$m`I+sKtqe_0JjJc9add|1=-pzfkAHL&bT6FiKpydOFhGp?n+YVi#n5kv1k830CB)93uuGmE87uNID z=IPbuo|gyKC^kHHOTP-=B|h_x)IHfDHXC|Tfjqx{>XR6Q(elkRhQAtAgkE)K1u#^S z9mSa7cXug?J_HeZUL8dbvS&MHXPtqLnd8`v!c`6K?ObX2{J9M5OU?`T_F!XSLYxhG zupCy*uUqVw1#IvZFOuqexlk&w?Ctg{l`2!4Y^`}M=OZkY1b zYhD*?iE7%~krqGQsh=K5=gF%+OB+ZLwI^pZuY}@K=o~idn1VPFOmq8o4fRkC2;>r` zh9Sxs=DafMtY?oB&#qdn*3FPa=6&64ajcBXXb?r2f{y5(W74ZaLckaF>oA;S#uVuTuxIG(q;qv!Q0B{ z`i^|+`%GKyOLn#-DRsPdtegQJLJ)vuCX0kNQnAuv@;Y@NrGsmy(FC%734A#!n&*nG z#6tIaG?Lp|^uA8SDS%f0=_Y-(Uf8gAbMGgPPFiH~Qu)Vk$p$kPRtKK3_{*L7HX3No zhn222j|L^h6?PB5E{nU!&k^*vF{=tH#?0w%5izIkY=B3rwlm>8fbq&#+A|z!4R(?l zjU+U8GW_seqi@{yPZvc%5VN}MO=Vmuvp1LQMaq1dlF8<8akVj zIaca`pQeGLM6oBINw+>7VH8zmH{ILKV=ugt{gI_ni2h@&<8~DRUq8cPnPf)n7noJDI2j*V?rbcQ|J0JU&Y8Wzh9Lbua?8=oR}UBp4XIa zbP4$x#mGJd`D0T|G6;m)&sUv83svZI<>;+f;MD5*5xtQ8m2s9v;CbuET-E3cpJg<=dvldQ&g?9_`AO&kTNGo4v9-7i3Y=q%4T>UWq3pTjDKeo99$ zX~qk+h*t#+;@#EpYOS>zTCmLBZ%sq?LZ>j{J#LC-i|!6e$q+2=Q(4Ltv@cXNB+nrac68A<=QDP4-< z<3`r=1KYGIWc}T-i40;P&=+p&{j>4Md!S38%O9$fh}4U$`3hi1*+U<49t z0^(@{zP1D~>U_d-3S{@$|^5q7G;BlT%JhNF-Q zYKWzDj1>@`qjLhY&h*EbKAtYgV9dZ3TkYvITF_~_r0_#dPM!JsM!As)PU7>y-(unz z2()EP{=`sNWuD)Q=RlU=koJ(+MC>MegH%MUIBY~gCp9iET7dQ6F(LfdTK9kDQ*iE0 z)zJK*!wm$~MUf*`AxdXVehJyqQb4hUg+_nyn_L(ed9nAv!W?O)Rwu&?2Cbn~)?BxX z1pT~a|3IXU)%J_vXtnFuXt=$SMQNdJzxMkxc_?%*(AORSI6+($cYt!mL&k|XEiMih zXX|xhm6C z7X6IDH~gHbAHA!kWzR2dTRjd>_F86nhcz>v=Kd(TzQyf1q#$z+6x zsF!O^Cj9X5#b8jFP&KZ!BSP*5CoOrOnIh{P1z+`%nbYQC(tIQZfmtC4enhQYFYR!4_ z%j~m|fP68D%MuS7sGHoHwpyBMk=)^272H)qLc$rKE5H;=#8|FUfcT{GU1#ikI$qQ__-eSe{zc+h#0&YjGW~T|( z(?+fhL+OURsW$v*CCbGA1JoZak?cN?tEy{Tk zYc52uy2luSuz$ha+p6LDH46|-+RCZGTtEhEY8X!?UFrx3@q68Tsc=AAafK zoA*~wTQm3Vdb@&yrIqy8*sQejL!Y(EckkYbV^pS%W^hMx=+@f;A`C4Sx&XI?pp@IM zl+F=sDc9hLr!bPBsv?4u{(af2klc;wdzKEDa(q|r!KSi1-6h08T}*vD$-w)x&nO0$ zk}e7nRg7oJ4jdZ{(-C>@+wlw!d-2^j%FNu>e3Nf1A*NZtS>TSx0)bd-c|f7D#zWz3 z8f1j^@;#NGBYDfU_&9Bp;h=SUr|%bQ?E)F{)v`Q&@uhQig9!|@0R6=8vK=(y9_`GAMNgP86%m`aCoF{d8HD50BHjdO?D_jY`LflQ5VgJO$>u+CI=YP7o) zAhOHc)=43|a`^DnTS}x;j(V2xB2T(z;|UGbDHa}23$aHkZiT(`%mydUk*lpq%2V6O zH0Bi~Tv}L^`yh?o)KR09{dOsAyRS7kZFW6R{VM!==>;lAtVmD>GB*`#x%o;&qWo_K z(Ubf7j~RnCvNY3wEy$N%$Ix4v>-Fwox9#wG1!;7U$<^BW}HuH(&Uphs>Zm&hs?4xP5Wq4 z&2@wel$t%}J9hct*^9B)2s74Q-JpxC}3nj7?| zHKsL=#eE;Qo8*{o_fxu7asWaW6lrV#lyT0DTv~%@OsGg!I`+I%W! znKMH%42rOS^Sj))T^6p+k;PH)(%+Vidw3nGdJjSu{LQ>Q_UT1%6n1th*LX=H7tEcA z8DhUwbtlKYDV3BB1g~EKoutDfUG@htEJK7Mmi4jR%VVbmd2W&UBx~*#Y$=o2^2zzX zFCee%85{)OHz-9rzVDLLCpDzO4*e6yHk9Hc?dr%El!Ow%isX@3CrIhc#IG(ZvB`p= ziS0ufL-e2K!2dyC{=e~(>U}%hpJ(!<5R>hBY$}X-DjFKIvIn4EEn933*hQb98#QHghklYAm-GO%c0gQ^e4J_xhUIP@%LL;tY3r%QM` zu!A?iNk~ZKJelAjA3Uk`}Pz&pEZ z2IL4J2oJq-Tk3iQ7`EE5!xwO_Xdm4lZ+4#jocA|CHZ+M*68?v~_^gWq?cQlEn81@i z%8|MU9$m3QY_;7-CWiC|B(c}JW4wM-2af;b1yB#3082E-qFN{mO2S5K^YL0Jxd<4g zsa)HE84(avmw}ifgTfTw?3bJMKmY!a659d94e-8>R2U$oY8=VIX6_Svmpf_g$d+0G z1PO1EB9egqII1i$)8=(W)j?w42#e?OcsBwxj0Wc|Io3d-+ueemP)y4Wkfq+h2zm@e z6dWa?Qy_d`uB5hubp*RfuU5QFwW$IWV6Bm+k+5}^K_3s+Y zjIjl}cvRzF(SQSL@VRq^`Z=L?sfJ>_0>rZyz}kOy=bC`r-SPBrjK`oZX7QYc9GRS! z7t1E-BrV;Ai1;j;~IT}oMo_L5l1sgSQcFI)Ok z_yJdr@2&MVf4zR2=jQ`HzFs2*+yOKaC>DI^s?U|a``ja7RjW9Um45*m7<1#Mrn+I( zm&B)fTzt&E5O|)!eOOa1tp}&O&H_UeBxYTD{t-k6U%aF{W~RNq+w$kZo^KD^nGR_k zPGNRZ&i~;H>^UCob8?c_!(YS{zQFR!h%_~r1!h>D6Xzw4!|v(A1<5! zecc2e<7d;^B9F-*SE_};4Qa1hy6poi5cz1ZUDY&}?__1-dKhR%A#l+vd*<*j@)~@A z?7e4>32ZMcI<>n1NhTyd9prY>J9U$VQeBjlAmp6Gw5G#P!LOiE~Mbvy4_diT{ zFA9DW9cqQaoiczS?ce<@yU*QW0|}EzXP-GCOCY|BO7FP*kKcy3#W`uTj zum`o(>;f;2X?jea@LViO)tQvu+F6&$m#$l>-kb}oiLbDVU*<8+u}Cr2mUbr#gja_u z@4rOw*e&!*1n6Hm_}!5}j4BM;4BXccQjh{>MLCbVeVpWUbch+F-ABUsb%CsjGFq&Z z8$)Q|byycG`p6fbx`OB?UK$>hW_}9W58RixSI5WU)C0zN*oj2&okqC>q3DlMHTPp| ze$L3XUDz-Fn*1bR5w)J8VT?^hJ|-9VWYt&?R9k2k|9Otr5x<$>%}GQV!Oz%mEF8Se zE`_1<5`mzk-M_8pot}Y&wb|O!93O<7HP0>h(dsVcI-6F^&D{9k}a3sjUY zAdx+L$U*%j<(!qqPX`^xY2ei?V7ALIYoH>ad+CEMBEoBlnL!u8`@?Ah1(gt6KkD=h z5Qxhieop|;`vL3uJ#cg33W5`9Rv0`ixj^Stwh@8J{x=ZNkAPq38Pu5=YtB3(dEl;n7Rdzbin zjkr_z>x`Pnro`sAH)H6e*s=&qquA`DI^L$v7=WfiM8}NOY+g!9<9d1F;=! z;n`qG!*c_s^(I8GMk}zJo6RGh>kaQi9dmXSxxdWs59E+qoxcm8>Sf^+#V2?r0Jm{aZ zS=(xX0{Fbc=Xd)_H!9=@Rc|$uUzYsXFGFDN0x}dX9y+;5mTQjp*^t3Z=j|S87F3!* zKS(8C*R|eue(IBimb{!4f1H!Vcm6k1T`#kH zd+n~}TV8nWA~`(IMLA~z@$TYsm)c-gAwmIE!`{p1jUk>FyI{l8MDieFq-E(gJ@E#; zDc(aE3dhCS9O3J#G=Q*#ZZ;EBE-paj>UqKTrk}@?Jhd;ds!*GdF+fV%q4gBej|!&z z<4n1>RzH{t*zIlmIFdQ9A9h5aPVc=jg=C34!>st(tuZCrw|w(>5N$_ImKvP~zeIAp zoRIh_yI9G8Vapq)8RfCT1oOuoA{2}>Bo86igT0b~R*uofNY;}>P1^yND2*oUb=lis zyV6RO`mY63@XcEyd;f==+ek`f$^pf zAXDS{S)hdWSamaRn1OQJL-tAH$;)@0xc@6KUAnwXt^5pGB94WgtFf-35Y@-gYPwzH zJQTc_McYZSA3s0;H9wj;F@l{C6nmx)+#Djn5<{j1EFeH_*}d75_qe>=H&Qn%(e1zq zqL6?>ofWX$XZ776nez#$cpH;vddH0$7_Oi{{rX9`DO=qDtBlQ(6A7U;;TiFY@&|o2c=is(sOW-OCeMt$}P zfdvR0`hW9IT?OFSwj7%GUiUj-FzYubp^SorlR`E0d-(iXW$CNOAbc0+oV*E}AZq?x zI4hOGI(|Ub$I+xp+1mH)_%O_Nu!19ZS{8d??@+#EfN?i}xzP5T3%Itm8y$0up{)Sn zrPo~&qgi%Bd8*@i3D#w3eSW`me9FdC2t~a@qP|JqVLy4Cm1he6$Yit*z&QxspZ>h z(@Cm?rE->zH$-xdB^_U7DRvI24l_3_NleI|^G(X5p%J{>ShaPHNn^KHNa2Pn&ejMy zqKe6v*Y4j;QIS@#9Bg{HyBa8OdQbetB}^n3!ZUKD<=8x$WihmS*~_&SA_6Ddf1 zgP(1W``8?M0;7B_aE)vQ&Eyb06Rw#pa*p`MOw$_N-c5u!0{fm^uoD>%yD^`>OEKlR z!Z8lzrEb*DtBA1L?MbNyo?BcIBvYkEI85yyJ8rlvEP7GGSys7{Z|NGhe4QrI*t;YQ zi}`O8b9;{;!U{e~0f*rBAi=2CdZPPTq9a_`cDlXCV-VkZ{%NQJ9YchYL9Ub>+0;^j zE{Tuah0IB4`OLW>!>vE!2kBTFcm95>1{@mJ0Z*FOD7X2`#xQhWDk{rb2kDzSsq@V4 zM2c#YeCAR${zZSFdxk30=9QxpclS~uM)Y->be;geEb(JwYEgy`GVc;p{~!EBV$Bl6 zyR31+e8pi{vS@X4tPLjZ?{tyZZiuTK%8*vGWM>b1@2jJ*y1~=7lzBBtNx0H(5>loy z$o@ecgQ>iVds6Kj^u1LfMU4^H0=CU$LqRmmV6!9xhg(vq3{~w0>9i`9Xboh&bO0CP zy(dVqjEBHDFa>YvobiA^rsk>ZRBqxM%pDT;Pk?%5O7ANz51;7R&X-uQIn)24nSFks zzTm^=tFy&b*MRKNj-Zh3_|4SbsxR|?IrRDRWVbz3f}G{#WwNA#@H%JfKXGYp%WN8{ zP~MRW(lm~yq*t(7At;3*Uo0d1ue*K!Tg3O@YzY3JFZn;;DE&Xz<$tcr|GsPf|D#}# zaH{+QzCgg_`TGJ;{$FDQ|9@8kh79oe+)uvJ^-fCU2zj)Qo^^olW_WrcYrjJVe;_`u bHXKaWqZg$w+TsX!1m>NzqSQ|bqk#VfXs!qo literal 0 HcmV?d00001 diff --git a/pr-evidence/3-after-children-toggle-agents-still-expanded.png b/pr-evidence/3-after-children-toggle-agents-still-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..413204d402f1003f4fa17fe78d3ab203f60cde6f GIT binary patch literal 13369 zcmeIZRa9M3w5Caf;O_435Znn4fgk~bTOb6N;1b+51b25296Y$Y2ZFo9!E*w&(mlE! zs%~}P?zif3-xzzGbM{_)%{BjjesdE3PDKt4nHU)k4h~I0URoUv?inE*+;bts=irL7 z@lP)}I7~PNX-Q4@jN?p%l=pMZx3`+c`|XNy67U7c0l4rlqp*6jQUd7d59j`resa3@ zZQdou8Q~>Xs$fbY)a(tIMtf_eUZ{OQ$%%WQ+$?^B{O2^od3Q8*LzMHy(;Cl&$e)V# zn_t$|?T<%33%~Z-g#Z5Zf02c`xh;Pkgvk&cL>u%BI|1{Ryu1`4B^8p%*Z=Xt&jYOK z`AWUL(bW3Sb7hot3=E-(*GCI-HYjS$0-{n6eH^}ApC)t4t2=}^I3@~@f43$lia%b& zVl7K0B1g~^r14ts?(KGHz=Cl3vS~YB1)iJ2SY#=5v zF>#|O^ejv!H`MQLBUCPy+~;aaZf0g?&Ff4Qd~CwX;bhh4_BVJ8G;$G$cC}%?T&$SO zejI}|6T@#7A%QO``A9hQtU5K6o*b>4q^%}BAxrg^Py2G>-K1zl+~(5-3g9*$dvNkz zJD0H~KzG2p+@Aj4s-|)WpSZ1h4B+G?YLG^5+o*_BREMoEd}q4}<*{yYC!hZw5R@?5h zGhFt*oc=Q4ClC2AG`#N_MGm~VT3wl<8jgyNvZecLKSn0r4lQSAYc)?sJzY>dNiJZ7 z(2eu^!rb|^Ds&v7WBisD7L7Jbm;9agW49f*?OvzNp;!E??py7*t8d69kyJ&maTQU- zd|?*@VUQnef{E_!|t-#I&@ClB$-&G7#_^ zI`!6raa0&$cR9&$;5JUWx@m5%>lJR2Qp~TG8*S4?JT5VO&tIq#s=cNvBumk@*y<@2 zT*lz_LF1?;?CdsXtamxZDePwGG(^|=FjwuKMOO8qxPRO3SHl6t)14f2cg(*1 zdVaOti@*inTdjAk>^;lzVfN!y@$h$+r<J%S%+9V3X3koU zsw4wn(M^ltN^QL6=4%6oKBF5tb_ed>=KI(4@PPb>YNpVa+tc5X1RT55gnb~Z7_>RU zf4rj*C7i4z4`> znKjdk(duNXw&8hN+=5olz8OIoRwY<*dH%3+fz8e1_xH$1m_N-o6MQksE(- zSp9le%EL+1nrDX@6r|?sgPBmVsdlg1C)mW(6|8XR-wMAR%@d$C*O-i_2T!Z*%CE05p8uO?JO0bKk`C2RqAD=#QoIb3lA7d1Pwj zOA(R_NSB9>_dlI1LwSzKRR!yq%odiJnh|2=iIcR89IvQGEmQU&wjny6IyKf{JKP;x zT~9m@3X=okvpR$wH+v~D0zIjHMS9vuimN*w4z&$Ef|n=U6^5=bT$BjT<||1=)4vo{ z8MK-@1KE&4F!dTu;d+69Zmu1QPPXELvV46|oZVu2ur|6T^yRSk25gG&-|Gc46(&ya z&+Lbm;(v%p#P5exO9bmoXm&pgzPbI#H>e+9HS|4?2%X_?2#Fmf#$JR68|v#XhqESO zr3S5y+PF1THu%=%fhDj*=0Qj+kz5Ap#a@Aw7CpP9Pi5r`)uL8j#t z`eSUZFzKlDa-JT9))Y7%dV2e+_YsT{*2!98eIsKN70I6Fw(L^F6JxjPJgyzzlhxMB zeSJo0vfMYr_XV*zr^Wvq*SnB;e+Sw6)q%_W%RPCM6(RD;J;LyVqB|bcY!i+!!#+Q| z`(^viXsH?tfp0q_$qbGoNo?-ik}96xmvb;zQpBI`Cr)YZFOsQcjTXvDD-^hm!Ku@( z)7$9s%a6^2t}hxpazE%sh~g=$KJAV5)uJY;@^Eug*EQolw!&a;F+ah%i05ySGGOhJ zAsz2C9WJ58AH|WuR`>OMKTpVACs%Yv^i8{n$;p(4&eF|StF^5Hio<~*he*Y>;(9RM zr-bhQi4g%|=(^iDI>8EQc~t29Aq5siDwrVs5CN?aS@X($A$XyINTN)*QguGl1V@Wy zvqjy)65)}=xron7f-QQx;*7$4zdbZQ-v3N?u43Q#TRSzk^*n2a1gewE-)DbrfLe1R z3I(!8aLNso=E*R@^!N)=vX?evyd8jCeQ^%UmFU!7`ss?K?{pDg@_b9TyV-*Qxs z^SPwwxh+atn8Rb(Sr(5D{oM#-n*gn}DxLT%gr?qhNix67yh!#uznLM-SmCqp+J*{4 z3x-}NGpgcaFDcJ+1dxqzTYHti9*CjqTxCQzBRn!3jIyNgLHa^cEW;s~S{x}xj?pRf zFj-}{jFB#%ZhG8nEWuZCOJv`;j$e@%X-l7P*2uf>>QH5!aeYKP>l<}KsQn_+eJMSq z-zXh6)}4u>Dn<6sMpD9M+ywa?m(K_A)E)M;Zfn2&J&_?{HBjR%0tj%A(sjQLwA3bAw#X&$H&{w ze9YYo@cVtHx`Kq5fcrm)3W?glG9mxFK>#QAB)*lM{v-Y%V``UUwst}9M9e+=q z{p!MdEdk-H4JW>F454%hl^Gfz6qqJNWJ~XjL(FsrZ&TPTX}%;$^ZU#I;|+f%oN;ms zWPcAjy!o#43+k6YH}SSgphXZJdMOuypaINCqtGAb_w4S>s;YV6E4VDrNi}c33g{b4 zT81CAk%_p+wFFy+_kE>%+()Qm{k-+LWrK;RMkiF-@gquFx8n_u?_1LJi4&GneiHA9 z*Gxjuz1|{tk9=sAKjkOt5L9I_QE{yCGkex-bMt#OCZ*7&c#}k1 zOE+MBG5(w=g7+*M-;(2+=`gTsT7`>kng}Vfd!zd1JAMF1Wl~)AaxV3*uO%#5tKIL1 zJoa?zS6^92T~3D+>F@wUuYSL|mZ1E&zW;?q|38f2kWWlvQc}^n`%s{qe{*wl7UMHo zHTlz{l+)!;)I_9$PTL>_M@Z;k%O}Ov$f&E;IgWV&r?*r%qf)mk@9skPK}OSlD1mNi zX~|IGIZFMfsk}f040>P0=nUGuR8#FL{e$pc0TKcd;GE`wR<)jW{7Fz^4}Y4O)fno* z5MQacoB(^;1jKd&k+T@3Vr8~eLo}YSSX2HvP$yK=!9C#xsUC_A=d8t9`FoCNOrOOP1N9mxDAe z^8*xqFmRS#>{B|P9{=by*@-%=OAs_Oqe3y6p_`E$EyfalkI-ykw^L=Zl~|8b!)!4h zUugPT!{@ofc|H5W*VvX5Sw29WzDZ>Yno0Z5uKfL#Xp=ZfYY99H>JtU9SwToRA*e*y zL59qH2sME)J|Cx?@+rba=fysM3hUyzgTDU|ZQwusvYe zC~sqZmG@R#zOaZMg95?)EY|lb-{|o&`N}}(0?4YN`$n*3wofCF@>Bk46Udr4Yy$%b z;={BOM>HYVroG=EJx@=eRF3wm>A7-k@tZ|6Mou0cX!BlHzG~a$FpKT4hTL;R%kbEv zh3et~R1&_ZL&rlS*d_sCgWuDwUomD`llz6KN|2)&qTl2F-o=<*$Nlc^SbDyC1e)(U zS&iicIl)e>8B^-zgy0^jrvq?N^l#IoMO7d9J>-L1)W(4a7A44tVm1KsH@gPysjJ_- z4WxkOfGujM*&B#2`uOR__wU10K>;e6P=i!3^4inmEfKmypxfz+2XI&h9y{M>G5wn_ ziN>)pez;j^Z2HP}8Q3h=ji4*)dj2q%-M^cwNz@u5e&_`GKuLzWl-Vcb{`Np8S zt75-cV{-EAN9Uj4XDcb5W4sH-=7fPP+EvO%)p||r-Od-12Nj6ec+HeWh&jL_vUlM2 z`;Y&flA~yr-(k~hgq=M-+E2vxPPV$)Grw#1{6cV?{V>F29dw=<*S+4 zCta>H8$hQWD+MJNum-Y9(^>4wGK9oQg*3kgJR9Tz5%@n@0CdYF?i)AL3LZ-vHY05a zbltMd29nBe{gs_k!QFnQ=xw=XX`Rc#G>IL=zjj`Rzyv9hYx*xU7G84?7lFIJfcq8g zL@cT|@N>U`8@w}M&j=g|$sHUVgzRN`$=fVcWeVrdgu888Zfu*gx{2quo-{5qPQvp! zITDnRWRC7QZXC;Z9oO)OZEhmFJQ@o8#N52U+lqa9Sm+c&xZFm{V=B0Je|&+I91wKQ zoX%??fq3zu~uE6|oS5=rH!elV7R~j!LSCC@AKeZLAx3o%~>Dq{WXaM2VUJCe^`x9|HO8 zE|Z|k0OLP|-gZ8bl?OZNBa5cP1fpF&!Pw_FKhIa(7mNQ5pLQtJ@y&@`C{YWn z{aFtuSEZE9j)*jKScNY4RSii`5r=D%lGX(q0!h(wGR;uP71Lo8ZHvh1MPm+ph1Rl@ zq?dwT>rboC`Ded9h0K!&U6kpL0m$bD0)_X_GJ+~K0dZn8izlO}YaqwY|!6)xKMreyxP<+j*D7uRYy?eAru_< zRLiVfE%)zJwPsT2|BZ8^T*>5h9 zuR%H@n7(LT`+GW?%9HQ6WEUJM0%n`1cRUvulM)$yZlbuh57K3JQ*w zn>a|#%)0{MjeM@(8yb=)1=$n;VD#~Fdx&%~_f4owB>r!Q9<&c1K7gc?ChEmU{SS`- z`C{Ay**!gy=6h=D5)e<%>%(@SE?E>Qt#8x$fF9F70SSsB7ySi@2DzvwEjEdKnCUnD zNX=ZS;0~WZo7RBrd^%pN{m%Tp>_fdpjoU(%!9N8Oa9Dq@ZNTgI2qP1AUC;W5+(1>| z41PNvt!!jsVq$!p9F)AEc8)4(v|o#n5=b&g=eMWVtG5^n(3yjEK0X``DGZsDh`66e z^RGfo`yxP9>Mj+u*kFb8s3s5`OY7OUqshuwVc7m~ycty6e(xUa}HZD2>rO%sqftabpcHf;B7UR*J`IsGl{v@PGp zz0w(h$6^{YoXE5#Rb<>7;BL&w$;&$@L-YApgB7O6xNQ*4(JReT^_`-0yKX8N3_8KI z2Y5mxq7cK&BSRjafY?=qIQ_^VIewpqD{yvczP=ILkU9Thy})fm%xMhgRm;4e3rbB; z4ibs`iqzBGwqQwE=&)UdAa!>Ky_g8?nl}m4r?s{rSf#9IwV|Z=Jwg){Mu|Bw#6i!n zK)n%bqWZY8rJmBX9@T8Cg^tgzKX?g8{Asz7jRk?gzy%9RhnN^DEXriJnk zT}=3We{sMyAv2D`(T0gLP!Zl2^3S4x)$6dKOO|9|*4B8Ef2)u$5621m=}Jx#A z#BMO0cmT||?>iP7HQ2j;tQ?b=X*0Tb+U|HQ)y_ET^)^<>M)&)2t!Q}1_B)A09mR&Y zAV#*J-~x_H_5zFzHO=LFmYI(S7 z)Io`>Irp_oTfZBZC1m{bs0W|4H*xhoh0I9RmM11BC(B#W%{1=q?t;*MtzT%WU2Psy zQ~W``IelT0(<<{%ZDzd{FNKD>dPW2?_B2qHo1}>_EE}y3afZ11& zqbR%3IElhhatYsMFgP@HFjr2%|2&vOA5sHMJ6Ab@!8ZYqO4=CFP)wyCA=}o5u+KrYe>@{=Q7p5lf`G0q`p`QHaAh2o-}exk^U=VUV&y zdmYqpPy71NsKFXtFHlSI@)U13E}I=`tBg9cT7P;7CP$|A2RpC_-2%j<{{9sGHreS@ zkEqv8&08LITjK0-39khCtyU}^RBg#IqR&?MhjZm#zERTSKR|s1o_9uJcRX_>T!7=P zqM267JAasFRR1mZS8R3fw(VxukByJ2{Wr-o=hyAiLxO4ml%hZ644qN1EMH3(P*mnzu7vzMM#}UHkB~ zgRHR)xJ45>Hg8(8FJ_`Y&zE^z&uP0I%}2)$wt~NWP zNCZaS7oa>nV7wyKCT8o;QAyrBEGdhOLW)8R+W9 zUjK~92OdLKgOk)*5NcH3gXuz)N;%P?QCWeKFK|5wJ;*W}_$?MDw1wzv59jeO@?)LGgPk!k8OFk@ zhO@q6FDeb0=jdpok6{NzvS2G^yvJ{A zLY#7qiMgWC?9uea&0)5OiiDe^35DpINj?tG&&i^wcsp!tGVZlzWneQ3vYY(}1PSe` zBaN`|vE5!TjvNGBM|~O)YB4|Sy(E4z8`pczr2iKI0f>b9XpIk*c%O+BaSSO>I z0Mn<8-nzfYIGU~Md4<>ilBz%@Q|RQX>y9%rKZDDxe^O%#BZMWa?*KXWgC?9^*)rmQ zcs`@2gN{lHnN6g#kEjs_p^ngN{O_ZZ8ueWYfv=+QX_@p(QMUV^QpQsv^-nTW9757# z&=e)0<4wiSk7bl>2snK8K)}En6q`RzD2X<78eP7 z{4+e1T|)s(^e1U#vc%7mYM)3eh96%HEVL5ye(r@&_~s%lqwVU9s?I4bprpgVuNkUJ zR$uaUn888O`Q7uhUX67L=D?*~(aZ>aYtDyvl;l>%IB?LT)oje5!9tzUd>%>m6!x-w z<%rEwND%UF)pK6`pMV>&OX#Ghz|&1AJws`;uc=bIF14LDa8zl zW-puL7Tu(pP75UlyJ^OC-g%X=u;~jbHPxhRrVO!d8{*^%^=D;8I6TM@7=%&T%3&@} zh$K}kh0GPu;h1q2S$RU&B8n^yJVj&p0I`V0bF1YhdkW33jnV97@6MyKuu%x3?5>QF zUB))G-hN^yj-G?p#V9|Wk0`-zO3-*266xA=mrsYCd8YVL$JtbNPpXLds{Q|B0q-V?T84d0hu&^y>gKU*U8KJBOw z&K9}siY&d3K_C(ndmCXLPo1NUhZ`{$9X4KvaFn~Ety@pYkR`ibB8BE7Nyot3&BCxF zjl~=5d>?!oJvg)jX~OAE^x2ewpG+aei()+F zigw9!)TcdlVWT&nM@=uU!sqTX;O(qQi|4_xs)~j45=ZpM`m5eldx9qRY^oSwMjv zu8&$L;nfov*=TPqagjpENCchCL2)!S0or4H_N&U9pe@LL8BVmuY5~`Kvpf1s<@ip>Pt4KNZC5zD{2C@=y@n)z`^Sy`JO|_Sh7rCF!IhO@r8tR3y-=aI1 zG!PXpQcY$zIBhv%Z(PEQe*!JEG(qR0WkZO=27sq~jrH~QfhS)nUt3}B&%V^Qk&OS~ zcD$%oz{S`F(_aTHfzx7?u)3iZAT%b8A_!vul59I$YOUthH(V;fBcN5bPde`Amg(HQ z_hx~yw6f+s$CTucW-*hf*xigKnff+*qiQ*+UqSoUZn<&Hw!Zcs&_fQiB@h@!TR!bX zd3$JzOD^QXNQx_LL7I>fUz#5n)oN;BKELnO0qQ)^fA1F*p)Q<+Nj8GcIx49Er$yuW zdpUizT)Z%W#*+K3dQdKZUIm{6_Sxld7KuVE%mLdcm5W29h1(EIVVjj9_Ae}mw832#s>RoRJdZWNJZpWC)a()yP z6ucU_Iav<17W2INbg9AJ=>h65MS ziaprr&?Uv(25Rjy?>*rZtPqooJ>4Il)rjeU2~R(gdUA&cgCKd^4!-C{ZSJ)wO4Fba z|LI?lyE~RC$49$RZPfY0dY)YevJRdznwZyVm1D~X+`G8}w6wKL)#*x5cVo2j`fBdk z*;hc3UIK?#4ouP>aC*rc6k_v&h?3V}t|X6xYPT8r`3|7h$i?GOnmGp)&Q9Q~3lssQ z+EEmo0(tK&VAsh=-mNxbWn(tU=CxRO1CLuY7x1_-nt}LAN=hiEU;AD@(TR#?j+Cn9 zziL{ZLy#8%6$q2_=}Pm8Mq2Q)L8UG)L1F~#im^IuQLn%ep~Egtjq6=J3EZ7Lb!)fa z0fn7fS2X5TIG_{>m!murFP#ez&tgJX;wMqAW@8foaz@l5gEq_FlmuzPB70CuTuuI zOqABu7jSEv3==Qro-S*TOQIM$)xUA4gb*P#O|tlPT9f;E)5aTYWQ?y*)~T?u6Z0mY z%O_U7-}$lRc`?LWCG zOEE-BbHQiM&rqAmG?D!ORSuJM*D;bMry{iP z{8XbC{k=20HahnPNG*idVl$8Kot!)ZVzAeX6eL1(WC*Qvln@6TqUzVTq)IIA9hQ-i zRY+c*-M|)O>}U{|Qz<3&)k{E49eP71B91QLla|%Rxsit!+BNJb6E)`EJT(>7lZ>g! zg!q=&`4~~|%a?w94Njj27Z~F#q5C_@#<0V_wxE=~&7#*Aj)BH-hHu=N;I(mbTNumO z`{4f$-x5TiL=+zRy)?7K2}MX zv}>^Q^uBT&m^l%z8RIsnxxqC5nfZkqC9wSGy1$y!=7xBcL+{^6N<^0kYe_S%GOmqq zLjtE~FJEvCFp#G5?lznD(|siFcZmEJ9@sS(40Tpx9X3(nlAEokv`%%$)tRvWY3v{0 zwHS#<%%?1Uy&0;77O1`3UGa)*m~4f>2KUSQbD~R!*pcrnau$1^1X=d*<8h)24=n_g zzn!aXwGp$>7qU(t?(C5GU7iSO5wNrPq3d44sLZGRqtjzg{Bk@Cc;39>;poPvqH3Y9 zqvFvT{A>9ks(q^1VPo(2V3*DyCBap3N|$1wL@}XIqaeX5I$Ed~Ci@L2-i%Gh?m}~8 z$eUK@#_?Ie~1T3#@?iRtupjPVA){p*nJj2burD4#AYl1|)bDUtzPJ_^8NSl^& z-!$_|s1p(p;(m(gWzl$`u4EUkJGws(zUEEO8fAudBL#9rzpbb$AcA5}9Kix4OL`N+8{!A7pWv_IFIp8je>E~#?a zuNiqSq(h88u5*SL5@*xO=)RrjC)tm!m};Jb7ZDoO{QT^_oYHBkO^y11tYo7+E&4c> z>oVJ)2y+MKAWc-hd&nI8Hm=_Aq5wJdWNE0srHy<9}=){%^-4CF%^1 zuzPW!iX<`-wroK(Oj1s2y87-N(}i>d6_YY5iAmC%izrNU1AKXNOg&855BY^D7Zkx^ z;k9x49IS=R$Xo1G3e|8}mgGs1X#R2p@f{w}-C(gPtjIh^4*0O1e~59Fxbe%8mi8t8 znm5kW+3oSt)+(t4jDvF8|8%j%`37@q=>nFsZ5lU(=_9E-?y&`erAs}4D1eZu&R5T!Mc2jq01OaydJ?{t0s-1w~fBdnk>u)Hq|;$^(z z4ZO2poP-v$00ZfI1Hd|R){}2*%E$}=g07(wk{mvWJYd`eNg;!_c%MZIcmv%!b0lXH z&8rwy&^%1V#q%@<@S#7N1h~3dg>UrNXKUh*QShl3uwnqKMt{`R*MAt(Yj*mW0ZXBw z#q~py4wL3=l;>J&+Pb$FGF=B9Z97SO7gj{id$y*Pfb67i-=Lr|H;`S+8QXF_uO}Bp z*o9i=cD9N}{5ovw7ndZnri^C{Eq3pvE$-DN=tA684;JRPF6cuY4oZu&BA#~p;h0mu ze}7-#As&i*c0Fsd@~7?U?<=ft=I!%+m5zOW&BVpdZ{In+A>yOuv+K(m6k}8|&*^3a@9W;Oh0R!a4J|$)UTY zYaylRo5!-mV`tWCiCvDlcWH>T=Yzah>4FXLvKB`ElEMw79JhKoC5ZAA5hu)DK~XB7 zAsS@%YTyHt{1qrK0%&O+Z{pUu^&C4I%J4*}s3+YB{BP4(W!-D_B6Nxry!#uT_t54@ZSKTGQIr( literal 0 HcmV?d00001 diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx new file mode 100644 index 00000000000..cc3a83d0a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearWorktreeAgentExpansionStateForTests, + getWorktreeAgentExpansionCountForTests, + MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS, + seedWorktreeAgentExpansionStateForTests, + useWorktreeAgentExpansionState +} from './worktree-card-agents-expansion-state' + +let mockAgents: unknown[] = [] + +function mockAgent(paneKey: string, prompt: string): unknown { + return { + paneKey, + tab: { id: paneKey.split(':')[0] }, + agentType: 'codex', + rowSource: undefined, + state: 'done', + startedAt: 1000, + entry: { + prompt, + lastAssistantMessage: undefined, + state: 'done', + stateStartedAt: 1000, + stateHistory: [], + orchestration: undefined + }, + lineage: undefined + } +} + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + agentActivityDisplayMode: 'compact', + acknowledgedAgentsByPaneKey: {}, + cacheTimerByKey: {}, + dropAgentStatus: vi.fn(), + dismissRetainedAgent: vi.fn(), + agentSendPopoverTargetMode: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + sendPromptToSidebarAgentTarget: vi.fn(), + settings: { promptCacheTimerEnabled: false, promptCacheTtlMs: 60_000 } + }) +})) + +vi.mock('./useWorktreeAgentRows', () => ({ + useWorktreeAgentRows: vi.fn(() => mockAgents) +})) + +vi.mock('@/components/dashboard/useNow', () => ({ + useNow: vi.fn(() => 2000) +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +const mountedRoots: { root: Root; host: HTMLElement }[] = [] + +async function mountAgents(worktreeId: string): Promise { + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + mountedRoots.push({ root, host }) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + await act(async () => { + root.render() + }) + return host +} + +function summaryButton(host: HTMLElement): HTMLButtonElement { + // The compact multi-agent summary is the only control carrying aria-expanded + // when the agents are flat (no per-agent child disclosure). + const button = host.querySelector('button[aria-expanded]') + if (!button) { + throw new Error('compact agent summary button not found') + } + return button +} + +describe('WorktreeCardAgents inline-list expansion durability', () => { + beforeEach(() => { + clearWorktreeAgentExpansionStateForTests() + mockAgents = [mockAgent('tab-1:1', 'One'), mockAgent('tab-2:2', 'Two')] + }) + + afterEach(async () => { + await act(async () => { + for (const { root, host } of mountedRoots.splice(0)) { + root.unmount() + host.remove() + } + }) + document.body.innerHTML = '' + clearWorktreeAgentExpansionStateForTests() + }) + + it('keeps the compact agent summary expanded across a card remount', async () => { + const host = await mountAgents('wt-remount') + expect(summaryButton(host).getAttribute('aria-expanded')).toBe('false') + + await act(async () => { + summaryButton(host).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(summaryButton(host).getAttribute('aria-expanded')).toBe('true') + + // Simulate the WorktreeCard remount that a virtualizer recycle or a sibling + // child-worktrees toggle triggers: fully unmount, then mount a fresh tree + // for the same worktree. Before the fix this reset the summary to collapsed. + await act(async () => { + const first = mountedRoots.shift()! + first.root.unmount() + first.host.remove() + }) + const remounted = await mountAgents('wt-remount') + + expect(summaryButton(remounted).getAttribute('aria-expanded')).toBe('true') + }) + + it('does not leak expansion between different worktrees', async () => { + const first = await mountAgents('wt-a') + await act(async () => { + summaryButton(first).dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(summaryButton(first).getAttribute('aria-expanded')).toBe('true') + + const second = await mountAgents('wt-b') + expect(summaryButton(second).getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('worktree-card-agents-expansion-state module cache', () => { + beforeEach(() => { + clearWorktreeAgentExpansionStateForTests() + }) + + afterEach(() => { + clearWorktreeAgentExpansionStateForTests() + }) + + it('persists a collapsed lineage parent across a hook remount and toggles independently', async () => { + function Probe({ worktreeId }: { worktreeId: string }) { + const { collapsedLineageParents, toggleLineageParent } = + useWorktreeAgentExpansionState(worktreeId) + return ( + + ) + } + + const host = document.createElement('div') + document.body.append(host) + let root = createRoot(host) + await act(async () => { + root.render() + }) + const read = () => host.querySelector('button')!.getAttribute('data-collapsed') + expect(read()).toBe('false') + + await act(async () => { + host.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(read()).toBe('true') + + // Remount the hook consumer: the collapsed parent must survive. + await act(async () => root.unmount()) + root = createRoot(host) + await act(async () => { + root.render() + }) + expect(read()).toBe('true') + + await act(async () => root.unmount()) + host.remove() + }) + + it('drops default (empty) state and bounds the cache with LRU eviction', () => { + seedWorktreeAgentExpansionStateForTests('wt-default', { + collapsedLineageParents: new Set(), + compactRootListExpanded: false + }) + expect(getWorktreeAgentExpansionCountForTests()).toBe(0) + + for (let i = 0; i < MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS + 25; i++) { + seedWorktreeAgentExpansionStateForTests(`wt-${i}`, { + collapsedLineageParents: new Set(), + compactRootListExpanded: true + }) + } + expect(getWorktreeAgentExpansionCountForTests()).toBe(MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 2a924211874..9e8faa70acd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -24,6 +24,7 @@ import { import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model' import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants' import { revealElementInScrollContainer } from './worktree-sidebar-reveal' +import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = @@ -231,13 +232,24 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [agents] ) const hasLineage = childrenByParentPaneKey.size > 0 - const [collapsedLineageParents, setCollapsedLineageParents] = useState>( - () => new Set() - ) - const [compactRootListExpanded, setCompactRootListExpanded] = useState(false) + // Why: keep disclosure state out of raw local useState so the WorktreeCard + // remount that fires on virtualizer recycle or a sibling child-worktrees + // toggle no longer resets it (which read as one section expanding the other). + const { + collapsedLineageParents, + compactRootListExpanded, + toggleLineageParent: toggleLineageParentState, + toggleCompactRootList + } = useWorktreeAgentExpansionState(worktreeId) + // Why: reveal only on a genuine user collapse→expand within this mount. + // Seeding an already-expanded panel from the durable cache on a remount must + // not re-trigger the reveal scroll, or recycled cards would fight the scroll. + const previousCompactExpandedRef = useRef(compactRootListExpanded) useLayoutEffect(() => { - if (compactRootListExpanded && agentActivityDisplayMode === 'compact') { + const wasExpanded = previousCompactExpandedRef.current + previousCompactExpandedRef.current = compactRootListExpanded + if (!wasExpanded && compactRootListExpanded && agentActivityDisplayMode === 'compact') { dispatchSuppressScrollAdjustment() // Why: defer the reveal scroll out of the expand commit. Running it inline // forces a synchronous sidebar layout that blocks the animation's opening @@ -250,18 +262,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ } return undefined }, [agentActivityDisplayMode, compactRootListExpanded]) - const toggleLineageParent = useCallback((paneKey: string) => { - dispatchSuppressScrollAdjustment() - setCollapsedLineageParents((current) => { - const next = new Set(current) - if (next.has(paneKey)) { - next.delete(paneKey) - } else { - next.add(paneKey) - } - return next - }) - }, []) + const toggleLineageParent = useCallback( + (paneKey: string) => { + dispatchSuppressScrollAdjustment() + toggleLineageParentState(paneKey) + }, + [toggleLineageParentState] + ) const stopBubble = useCallback((e: React.MouseEvent) => { e.stopPropagation() @@ -445,7 +452,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ expanded={compactRootListExpanded} onToggle={() => { dispatchSuppressScrollAdjustment() - setCompactRootListExpanded((expanded) => !expanded) + toggleCompactRootList() }} /> diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx new file mode 100644 index 00000000000..6190a6acd5a --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -0,0 +1,581 @@ +// @vitest-environment happy-dom + +// Regression test for the child-worktrees <-> agent-list expansion coupling: +// in a worktree card that shows BOTH inline agent rows (with orchestration +// lineage) AND a "N children" child-worktrees chip, toggling the child-worktrees +// chip used to reset the agent list's expansion state (it remounts the card). +// It renders the REAL WorktreeCardAgents (not a mock) inside the REAL +// WorktreeList so the remount and the durable-expansion fix are exercised +// end-to-end. The two toggles must stay independent in both directions. +// +// NOTE: unlike the sibling lineage test, this file's useVirtualizer mock HONORS +// the real `getItemKey` (which returns getRenderRowKey(row)). Without that, the +// parent card's virtual-row key would be a static `row-` and the +// item<->lineage-group remount would NOT reproduce (false negative). + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' +import type { + Repo, + TerminalTab, + Worktree, + WorktreeCardProperty, + WorktreeLineage +} from '../../../../shared/types' +import { clearWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mockStore = vi.hoisted(() => ({ + state: {} as Record, + activateWorktreeFromSidebar: vi.fn(), + openModal: vi.fn() +})) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(mockStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { + getState: () => Record + } + useAppStore.getState = () => mockStore.state + return { useAppStore } +}) + +// Why: honor the real getItemKey so each virtual row's React key equals +// getRenderRowKey(row). This is what makes the parent card remount when it +// moves between a standalone 'item' row (key wt:...) and a 'lineage-group' row +// (key lineage-group:...) as the child-worktrees group collapses/expands. +vi.mock('@tanstack/react-virtual', () => ({ + defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => + Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), + measureElement: () => 32, + useVirtualizer: ({ + count, + getItemKey + }: { + count: number + getItemKey?: (index: number) => string | number + }) => ({ + elementsCache: new Map(), + getTotalSize: () => count * 96, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: getItemKey ? getItemKey(index) : `row-${index}`, + start: index * 96 + })), + measureElement: vi.fn(), + scrollToIndex: vi.fn() + }) +})) + +vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', + useVirtualizedScrollAnchor: vi.fn() +})) + +vi.mock('./project-header-drag', () => ({ + useRepoHeaderDrag: () => ({ + state: { draggingRepoId: null, dropIndicatorY: null }, + onHandlePointerDown: vi.fn() + }), + isRepoHeaderActionTarget: () => false +})) + +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null, + usePromptCacheCountdownForPane: () => null +})) + +// NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one. + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +const TAB_ID = 'tabP' +const PANE_ROOT = `${TAB_ID}:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` +const PANE_CHILD = `${TAB_ID}:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb` +const PANE_ROOT_2 = `${TAB_ID}:cccccccc-cccc-4ccc-8ccc-cccccccccccc` + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/tmp/lineage-agent-coupling', + displayName: 'lineage-agent-coupling', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(args: { + id: string + displayName: string + branch: string + sortOrder: number + instanceId: string +}): Worktree { + return { + id: args.id, + instanceId: args.instanceId, + repoId: 'repo-1', + path: `/tmp/lineage-agent-coupling/${args.id}`, + displayName: args.displayName, + branch: args.branch, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: args.sortOrder, + lastActivityAt: args.sortOrder + } +} + +function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId!, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + createdAt: 1 + } +} + +function makeAgentEntry( + paneKey: string, + prompt: string, + orchestration?: AgentStatusOrchestrationContext +): AgentStatusEntry { + const now = Date.now() + return { + state: 'working', + prompt, + updatedAt: now, + stateStartedAt: now, + agentType: 'claude', + paneKey, + worktreeId: 'parent', + stateHistory: [], + ...(orchestration ? { orchestration } : {}) + } +} + +function makeParentTab(): TerminalTab { + return { + id: TAB_ID, + ptyId: null, + worktreeId: 'parent', + title: 'Parent Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function setAgentLineageState(options: { + agentActivityDisplayMode: 'compact' | 'full' + secondRootAgent?: boolean + collapsedGroups?: Set +}): void { + const repo = makeRepo() + const parent = makeWorktree({ + id: 'parent', + instanceId: 'parent-instance', + displayName: 'lineage parent', + branch: 'parent-branch', + sortOrder: 20 + }) + const child = makeWorktree({ + id: 'child', + instanceId: 'child-instance', + displayName: 'lineage child', + branch: 'child-branch', + sortOrder: 10 + }) + const agentStatusByPaneKey: Record = { + [PANE_ROOT]: makeAgentEntry(PANE_ROOT, 'PARENT_AGENT_PROMPT'), + [PANE_CHILD]: makeAgentEntry(PANE_CHILD, 'CHILD_AGENT_PROMPT', { + taskId: 't1', + dispatchId: 'd1', + parentPaneKey: PANE_ROOT + }) + } + if (options.secondRootAgent) { + agentStatusByPaneKey[PANE_ROOT_2] = makeAgentEntry(PANE_ROOT_2, 'SECOND_ROOT_PROMPT') + } + + mockStore.state = { + // Reactive-ish toggle: the chip's onClick eventually calls + // toggleCollapsedGroup; mutate the live Set so the next manual re-render + // (store isn't reactive in this harness) reflects the user's toggle. + collapsedGroups: options.collapsedGroups ?? new Set(), + toggleCollapsedGroup: vi.fn((key: string) => { + // Why: mirror the real store's IMMUTABLE update — a fresh Set reference is + // what makes WorktreeList's `rows` useMemo (keyed on the Set identity) + // recompute buildRenderableRows and flip the parent's render-row. + const set = new Set(mockStore.state.collapsedGroups as Set) + if (set.has(key)) { + set.delete(key) + } else { + set.add(key) + } + mockStore.state.collapsedGroups = set + }), + + // ── worktree list plumbing ── + activeModal: '', + activeView: 'terminal', + activeWorktreeId: null, + activeWorkspaceKey: null, + activeTabId: null, + activeTabType: null, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + clearPendingRevealWorktreeId: vi.fn(), + deleteStateByWorktreeId: {}, + detectedWorktreesByRepo: {}, + fetchFolderWorkspacePathStatus: vi.fn(), + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + filterRepoIds: [], + folderWorkspaces: [], + folderWorkspacePathStatuses: {}, + getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), + getFreshFolderWorkspacePathStatus: () => null, + gitConflictOperationByWorktree: {}, + groupBy: 'none', + hideDefaultBranchWorkspace: false, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + linearStatus: null, + migrationUnsupportedByPtyId: {}, + openModal: mockStore.openModal, + openSettingsPage: vi.fn(), + openSettingsTarget: null, + openTaskPage: vi.fn(), + pendingRevealWorktree: null, + prCache: {}, + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + reorderRepos: vi.fn(), + reportVisibleGitHubPRRefreshCandidates: vi.fn(), + repos: [repo], + retainedAgentsByPaneKey: {}, + revealWorktreeInSidebar: vi.fn(), + runtimePaneTitlesByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + setFilterRepoIds: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + setRenamingWorktreeId: vi.fn(), + setShowSleepingWorkspaces: vi.fn(), + setSortBy: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + settings: null, + showSleepingWorkspaces: true, + sortBy: 'manual', + sortEpoch: 0, + sshConnectedGeneration: 0, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + terminalLayoutsByTabId: {}, + updateRepo: vi.fn(), + updateWorktreeMeta: vi.fn(), + updateWorktreesMeta: vi.fn(), + workspaceHostScope: 'all', + workspacePortScan: null, + workspaceStatuses: [], + worktreeCardProperties: [ + 'status', + 'pr', + 'comment', + 'inline-agents' + ] satisfies WorktreeCardProperty[], + worktreeLineageById: { [child.id]: makeLineage(child, parent) }, + worktreesByRepo: { [repo.id]: [parent, child] }, + + // ── agent-list specific state (real WorktreeCardAgents deps) ── + agentActivityDisplayMode: options.agentActivityDisplayMode, + agentSendPopoverTargetMode: null, + acknowledgedAgentsByPaneKey: {}, + dropAgentStatus: vi.fn(), + dismissRetainedAgent: vi.fn(), + sendPromptToSidebarAgentTarget: vi.fn(), + tabsByWorktree: { parent: [makeParentTab()] }, + agentStatusByPaneKey + } +} + +const mountedRoots: Root[] = [] + +async function renderWorktreeList(): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render( + + ) + }) + return { container, root } +} + +// Why: the mocked store isn't reactive. Re-render with FRESH ref props so +// React.memo(WorktreeList) doesn't bail out, letting a collapsedGroups mutation +// flow into buildRenderableRows (and thus flip the parent's virtual-row key). +async function rerender(root: Root): Promise { + await act(async () => { + root.render( + + ) + }) +} + +function findButtonByAriaLabel(container: HTMLElement, pattern: RegExp): HTMLButtonElement | null { + return ( + [...container.querySelectorAll('button[aria-expanded]')].find((button) => + pattern.test(button.getAttribute('aria-label') ?? '') + ) ?? null + ) +} + +function childWorktreeChip(container: HTMLElement): HTMLButtonElement | null { + return findButtonByAriaLabel(container, /child workspace/i) +} + +function agentChildDisclosure(container: HTMLElement): HTMLButtonElement | null { + return findButtonByAriaLabel(container, /child agent/i) +} + +function compactAgentSummary(container: HTMLElement): HTMLButtonElement | null { + return ( + [...container.querySelectorAll('button.compact-agent-summary-button')][0] ?? + null + ) +} + +function childWorktreeCardPresent(container: HTMLElement): boolean { + return container.querySelector('[id="worktree-list-option-all%3Achild"]') !== null +} + +function parentVirtualRowKey(container: HTMLElement): string | null { + return ( + container + .querySelector('[data-worktree-id="parent"]') + ?.closest('[data-worktree-virtual-row]') + ?.getAttribute('data-worktree-virtual-row-key') ?? null + ) +} + +async function click(el: Element): Promise { + await act(async () => { + el.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () => { + beforeAll(async () => { + WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + // Expansion now persists in a module-level cache that survives remounts, so + // it must be reset between cases or one test's collapse leaks into the next. + clearWorktreeAgentExpansionStateForTests() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + clearWorktreeAgentExpansionStateForTests() + }) + + it('[full mode] both toggles render independently at mount', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container } = await renderWorktreeList() + + // Sanity: real agent rows rendered (parent + its lineage child agent). + expect(container.textContent).toContain('PARENT_AGENT_PROMPT') + expect(container.textContent).toContain('CHILD_AGENT_PROMPT') + + // Both controls exist and are independent DOM elements. + expect(childWorktreeChip(container)).not.toBeNull() + expect(agentChildDisclosure(container)).not.toBeNull() + + // Defaults: child worktrees expanded (chip aria-expanded true) AND child + // agents expanded (disclosure aria-expanded true). + expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + }) + + it('[full mode] toggling AGENTS does NOT change the child-worktrees chip (agents -> children uncoupled)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container } = await renderWorktreeList() + + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + + // Collapse the child AGENTS via the disclosure chevron. + await click(agentChildDisclosure(container)!) + + // Agent children now collapsed (local state)... + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull() + // ...but the child WORKTREES are untouched: chip still expanded, child card present. + expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') + expect(childWorktreeCardPresent(container)).toBe(true) + // And collapsedGroups was never mutated by an agent toggle. + expect((mockStore.state.collapsedGroups as Set).size).toBe(0) + }) + + it('[full mode] toggling CHILD WORKTREES still remounts the card but PRESERVES agent expansion (regression)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container, root } = await renderWorktreeList() + + // Parent starts inside a lineage-group render row (children expanded). + expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent') + + // User collapses the child AGENTS. + await click(agentChildDisclosure(container)!) + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + + // User now clicks the CHILD-WORKTREES chip to collapse child worktrees. + await click(childWorktreeChip(container)!) + expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + // Store isn't reactive; flush the collapsedGroups change into a re-render. + await rerender(root) + + // The remount still happens: the parent moved to a standalone 'item' render + // row with a DIFFERENT React key, and the child card is gone. + expect(parentVirtualRowKey(container)).toBe('wt:all:parent') + expect(childWorktreeCardPresent(container)).toBe(false) + + // FIXED: the card remounted, but the durable expansion cache means the + // child AGENTS the user collapsed stay collapsed — the child-worktrees + // toggle no longer bleeds into the agent list. + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull() + }) + + it('[full mode] CONTROL: a re-render that does NOT change collapsedGroups preserves agent state (isolates the remount)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'full' }) + const { container, root } = await renderWorktreeList() + + await click(agentChildDisclosure(container)!) + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + + // Re-render WITHOUT touching collapsedGroups: the parent's virtual-row key + // stays 'lineage-group:all:lineage:parent', so there is no remount. + await rerender(root) + + expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent') + // Agent collapse survives => proves it is the KEY change (remount), not the + // re-render itself, that resets the agent expansion. + expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') + }) + + it('[compact mode] toggling CHILD WORKTREES preserves the compact agent summary expansion (regression)', async () => { + setAgentLineageState({ agentActivityDisplayMode: 'compact', secondRootAgent: true }) + const { container, root } = await renderWorktreeList() + + // Two root agents => compact summary pill is shown, collapsed by default. + const summary = compactAgentSummary(container) + expect(summary).not.toBeNull() + expect(summary!.getAttribute('aria-expanded')).toBe('false') + + // User expands the compact agent summary. + await click(summary!) + expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true') + + // User toggles child worktrees. + await click(childWorktreeChip(container)!) + expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + await rerender(root) + + // FIXED: the card remounts (child card gone), but the expanded "N agents" + // summary is restored from the durable cache instead of collapsing. + expect(childWorktreeCardPresent(container)).toBe(false) + expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts new file mode 100644 index 00000000000..b357cb21956 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' + +/** + * Expand/collapse state for a WorktreeCard's inline agent list: + * - `collapsedLineageParents`: agent-lineage parent paneKeys the user folded. + * - `compactRootListExpanded`: whether the "N agents" compact summary is open. + */ +export type WorktreeAgentExpansionState = { + collapsedLineageParents: ReadonlySet + compactRootListExpanded: boolean +} + +const EMPTY_COLLAPSED_PARENTS: ReadonlySet = new Set() + +const DEFAULT_EXPANSION_STATE: WorktreeAgentExpansionState = { + collapsedLineageParents: EMPTY_COLLAPSED_PARENTS, + compactRootListExpanded: false +} + +// Why: the inline agent list's expand/collapse must outlive the WorktreeCard +// remount that fires (a) when the sidebar virtualizer recycles the row on +// scroll and (b) when the sibling child-worktrees section toggles — that toggle +// flips the card between an `item` and a `lineage-group` render row, changing +// its virtual-row key and forcing a fresh mount. Holding this in plain local +// useState reset it on every such remount, so folding one section visibly +// re-expanded the other. Renderer-only and LRU-bounded: it resets on reload, +// matching the ephemeral live-agent lineage it tracks, and never grows without +// bound in a long-lived renderer. +export const MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS = 512 +const expansionByWorktreeId = new Map() + +function trimPersistedExpansions(): void { + while (expansionByWorktreeId.size > MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) { + const oldest = expansionByWorktreeId.keys().next().value + if (oldest === undefined) { + break + } + expansionByWorktreeId.delete(oldest) + } +} + +function readExpansionState(worktreeId: string): WorktreeAgentExpansionState { + return expansionByWorktreeId.get(worktreeId) ?? DEFAULT_EXPANSION_STATE +} + +function persistExpansionState(worktreeId: string, state: WorktreeAgentExpansionState): void { + // Re-insert to refresh LRU order; drop entries that carry no non-default + // state so idle worktrees never occupy a slot. + expansionByWorktreeId.delete(worktreeId) + if (state.compactRootListExpanded || state.collapsedLineageParents.size > 0) { + expansionByWorktreeId.set(worktreeId, state) + trimPersistedExpansions() + } +} + +export type WorktreeAgentExpansionControls = { + collapsedLineageParents: ReadonlySet + compactRootListExpanded: boolean + /** Fold/unfold a single agent-lineage parent by its paneKey. */ + toggleLineageParent: (paneKey: string) => void + /** Open/close the compact multi-agent summary panel. */ + toggleCompactRootList: () => void +} + +/** + * Remount-durable expand/collapse state for one worktree card's inline agent + * list. Reads seed from a module-level, session-scoped cache so a virtualizer + * recycle or a sibling child-worktrees toggle no longer wipes the user's + * disclosure choices. Independent per worktree id. + */ +export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgentExpansionControls { + const [rendered, setRendered] = useState<{ + worktreeId: string + state: WorktreeAgentExpansionState + }>(() => ({ worktreeId, state: readExpansionState(worktreeId) })) + + // Why: a memoized body can be handed a new worktreeId without remounting; + // fall back to the cache so we never show a stale card's disclosure. + const current = + rendered.worktreeId === worktreeId ? rendered.state : readExpansionState(worktreeId) + + const commit = useCallback( + (next: WorktreeAgentExpansionState) => { + persistExpansionState(worktreeId, next) + setRendered({ worktreeId, state: next }) + }, + [worktreeId] + ) + + const toggleLineageParent = useCallback( + (paneKey: string) => { + const base = readExpansionState(worktreeId) + const nextParents = new Set(base.collapsedLineageParents) + if (nextParents.has(paneKey)) { + nextParents.delete(paneKey) + } else { + nextParents.add(paneKey) + } + commit({ ...base, collapsedLineageParents: nextParents }) + }, + [commit, worktreeId] + ) + + const toggleCompactRootList = useCallback(() => { + const base = readExpansionState(worktreeId) + commit({ ...base, compactRootListExpanded: !base.compactRootListExpanded }) + }, [commit, worktreeId]) + + return { + collapsedLineageParents: current.collapsedLineageParents, + compactRootListExpanded: current.compactRootListExpanded, + toggleLineageParent, + toggleCompactRootList + } +} + +export function clearWorktreeAgentExpansionStateForTests(): void { + expansionByWorktreeId.clear() +} + +export function getWorktreeAgentExpansionCountForTests(): number { + return expansionByWorktreeId.size +} + +export function seedWorktreeAgentExpansionStateForTests( + worktreeId: string, + state: WorktreeAgentExpansionState +): void { + persistExpansionState(worktreeId, state) +} diff --git a/tests/e2e/worktree-lineage-agent-expansion.spec.ts b/tests/e2e/worktree-lineage-agent-expansion.spec.ts new file mode 100644 index 00000000000..6662d7eafe0 --- /dev/null +++ b/tests/e2e/worktree-lineage-agent-expansion.spec.ts @@ -0,0 +1,112 @@ +import type { Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { seedLineageScenario } from './worktree-lineage-state' +import { worktreeRow } from './worktree-row-locators' + +// Set ORCA_CAPTURE_EVIDENCE=1 to also write before/after screenshots to +// pr-evidence/. Off by default so CI just runs the behavioral assertions. +const CAPTURE_EVIDENCE = process.env.ORCA_CAPTURE_EVIDENCE === '1' +const SHOT_DIR = resolve(process.cwd(), 'pr-evidence') + +async function captureSidebar(page: Page, name: string): Promise { + if (!CAPTURE_EVIDENCE) { + return + } + mkdirSync(SHOT_DIR, { recursive: true }) + await sidebar(page).screenshot({ path: resolve(SHOT_DIR, name) }) +} + +// Seed two independent root agents on the parent worktree so its card shows the +// compact "2 agents" summary pill alongside the "N child workspaces" chip. +async function seedTwoParentAgents(page: Page, worktreeId: string): Promise { + await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.worktreeCardProperties.includes('inline-agents')) { + state.toggleWorktreeCardProperty('inline-agents') + } + while ((store.getState().tabsByWorktree[worktreeId] ?? []).length < 2) { + store.getState().createTab(worktreeId) + } + const tabs = (store.getState().tabsByWorktree[worktreeId] ?? []).slice(0, 2) + const now = Date.now() + const specs = [ + { state: 'working' as const, prompt: 'Refactor auth middleware', agentType: 'claude' }, + { state: 'done' as const, prompt: 'Write unit tests for parser', agentType: 'codex' } + ] + tabs.forEach((tab, index) => { + const spec = specs[index]! + const leafId = crypto.randomUUID() + store + .getState() + .setAgentStatus( + `${tab.id}:${leafId}`, + { state: spec.state, prompt: spec.prompt, agentType: spec.agentType }, + spec.agentType, + { updatedAt: now, stateStartedAt: now } + ) + }) + }, worktreeId) +} + +function sidebar(page: Page) { + return page.locator('[data-worktree-sidebar]').first() +} + +function compactSummary(page: Page, parentId: string) { + return worktreeRow(page, parentId).locator('button.compact-agent-summary-button').first() +} + +function childWorkspacesChip(page: Page, parentId: string) { + return worktreeRow(page, parentId) + .getByRole('button', { name: /child workspace/i }) + .first() +} + +test.describe('Worktree lineage agent-list expansion independence', () => { + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('toggling child worktrees does not collapse the expanded agent summary', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + const parentRow = worktreeRow(orcaPage, parentId) + const childRow = worktreeRow(orcaPage, childId) + + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + + await seedTwoParentAgents(orcaPage, parentId) + + // Both sections present: the "2 agents" summary and the child-workspaces chip. + await expect(compactSummary(orcaPage, parentId)).toBeVisible({ timeout: 10_000 }) + await expect(childWorkspacesChip(orcaPage, parentId)).toBeVisible() + await expect(childRow).toBeVisible() + await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'false') + await captureSidebar(orcaPage, '1-before-both-collapsed.png') + + // Expand the agent summary. + await compactSummary(orcaPage, parentId).click() + await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'true') + await captureSidebar(orcaPage, '2-agents-expanded.png') + + // Collapse the child worktrees via the chip. This remounts the parent card. + await childWorkspacesChip(orcaPage, parentId).click() + await expect(childRow).toBeHidden() + + // FIXED: the agent summary stays expanded despite the card remount. + await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'true') + await captureSidebar(orcaPage, '3-after-children-toggle-agents-still-expanded.png') + }) +}) From 83588b43a73f386d870ff0a6efb6a185a8f1d9b2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:36:59 -0700 Subject: [PATCH 13/52] fix(terminal): stop phantom pinned-viewport pins from freezing follow-output (#8625) Co-authored-by: Orca --- .../terminal-pane/pty-connection.ts | 4 +- .../lib/pane-manager/pane-tree-ops.test.ts | 52 +++++ .../src/lib/pane-manager/pane-tree-ops.ts | 31 ++- .../terminal-scroll-intent.test.ts | 115 +++++++++++ .../pane-manager/terminal-scroll-intent.ts | 65 +++++-- .../fixtures/streaming-scrollback-fixture.cjs | 44 +++++ .../e2e/terminal-scroll-intent-follow.spec.ts | 180 ++++++++++++++++++ 7 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/fixtures/streaming-scrollback-fixture.cjs create mode 100644 tests/e2e/terminal-scroll-intent-follow.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index add578b7e2c..4c56bc6458f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -6064,7 +6064,9 @@ export function connectPanePty( } // Why: snapshot replay clears and rebuilds xterm state; re-apply the // user's scroll intent once so hidden catch-up cannot repin the viewport. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + // Restore by bottom offset — the rebuilt buffer renumbers every row, so + // the pre-replay absolute viewport line points at arbitrary content. + enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index abe054b80c1..16a4e0092b6 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -199,6 +199,58 @@ describe('safeFit', () => { expect(activeBuffer.viewportY).toBe(42) }) + it('restores pinned content via marker when fit reflow renumbers buffer lines', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + // Reflow at narrower cols rewraps lines; the tracked content now lives + // at a different absolute line than the pre-fit viewport number. + activeBuffer.baseY = 130 + activeBuffer.viewportY = 0 + marker.line = 57 + }) + + safeFit(pane) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(57) + expect(activeBuffer.viewportY).toBe(57) + expect(marker.dispose).toHaveBeenCalled() + }) + + it('keeps a follow-output pane at the bottom through fit', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 100 + activeBuffer.baseY = 100 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 130 + activeBuffer.viewportY = 0 + }) + + safeFit(pane) + + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + }) + it('does not throw when xterm rejects scroll restoration during layout', () => { const pane = createPane({ proposedCols: 100, diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3b2af6fed87..d069244cf4f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -10,8 +10,11 @@ import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' import { captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + enforceTerminalWriteScrollIntent, + syncTerminalScrollIntentFromViewport } from './terminal-scroll-intent' +import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' export { captureScrollState, restoreScrollState } from './pane-scroll' @@ -73,7 +76,16 @@ export function safeFit(pane: ManagedPane): void { return } let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } try { // Why: when a mobile client has resized this PTY to phone dimensions, // the desktop must keep xterm at those dimensions instead of fitting to @@ -85,8 +97,7 @@ export function safeFit(pane: ManagedPane): void { if (override) { if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { if (canPreserveScrollIntentForFit(pane)) { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - shouldRestoreScroll = true + captureScrollForFit() } pane.terminal.resize(override.cols, override.rows) } @@ -101,8 +112,7 @@ export function safeFit(pane: ManagedPane): void { return } if (canPreserveScrollIntentForFit(pane)) { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - shouldRestoreScroll = true + captureScrollForFit() } pane.fitAddon.fit() } catch { @@ -110,10 +120,19 @@ export function safeFit(pane: ManagedPane): void { } finally { if (shouldRestoreScroll) { try { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + if (pinnedScrollState) { + restoreScrollState(pane.terminal, pinnedScrollState) + syncTerminalScrollIntentFromViewport(pane.terminal) + } else { + enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) + } } catch { // Why: xterm can temporarily expose a terminal whose renderer has not // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } } } } diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index bc728ce7dd2..205bd8ecaa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -367,4 +367,119 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenCalledWith(40) }) + + it('reverts a wheel pin to followOutput when the viewport never leaves the bottom', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + // A sub-row trackpad delta or a wheel consumed by a mouse-reporting TUI: + // the wheel event fires but xterm's viewport never moves. + const wheelUp = new Event('wheel') as WheelEvent + Object.defineProperty(wheelUp, 'deltaY', { value: -2 }) + host.dispatchEvent(wheelUp) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + await Promise.resolve() + while (frameCallbacks.length) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a wheel pin when the viewport leaves the bottom before settle', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + const wheelUp = new Event('wheel') as WheelEvent + Object.defineProperty(wheelUp, 'deltaY', { value: -10 }) + host.dispatchEvent(wheelUp) + + terminal.buffer.active.viewportY = 60 + await Promise.resolve() + while (frameCallbacks.length) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(60) + disposable.dispose() + }) + + it('does not freeze the viewport when a pinned intent is latched at the bottom', () => { + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + // Phantom pin: a wheel/PageUp the viewport never followed latched + // pinnedViewport while the terminal was still at the bottom. + markTerminalPinnedViewport(terminal) + + for (let batch = 1; batch <= 2; batch += 1) { + const snapshot = captureTerminalWriteScrollIntent(terminal) + // xterm follows output during the write because the viewport was at bottom. + terminal.buffer.active.baseY += 25 + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + enforceTerminalWriteScrollIntent(terminal, snapshot) + expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) + } + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + + it('resumes following on visibility enforce when the stored pin was recorded at the bottom', () => { + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + markTerminalPinnedViewport(terminal) + + terminal.buffer.active.baseY = 400 + terminal.buffer.active.viewportY = 100 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToBottom).toHaveBeenCalled() + expect(terminal.buffer.active.viewportY).toBe(400) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + + it('restores a pinned viewport by bottom offset after a rebuild shrinks the scrollback', () => { + const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + markTerminalPinnedViewport(terminal) + + // Snapshot replay rebuilds a shorter, renumbered buffer. + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(150) + expect(terminal.buffer.active.viewportY).toBe(150) + }) + + it('supports bottom-offset restore for buffer-rebuild write paths', () => { + const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalWriteScrollIntent(terminal) + + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index c0ab35f654c..88e70096a59 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -29,6 +29,14 @@ type TerminalScrollIntentWriteSnapshot = { kind: TerminalScrollIntentKind bufferType: BufferType viewportY: number + baseY: number +} + +type TerminalScrollIntentEnforceOptions = { + // 'viewportLine' restores the absolute buffer line (correct while content + // only grows). 'bottomOffset' restores the distance from the bottom — + // required after a buffer rebuild (snapshot replay, reflow) renumbers rows. + restoreBy?: 'viewportLine' | 'bottomOffset' } const terminalScrollIntentByTerminal = new WeakMap< @@ -180,7 +188,11 @@ export function syncTerminalScrollIntentSoon( queueMicrotask(sync) requestAnimationFrame(sync) requestAnimationFrame(() => requestAnimationFrame(sync)) - setTimeout(sync, 80) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed (sub-row delta, TUI-consumed mouse report, + // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. + setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) } export function getTerminalScrollIntentKind( @@ -205,19 +217,27 @@ export function captureTerminalWriteScrollIntent( return null } const existing = readStoredIntent(terminal) - const kind = + let kind = existing?.kind ?? (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + // Why: a pinned intent whose live viewport still sits at the bottom is a + // phantom pin (the user's scroll never detached the viewport). Enforcing it + // would freeze the terminal at the current line on every write batch. + if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + kind = 'followOutput' + } return { kind, bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY + viewportY: snapshot.viewportY, + baseY: snapshot.baseY } } export function enforceTerminalWriteScrollIntent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null + snapshot: TerminalScrollIntentWriteSnapshot | null, + options: TerminalScrollIntentEnforceOptions = {} ): void { if (!snapshot) { return @@ -232,7 +252,11 @@ export function enforceTerminalWriteScrollIntent( } return } - const targetY = clampViewportY(snapshot.viewportY, current.baseY) + const requestedY = + options.restoreBy === 'bottomOffset' + ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) + : snapshot.viewportY + const targetY = clampViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { safeScrollCall(() => terminal.scrollToLine?.(targetY)) } @@ -241,14 +265,29 @@ export function enforceTerminalWriteScrollIntent( export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { const existing = readStoredIntent(terminal) - const snapshot = existing - ? { - kind: existing.kind, - bufferType: existing.bufferType, - viewportY: existing.viewportY - } - : captureTerminalWriteScrollIntent(terminal) - enforceTerminalWriteScrollIntent(terminal, snapshot) + if (!existing) { + enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + return + } + const snapshot = { + kind: existing.kind, + bufferType: existing.bufferType, + viewportY: existing.viewportY, + baseY: existing.baseY + } + if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // Why: a pin recorded at the bottom means the viewport never detached; + // resuming must follow live output, not freeze at that stale line. + snapshot.kind = 'followOutput' + } + const current = readBufferSnapshot(terminal) + // Why: a shorter live buffer than the stored intent means the buffer was + // rebuilt (snapshot replay/remount); absolute lines are renumbered there. + const restoreBy = + snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY + ? 'bottomOffset' + : 'viewportLine' + enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) } export function attachTerminalScrollIntentTracking( diff --git a/tests/e2e/fixtures/streaming-scrollback-fixture.cjs b/tests/e2e/fixtures/streaming-scrollback-fixture.cjs new file mode 100644 index 00000000000..9db942e0232 --- /dev/null +++ b/tests/e2e/fixtures/streaming-scrollback-fixture.cjs @@ -0,0 +1,44 @@ +// Emits numbered scrollback in two stdin-gated phases so tests can interact +// with the terminal between deterministic write batches: +// phase 1: LINES numbered rows, then STREAM_PHASE1_DONE +// (waits for any stdin byte — a keypress escape sequence also qualifies) +// phase 2: LINES more rows in spaced chunks, then STREAM_PHASE2_DONE, exit +const LINES = 300 +const PHASE2_CHUNKS = 10 +const PHASE2_CHUNK_INTERVAL_MS = 30 + +let phase = 1 + +function numberedRows(from, count) { + let out = '' + for (let i = from; i < from + count; i += 1) { + out += `STREAM_LINE_${String(i).padStart(5, '0')}\n` + } + return out +} + +process.stdout.write(`${numberedRows(0, LINES)}STREAM_PHASE1_DONE\n`) + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} +process.stdin.on('data', () => { + if (phase !== 1) { + return + } + phase = 2 + // Spaced chunks make the renderer process several distinct write batches, + // which is what re-triggers per-batch scroll-intent enforcement. + const perChunk = Math.ceil(LINES / PHASE2_CHUNKS) + let chunk = 0 + const timer = setInterval(() => { + process.stdout.write(numberedRows(LINES + chunk * perChunk, perChunk)) + chunk += 1 + if (chunk >= PHASE2_CHUNKS) { + clearInterval(timer) + process.stdout.write('STREAM_PHASE2_DONE\n') + process.exit(0) + } + }, PHASE2_CHUNK_INTERVAL_MS) +}) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts new file mode 100644 index 00000000000..d3873805948 --- /dev/null +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -0,0 +1,180 @@ +import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const STREAMING_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/streaming-scrollback-fixture.cjs' +) +// Past the scroll-intent settle window (80ms) so a phantom pin has had every +// chance to latch before phase-2 output arrives. +const INTENT_SETTLE_WAIT_MS = 250 + +type ViewportProbe = { + baseY: number + viewportY: number + containsMarker: boolean +} + +async function probeActiveViewport(page: Page, marker: string): Promise { + return page.evaluate((markerText) => { + 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) { + return null + } + const buffer = pane.terminal.buffer.active + let containsMarker = false + for (let line = buffer.baseY + pane.terminal.rows - 1; line >= 0; line -= 1) { + const text = buffer.getLine(line)?.translateToString(true) ?? '' + if (text.includes(markerText)) { + containsMarker = true + break + } + } + return { + baseY: buffer.baseY, + viewportY: buffer.viewportY, + containsMarker + } + }, marker) +} + +async function waitForMarkerAtBottom(page: Page, marker: string): Promise { + await expect + .poll( + async () => { + const probe = await probeActiveViewport(page, marker) + return Boolean(probe && probe.containsMarker && probe.viewportY === probe.baseY) + }, + { + timeout: 30_000, + message: `terminal did not reach "${marker}" with the viewport following the bottom` + } + ) + .toBe(true) +} + +async function dispatchSubRowWheelUp(page: Page): Promise { + await 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 + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + // A -2px delta is far below one cell height: xterm scrolls zero rows, the + // viewport stays at the bottom, but the wheel listener still observes an + // upward wheel — the phantom-pin shape from trackpad jitter. + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, 40), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -2 + }) + pane.terminal.element.dispatchEvent(event) + }) +} + +async function dispatchPlainHomeKeydown(page: Page): Promise { + await 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 + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + const textarea = + pane.terminal.element.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('xterm helper textarea unavailable') + } + textarea.focus() + // Plain Home is delivered to the PTY app (readline start-of-line); it + // never scrolls the xterm viewport. + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Home', + code: 'Home' + }) + // Why: xterm's key evaluator reads the legacy keyCode, which KeyboardEvent + // constructors do not populate; without it no escape bytes reach the PTY. + Object.defineProperty(event, 'keyCode', { configurable: true, value: 36 }) + Object.defineProperty(event, 'which', { configurable: true, value: 36 }) + textarea.dispatchEvent(event) + }) +} + +async function startStreamingFixturePhase1(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal(page, ptyId, `node "${STREAMING_FIXTURE_PATH}"`) + await waitForMarkerAtBottom(page, 'STREAM_PHASE1_DONE') + return ptyId +} + +test.describe('terminal scroll intent keeps following output', () => { + test('a sub-row wheel-up that never moves the viewport must not stop follow-output', async ({ + orcaPage + }) => { + const ptyId = await startStreamingFixturePhase1(orcaPage) + + await dispatchSubRowWheelUp(orcaPage) + await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS) + + // Any byte releases the fixture's phase-2 stream. + await sendToTerminal(orcaPage, ptyId, 'g') + await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') + }) + + test('a plain Home keypress delivered to the app must not stop follow-output', async ({ + orcaPage + }) => { + await startStreamingFixturePhase1(orcaPage) + + // The Home escape sequence reaching the fixture's stdin doubles as the + // phase-2 release, exactly like a user pressing Home mid-generation. + await dispatchPlainHomeKeydown(orcaPage) + await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') + }) +}) From 1724eef2f025cca5dbb11b70bb70761c1b926151 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:36:26 -0700 Subject: [PATCH 14/52] fix(window): extend startup reveal fallback to Linux so first launch never stays hidden (#8425) * fix(window): extend startup reveal fallback to Linux so first launch never stays hidden (#8421) On Linux/X11, ready-to-show can never fire (GPU/driver quirks), leaving the only BrowserWindow hidden until a second launch triggers the second-instance reveal path. Reuse the existing bounded Windows fallback timer on Linux; the handledInitialReadyToShow guard and headless E2E check already make the reveal idempotent and safe. Claude-Session: https://claude.ai/code/session_017rio4rnPiCUh8jHWxkq4xH * docs(window): drop win32-only qualifier from tray-fallback comments The tray-create fallback comments referenced 'createMainWindow's win32 10s reveal fallback', but #8421 extends that reveal fallback to Linux too. Since the tray itself is win32-only (createSystemTray no-ops off win32), naming a platform in these comments is both stale and misleading. Drop the qualifier; the surrounding Windows-only tray context already scopes it. --------- Co-authored-by: kaynan Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/index.ts | 4 +- src/main/window/createMainWindow.test.ts | 81 +++++++++++++++++++++++- src/main/window/createMainWindow.ts | 7 +- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 5196b7eac01..211bd473653 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -432,7 +432,7 @@ if (app.isPackaged && process.platform !== 'win32') { configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() -// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback, +// Why: just past createMainWindow's 10s ready-to-show reveal fallback, // so a window revealed on that path still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 @@ -875,7 +875,7 @@ function openMainWindow(): BrowserWindow { // seconds while explorer.exe's notification area is busy (part of issue // #7225's pre-paint stall), so create it after first paint. The timer // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's win32 10s reveal fallback) — those can still be + // (createMainWindow's 10s reveal fallback) — those can still be // hidden to the tray on close, so the icon must exist by then. let trayCreated = false const createSystemTrayDeferred = (): void => { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 54b4728f7cd..8899ba81bbf 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -2837,11 +2837,39 @@ describe('createMainWindow', () => { }) }) - it('does not install the startup reveal fallback off Windows', () => { + it('reveals the startup window on Linux when ready-to-show never fires', () => { vi.useFakeTimers() const { browserWindowInstance } = createStartupRevealWindowFixture() withPlatform('linux', () => { + createMainWindow(null) + vi.advanceTimersByTime(9_999) + expect(browserWindowInstance.show).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('cancels the Linux startup reveal fallback after ready-to-show', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(null) + windowHandlers['ready-to-show']() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('does not install the startup reveal fallback on macOS', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('darwin', () => { createMainWindow(null) vi.advanceTimersByTime(10_000) @@ -2901,6 +2929,57 @@ describe('createMainWindow', () => { }) }) + it('keeps the headless E2E window hidden when the Linux fallback fires', () => { + vi.useFakeTimers() + const previousHeadless = process.env.ORCA_E2E_HEADLESS + process.env.ORCA_E2E_HEADLESS = '1' + const { browserWindowInstance } = createStartupRevealWindowFixture() + + try { + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + } finally { + if (previousHeadless === undefined) { + delete process.env.ORCA_E2E_HEADLESS + } else { + process.env.ORCA_E2E_HEADLESS = previousHeadless + } + } + }) + + it('clears the Linux startup reveal fallback when the window is closed', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + windowHandlers.closed() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + + it('does not show or maximize a destroyed window when the Linux fallback fires', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + browserWindowInstance.isDestroyed.mockReturnValue(true) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + describe('system resume relay', () => { function setupResumeWindow() { const windowHandlers: Record void> = {} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index ff66b5ebc16..52c84ec6048 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -337,10 +337,11 @@ export function createMainWindow( // the window back to full-screen after the user already resized it (#591). let handledInitialReadyToShow = false let initialRevealFallbackTimer: ReturnType | null = - process.platform === 'win32' + process.platform === 'win32' || process.platform === 'linux' ? setTimeout(() => { - // Why: GPU/driver failures on Windows can prevent ready-to-show forever, - // leaving the only app window hidden while the main process stays alive. + // Why: GPU/driver failures on Windows and Linux/X11 can prevent + // ready-to-show forever, leaving the only app window hidden while the + // main process stays alive (#8421). initialRevealFallbackTimer = null revealInitialWindow() }, 10_000) From dbe2481d5759ed474ddb46eadc99e0a337a8cb00 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:37:16 -0700 Subject: [PATCH 15/52] Being able to reply to any comment from bot (#8621) * Allow replying to any comment in a thread, not just the root Reply state now tracks a comment id instead of a group id, and reply composers/handlers are threaded through each comment row (root and replies alike) so any comment can receive an inline reply rather than only the thread root. * Fix reply cancellation clearing the wrong comment's reply box When multiple comment threads had reply boxes open, cancelling one reply cleared replyingCommentId unconditionally, closing whichever box was open instead of the one actually cancelled. onCancelReply now threads through the specific commentId so cancellation only clears state if it matches the currently open reply. --- .../right-sidebar/checks-panel-content.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 0ffc41dd686..84798c944f8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -1980,7 +1980,7 @@ function PRCommentGroupView({ presentation: PRCommentPresentationClasses onResolve?: (threadId: string, resolve: boolean) => boolean | Promise onStartReply?: (commentId: number) => void - onCancelReply?: () => void + onCancelReply?: (commentId: number) => void onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise @@ -2001,7 +2001,7 @@ function PRCommentGroupView({ autoFocus disabled={replyDisabled} disabledReason={replyDisabledReason} - onCancel={onCancelReply} + onCancel={() => onCancelReply?.(comment.id)} onSubmit={(body) => onReply(comment, body)} /> @@ -2113,7 +2113,7 @@ function ResolvedCommentGroupsSection({ presentation: PRCommentPresentationClasses onResolve?: (threadId: string, resolve: boolean) => boolean | Promise onStartReply?: (commentId: number) => void - onCancelReply?: () => void + onCancelReply?: (commentId: number) => void onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise @@ -2351,7 +2351,9 @@ export function PRCommentsList({ presentation={presentation} onResolve={onResolve} onStartReply={setReplyingCommentId} - onCancelReply={() => setReplyingCommentId(null)} + onCancelReply={(commentId) => + setReplyingCommentId((current) => (current === commentId ? null : current)) + } onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} @@ -2679,7 +2681,9 @@ export function PRCommentsList({ presentation={presentation} onResolve={onResolve} onStartReply={setReplyingCommentId} - onCancelReply={() => setReplyingCommentId(null)} + onCancelReply={(commentId) => + setReplyingCommentId((current) => (current === commentId ? null : current)) + } onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} From 82573d70ae1865975de1b4e1d4d2f50f9f4ff004 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:46:25 -0700 Subject: [PATCH 16/52] fix(orchestration): compare stale-dispatch timestamps with julianday (#8514) Co-authored-by: Orca --- src/main/runtime/orchestration/db.test.ts | 75 +++++++++++++++++++++++ src/main/runtime/orchestration/db.ts | 13 ++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 8856101e1ee..15fb0a06a00 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -6,6 +6,20 @@ import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import type { MessageType } from './db' +// Overwrites the datetime('now')-seeded timestamps with explicit fixture values +// so stale-detection assertions stay deterministic (no wall clock). +function setDispatchTimes( + d: OrchestrationDb, + id: string, + dispatchedAt: string, + heartbeatAt: string | null = null +): void { + const sqlite = (d as unknown as { db: Database.Database }).db + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?') + .run(dispatchedAt, heartbeatAt, id) +} + describe('OrchestrationDb', () => { let db: OrchestrationDb | undefined @@ -696,6 +710,67 @@ describe('OrchestrationDb', () => { expect(stale.map((s) => s.id)).toEqual([ctxB.id]) }) + // Regression for #8452: dispatched_at / last_heartbeat_at are written by + // datetime('now') (space-format, e.g. "2026-07-12 12:00:00") while the + // threshold is ISO ("...T11:55:00.000Z"). Raw TEXT ordering ranks the space + // (0x20) below the 'T' (0x54) at index 10, flagging fresh same-date rows. + it('getStaleDispatches ignores fresh SQLite space-format timestamps (#8452)', () => { + const d = createDb() + + // Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both + // after the 11:55 threshold → NOT stale. + const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh') + setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00') + + // Legacy ISO-format fresh row (mixed-format table) stays fresh too. + const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy') + setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z') + + // Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold. + const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung') + setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale.map((s) => s.id)).toEqual([hung.id]) + }) + + it('getStaleDispatches keeps a just-dispatched space-format row in the grace window (#8452)', () => { + const d = createDb() + + // Space-format dispatched_at one minute after the threshold, no heartbeat + // yet → still inside the grace window, must not be flagged. + const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x') + setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z') + expect(stale).toEqual([]) + }) + + // Same-UTC-date midnight threshold: keeps the buggy space-vs-'T' compare in + // play so this guards the fix at a day boundary (#8452; idea from @KMGeon's #8453). + it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight') + setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00') + + const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z') + expect(stale).toEqual([]) + }) + + // Guards the last_heartbeat_at half of the fix on its own: a worker + // dispatched long before the threshold (stale under either format) that + // just sent a fresh space-format heartbeat must stay fresh (#8452). + it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live') + setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale).toEqual([]) + }) + it('getThreadMessagesFor returns only same-thread replies to a handle', () => { const d = createDb() const outbound = d.insertMessage({ diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 80a842da530..2525d30c851 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -798,17 +798,20 @@ export class OrchestrationDb { // failed / circuit_broken row with an old-or-null last_heartbeat_at would // warn every tick (warning storm). Without `dispatched_at < :threshold`, // a freshly-dispatched worker would trip the warning during its first - // heartbeat interval (false positive). Callers supply the threshold as an - // ISO timestamp so the SQLite string-compare ordering works correctly - // (ISO-8601 compares lexicographically in time order). + // heartbeat interval (false positive). The stored columns are space-format + // (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so + // raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and + // flags fresh same-date rows as stale (#8452). julianday() parses both + // formats as UTC for a correct numeric comparison; a malformed timestamp + // yields NULL, so that row simply isn't flagged. getStaleDispatches(thresholdIso: string): DispatchContextRow[] { return this.db .prepare( `SELECT * FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL - AND dispatched_at < ? - AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)` + AND julianday(dispatched_at) < julianday(?) + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) .all(thresholdIso, thresholdIso) as DispatchContextRow[] } From 9caebc3df9f7ed883e837f96ddb0a231d93841e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:50:32 +0000 Subject: [PATCH 17/52] 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 b60571232c8..f59489f67c0 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.9m + + downloads: 5.0m @@ -15,7 +15,7 @@ downloads downloads - 4.9m - 4.9m + 5.0m + 5.0m From 6c489a4379e6f33f6e4553cd0f33267e8b20b37b Mon Sep 17 00:00:00 2001 From: moseoh Date: Tue, 14 Jul 2026 09:52:25 +0900 Subject: [PATCH 18/52] fix(source-control): keep push-signal status refreshes alive while the huge flag is set (#8570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once git status hit the 10,000-entry limit, the huge flag disabled every status-refresh lane — including the push signals (repo metadata watcher, terminal command-finished) that carry the evidence needed to clear it. The flag only clears when a fresh non-huge status result arrives, so the worktree deadlocked into stale Source Control and explorer badges until an app restart. Split the gate: evidence-free interval polling stays paused while huge (preserving the #7983 idle-CPU fix), but push-signal refreshes now ride the coalesced change-signal lane, so a commit made in the integrated terminal clears the flag and resumes normal polling. A visibilitychange listener (active only while huge pauses polling) catches up signals dropped behind a hidden window, matching the becoming-visible catch-up the normal lane already has. --- .../right-sidebar/useGitStatusPolling.test.ts | 83 +++++++++++++++++-- .../right-sidebar/useGitStatusPolling.ts | 40 +++++++-- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 5c4d57338a3..44efe18be8b 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -47,6 +47,7 @@ async function usePollingOnce( enabled?: boolean expectStatusCall?: boolean stateOverrides?: Partial + documentStub?: object } = {} ): Promise<{ state: PollState; gitStatus: ReturnType }> { vi.resetModules() @@ -123,12 +124,15 @@ async function usePollingOnce( removeEventListener: vi.fn() }) - vi.stubGlobal('document', { - visibilityState: 'visible', - hasFocus: () => true, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - }) + vi.stubGlobal( + 'document', + options.documentStub ?? { + visibilityState: 'visible', + hasFocus: () => true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + ) vi.stubGlobal('setInterval', vi.fn()) vi.stubGlobal('clearInterval', vi.fn()) @@ -654,6 +658,73 @@ describe('useGitStatusPolling', () => { vi.useRealTimers() }) + it('refreshes on repo metadata push signals while the huge flag is set so the flag can clear', async () => { + const { gitStatus } = await usePollingOnce( + { + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'refs/heads/main' + }, + { + expectStatusCall: false, + stateOverrides: { + gitStatusHugeByWorktree: { [worktree.id]: { limit: 10_000 } } + } + } + ) + + // The huge flag must only pause evidence-free interval polling. + expect(globalThis.setInterval).not.toHaveBeenCalled() + expect(gitStatus).not.toHaveBeenCalled() + + // Push signals (e.g. a commit's metadata write) must stay subscribed while + // huge — a fresh non-huge status result is the only way the flag clears. + const onChanged = window.api.worktrees.onChanged as ReturnType + expect(onChanged).toHaveBeenCalledTimes(1) + const handleRepoSignal = onChanged.mock.calls[0][0] as (payload: { repoId: string }) => void + handleRepoSignal({ repoId: repo.id }) + + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + }) + + it('catches up on becoming visible while the huge flag is set', async () => { + const documentListeners = new Map() + const visibilityDocument = { + visibilityState: 'hidden', + hasFocus: () => false, + addEventListener: vi.fn((type: string, listener: EventListener) => { + documentListeners.set(type, [...(documentListeners.get(type) ?? []), listener]) + }), + removeEventListener: vi.fn() + } + const { gitStatus } = await usePollingOnce( + { + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'refs/heads/main' + }, + { + expectStatusCall: false, + documentStub: visibilityDocument, + stateOverrides: { + gitStatusHugeByWorktree: { [worktree.id]: { limit: 10_000 } } + } + } + ) + // Signals dropped while the window was hidden must be caught up on + // reveal so the huge flag can still clear. + expect(gitStatus).not.toHaveBeenCalled() + + visibilityDocument.visibilityState = 'visible' + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener(new Event('visibilitychange')) + } + + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + }) + it('does not overlap slow visible git status polls and runs one trailing refresh', async () => { vi.resetModules() vi.useFakeTimers() diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index b2726dd4380..acb8d464cbb 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -72,13 +72,20 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { openFiles } const isActiveConnectionReady = isConnectionReady(activeConnectionId) - const shouldPollActiveWorktreeGitStatus = + const canFetchActiveWorktreeGitStatus = enabled && !!activeWorktreeId && !!worktreePath && activeRepoSupportsGit && shouldPollActiveGitStatus(activeGitStatusPollingArgs) && - isActiveConnectionReady && + isActiveConnectionReady + // Why: the huge flag must only pause evidence-free polling, not push-signal + // refreshes — a fresh non-huge status result is the only thing that can + // clear the flag, so gating every lane on it would deadlock the worktree + // into stale status until an app restart. + const shouldPollActiveWorktreeGitStatus = + canFetchActiveWorktreeGitStatus && + !!activeWorktreeId && !gitStatusHugeByWorktree?.[activeWorktreeId] const activeStatusPollIntervalMs = hasInteractiveActiveGitStatusConsumer( activeGitStatusPollingArgs @@ -116,7 +123,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { if (!isWindowVisible()) { return } - if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { + if (!canFetchActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { return } try { @@ -141,7 +148,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { activePushTarget, activeWorktreeId, fetchUpstreamStatus, - shouldPollActiveWorktreeGitStatus, + canFetchActiveWorktreeGitStatus, worktreePath, setGitStatus, setUpstreamStatus, @@ -180,6 +187,29 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { statusPollRunnerRef.current?.run({ changeSignal: true }) }, []) + // Why: while huge, the visibility interval below is not installed, and push + // signals are dropped while hidden — e.g. an agent committing behind a + // minimized window. Catch up on reveal so the huge flag can still clear. + const hugeStatusPauseActive = canFetchActiveWorktreeGitStatus && !activeStatusPollScope + useEffect(() => { + if ( + !hugeStatusPauseActive || + typeof document === 'undefined' || + typeof document.addEventListener !== 'function' + ) { + return + } + const catchUpOnReveal = (): void => { + if (isWindowVisible()) { + fetchStatusOnChangeSignal() + } + } + document.addEventListener('visibilitychange', catchUpOnReveal) + return () => { + document.removeEventListener('visibilitychange', catchUpOnReveal) + } + }, [hugeStatusPauseActive, fetchStatusOnChangeSignal]) + useEffect(() => { if (!activeStatusPollScope) { return @@ -213,7 +243,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { useGitStatusPushSignalRefresh({ activeRepoId, activeWorktreeId, - enabled: shouldPollActiveWorktreeGitStatus, + enabled: canFetchActiveWorktreeGitStatus, fetchStatus: fetchStatusOnChangeSignal }) From 1450db85bab555c8f804c1bc3e43395aa3dab556 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:55:47 -0700 Subject: [PATCH 19/52] fix(grok): don't tell users to re-run grok login on refreshable token expiry (#8508) Co-authored-by: Orca --- src/main/rate-limits/grok-fetcher.test.ts | 4 ++++ src/main/rate-limits/grok-fetcher.ts | 5 ++++- .../src/components/status-bar/tooltip.test.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index f816d5c552f..07c536f32a0 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -159,6 +159,10 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + // Why: a stored-but-expired access token is refreshed by Grok CLI on next + // use (a genuine sign-out returns 'missing'), so the message must not tell + // users to re-run `grok login` (#8497). + expect(result.error).not.toMatch(/grok login/i) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 963a2fb1350..099298a56c4 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -145,7 +145,10 @@ export async function fetchGrokRateLimits( } const session = readResult.session if (!isGrokAccessTokenFresh(session)) { - return result('error', 'Grok session expired — run grok login to refresh') + // Why: a genuine sign-out returns 'missing' earlier, so reaching here always + // means a stored, refreshable session — Grok CLI refreshes the access token + // on its next run, so don't tell users to re-run `grok login` (#8497). + return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') } try { diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index c553f68ddb1..d9387633fa0 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -135,6 +135,21 @@ describe('provider usage error copy', () => { ) }) + it('keeps the reworded Grok expired-token error classified as an auth failure (#8497)', () => { + // Why: the fix (grok-fetcher.ts) dropped the "run grok login" wording that + // used to trigger auth classification; this pins that the new copy still + // resolves to the softer refresh message instead of leaking the raw string. + const grok = provider({ + provider: 'grok', + error: 'Grok access token expired — Grok CLI will refresh it on next use' + }) + + expect(getProviderUsageStatusLabel(grok)).toBe('Refresh failed') + expect(getProviderUsageErrorMessage(grok)).toBe( + 'Grok usage could not be refreshed. Agent sessions may still be signed in.' + ) + }) + it('frames known Codex auth refresh failures as auth-shaped usage failures', () => { const cases = [ 'Please reauthenticate before checking usage.', From 73d83a9fb4b5c9e9711cd0879267c1d47f30e1b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:56:22 -0700 Subject: [PATCH 20/52] fix(cli): stop ELECTRON_RUN_AS_NODE leaking into orca claude-teams child (#8513) Co-authored-by: Orca --- src/cli/handlers/core.test.ts | 131 ++++++++++++++++++++++++++++++++++ src/cli/handlers/core.ts | 8 ++- src/cli/runtime/launch.ts | 2 +- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/cli/handlers/core.test.ts diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts new file mode 100644 index 00000000000..69f27dc9bd6 --- /dev/null +++ b/src/cli/handlers/core.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) + +// The claude-teams handler spawns `claude` via node:child_process; mock it so we +// can inspect the child env without launching a real process. +vi.mock('node:child_process', () => ({ spawn: spawnMock })) + +// Keep the socket runtime client out of the import graph; only the error type +// and serveOrcaApp binding are referenced by the module under test. +vi.mock('../runtime-client', () => ({ + RuntimeClientError: class RuntimeClientError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + serveOrcaApp: vi.fn() +})) + +import { CORE_HANDLERS } from './core' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' + +type SpawnEnv = Record + +// Minimal child stub: the handler only awaits `exit`, so resolve it on the next +// microtask to complete the spawned-process promise deterministically. +function mockClaudeChild(): { once: (event: string, cb: (...args: unknown[]) => void) => unknown } { + const child = { + once(event: string, cb: (...args: unknown[]) => void) { + if (event === 'exit') { + queueMicrotask(() => cb(0, null)) + } + return child + } + } + return child +} + +describe('orca claude-teams CLI handler', () => { + const isWindows = process.platform === 'win32' + let previousRunAsNode: string | undefined + let previousPaneKey: string | undefined + let previousExitCode: typeof process.exitCode + + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + + function runClaudeTeams(): Promise { + const ctx: HandlerContext = { + flags: new Map(), + client, + cwd: '/tmp/repo', + json: false, + rawArgs: [] + } + return CORE_HANDLERS['claude-teams'](ctx) + } + + beforeEach(() => { + spawnMock.mockReset() + spawnMock.mockImplementation(() => mockClaudeChild()) + callMock.mockReset() + callMock.mockResolvedValue({ + result: { launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } } + }) + previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE + previousPaneKey = process.env.ORCA_PANE_KEY + previousExitCode = process.exitCode + // The `orca` launcher runs Orca's Electron binary as Node, so the CLI process + // itself carries ELECTRON_RUN_AS_NODE=1. Reproduce that inherited flag here. + process.env.ELECTRON_RUN_AS_NODE = '1' + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + }) + + afterEach(() => { + if (previousRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = previousRunAsNode + } + if (previousPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = previousPaneKey + } + process.exitCode = previousExitCode + }) + + // Guarded to non-Windows: the handler early-returns unsupported_platform on + // win32, so the leak path never runs there. + it.skipIf(isWindows)( + 'does not leak ELECTRON_RUN_AS_NODE into the spawned claude child', + async () => { + await runClaudeTeams() + + expect(spawnMock).toHaveBeenCalledWith('claude', expect.any(Array), expect.any(Object)) + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + + // The prepareLaunch request env is built from the same helper, so it must + // be sanitized too. + const prepareLaunchEnv = (callMock.mock.calls[0][1] as { env: SpawnEnv }).env + expect(prepareLaunchEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + } + ) + + it.skipIf(isWindows)( + 'still forwards non-Electron parent env and prepareLaunch env to claude', + async () => { + const previousMarker = process.env.ORCA_TEST_MARKER + process.env.ORCA_TEST_MARKER = 'keep-me' + try { + await runClaudeTeams() + } finally { + if (previousMarker === undefined) { + delete process.env.ORCA_TEST_MARKER + } else { + process.env.ORCA_TEST_MARKER = previousMarker + } + } + + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ORCA_TEST_MARKER).toBe('keep-me') + expect(spawnEnv.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(spawnEnv.PATH).toBe('/shim:/usr/bin') + } + ) +}) diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 63a18484f0b..145540bb627 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -2,10 +2,16 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +import { stripElectronRunAsNode } from '../runtime/launch' function envRecord(): Record { + // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI + // process carries ELECTRON_RUN_AS_NODE=1. Strip it before it reaches the + // spawned `claude` (and any nested Electron it launches), which would + // otherwise be forced into headless plain-Node mode. + const env = stripElectronRunAsNode(process.env) return Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) } diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index cdb20f194ab..94791306c95 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -266,7 +266,7 @@ function resolveForegroundOrcaExecutable(): string { ) } -function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE return next From 2f660a6028a0f05eb53e780de8e3badfa1f10cf7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:06:06 -0700 Subject: [PATCH 21/52] fix(source-control): route GitHub Enterprise Server remotes to the GitHub provider for PR creation (#8312) (#8603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(source-control): route GHES remotes to the GitHub provider for PR creation A GitHub Enterprise Server user could not submit a PR — Orca demanded ORCA_GITEA_TOKEN — while issue sync worked fine (#8312). Root cause: GitHub owner/repo resolution (parseGitHubOwnerRepo) hard-rejects any host that is not literally github.com. A GHES remote lives on a custom host, so GitHub's forge resolveRepository returned null and provider detection fell through the list to Gitea, whose KNOWN_NON_GITEA_HOSTS denylist cannot enumerate arbitrary GHES domains. Issue sync was unaffected because gh issue/pr list run with cwd=repoPath and let gh resolve the GHES host natively. Fix mirrors GitLab self-hosted detection (getGlabKnownHosts): a new getEnterpriseGitHubRepoSlug resolves a custom-host origin to owner/repo only when gh is authenticated to that host — gh only ever manages GitHub/GHES credentials, so a logged-in host is definitively GitHub. Wired into: - forge-provider GitHub resolveRepository (fallback after github.com miss), so detection claims GHES before Gitea is consulted; - createGitHubPullRequest owner/repo resolution; - isGitHubAuthenticated, which now probes the repo's real host instead of a hardcoded --hostname github.com. github.com repos keep the cached getRepoSlug fast path and never spawn the extra gh auth probe. * fix(github): host-qualify GHES gh commands and probe auth in the repo runtime Addresses two correctness issues found in review of the #8312 fix. 1. GHES host was discarded before `gh pr create`. `--repo owner/repo` shorthand resolves against gh's default host (usually github.com), so for a user authed to both github.com and GHES it could target a same-named github.com repo or fail — deterministic for SSH repos, which run gh with no cwd. Now `createGitHubPullRequest` and the `findOpenPRByHeadBase` fallback pass a host-qualified `HOST/owner/repo` for GHES (github.com keeps the shorthand). Also generalize `parseCreatePRPayload`'s URL regex off github.com so a GHES PR URL parses directly instead of limping through the list fallback. 2. GHES auth was probed on the wrong gh runtime. `getAuthenticatedGitHubHosts` ran a global `gh auth status` with no cwd/WSL/SSH context and cached every runtime under one "local" key, so a GHES login present only in the repo's WSL distro was missed and the repo fell back to Gitea. Replaced with `isGitHubHostAuthenticated`, which runs `gh auth status --hostname ` with the repository's execution options (cwd/WSL distro, or SSH-local like the create path) and caches per runtime+host — mirroring GitLab's isGlabConfiguredForRemoteHost. This also honors GH_ENTERPRISE_TOKEN inferred from repo context. Spawn failures stay indeterminate (uncached). Adds createGitHubPullRequest-level tests asserting the actual gh `--repo` arguments (create + fallback) and the WSL/SSH runtime of the auth probe. * perf(source-control): drop redundant GHES gh auth probe in eligibility Review follow-up. Detection only routes a GHES remote to the GitHub provider after getEnterpriseGitHubRepoSlug has confirmed gh is authenticated to its host, so isGitHubAuthenticated can trust a non-null slug as authenticated and skip a second, rate-limited `gh auth status` spawn per eligibility poll. Reaching the github.com probe now implies the remote is github.com. Tests assert the enterprise path fires no redundant gh probe. --- src/main/github/client-create-pr.test.ts | 80 ++++++++ src/main/github/client.ts | 38 ++-- .../github-enterprise-repository.test.ts | 181 ++++++++++++++++++ .../github/github-enterprise-repository.ts | 130 +++++++++++++ .../source-control/forge-provider.test.ts | 50 ++++- src/main/source-control/forge-provider.ts | 22 ++- .../hosted-review-creation.test.ts | 77 +++++++- .../source-control/hosted-review-creation.ts | 9 + 8 files changed, 569 insertions(+), 18 deletions(-) create mode 100644 src/main/github/github-enterprise-repository.test.ts create mode 100644 src/main/github/github-enterprise-repository.ts diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 6c64e4b8066..79d614eb0d0 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises' const { ghExecFileAsyncMock, getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, extractExecErrorMock, acquireMock, releaseMock, @@ -11,6 +12,7 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), extractExecErrorMock: vi.fn((error: unknown) => { const value = error as { stderr?: string; stdout?: string; message?: string } return { @@ -52,12 +54,18 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + import { createGitHubPullRequest } from './client' describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() @@ -115,6 +123,78 @@ describe('createGitHubPullRequest', () => { expect(releaseMock).toHaveBeenCalledOnce() }) + it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + // github.com-only slug parsing misses GHES, so creation comes from the + // enterprise resolver, which carries the host. + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // gh prints the PR URL (not JSON); the GHES host must still parse directly. + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://github.acme-corp.com/team/orca/pull/7\n' + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toEqual({ + ok: true, + number: 7, + url: 'https://github.acme-corp.com/team/orca/pull/7' + }) + + const [args] = ghExecFileAsyncMock.mock.calls[0] + // Bare "team/orca" would resolve against gh's default host (github.com); + // the host prefix pins the command to the Enterprise server. + expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + + it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // Create reports "already exists", forcing the pr-list fallback. + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('exists'), { + stderr: 'a pull request for branch "feature/create-pr" already exists', + stdout: '' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + ]) + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + }) + + const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + it('runs local WSL project pull request creation through the selected distro', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index b4702f28128..5848454e6dd 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -79,6 +79,7 @@ import { rememberGhCwdResolutionFailure } from './gh-cwd-repo-negative-cache' import type { GitHubRepoContext } from './github-repository-identity' +import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -1750,16 +1751,29 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | } catch { // Fall through to URL parsing for older gh versions without --json support. } - const urlMatch = trimmed.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/) + // Why: gh prints the PR URL (not JSON) here; match any host, not just + // github.com, so a GitHub Enterprise Server URL still parses directly (#8312). + const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) if (!urlMatch) { return null } return { number: Number(urlMatch[1]), url: urlMatch[0] } } +// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host +// (usually github.com), not the repo's remote — so a GHES repo would target a +// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits +// the Enterprise server; this is the only host signal for SSH repos, which run +// gh with no cwd context (#8312). github.com keeps the bare shorthand. +function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { + return slug.host && slug.host.toLowerCase() !== 'github.com' + ? `${slug.host}/${slug.owner}/${slug.repo}` + : `${slug.owner}/${slug.repo}` +} + async function findOpenPRByHeadBase(args: { repoPath: string - ownerRepo: OwnerRepo + repoArg: string head: string base: string connectionId?: string | null @@ -1771,7 +1785,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - `${args.ownerRepo.owner}/${args.ownerRepo.repo}`, + args.repoArg, '--head', args.head, '--base', @@ -1844,11 +1858,11 @@ export async function createGitHubPullRequest( } } - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - ) + // Why: github.com-only slug parsing returns null for GHES, so fall back to the + // enterprise resolver (gh-authenticated custom host) before giving up (#8312). + const ownerRepo = + (await getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))) ?? + (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) if (!ownerRepo) { return { ok: false, @@ -1856,6 +1870,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } + // Host-qualified for GHES so gh targets the Enterprise server, not github.com. + const repoArg = ghRepoArg(ownerRepo) const base = normalizeHostedReviewBaseRef(input.base) const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined @@ -1888,7 +1904,7 @@ export async function createGitHubPullRequest( 'pr', 'create', '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, + repoArg, '--base', base, '--title', @@ -1917,7 +1933,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, @@ -1941,7 +1957,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts new file mode 100644 index 00000000000..73de02ddae9 --- /dev/null +++ b/src/main/github/github-enterprise-repository.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn() +})) + +// Mock only the exec boundary so the real remote-identity parsing, runtime +// option resolution, and `gh auth status` parsing run against controlled output. +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetGitHubHostAuthCache, + getEnterpriseGitHubRepoSlug, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +function mockOriginRemote(url: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${url}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +// gh exit 0 for `auth status --hostname ` means logged in to that host. +function mockHostAuthenticated(host = 'github.acme-corp.com'): void { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `${host}\n ✓ Logged in to ${host} account kelora (keyring)`, + stderr: '' + }) +} + +// gh exits non-zero and reports no matching host when not logged in. +function mockHostNotAuthenticated(): void { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: 'You are not logged into any GitHub hosts. To log in, run: gh auth login' + }) + ) +} + +describe('getEnterpriseGitHubRepoSlug', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // The auth probe targets the remote's host, not a hardcoded github.com. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo' } + ) + }) + + it('resolves a GHES SCP-style SSH remote', async () => { + mockOriginRemote('git@github.acme-corp.com:team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + }) + + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await getEnterpriseGitHubRepoSlug('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + }) + + it('leaves github.com to getOwnerRepo without probing gh auth', async () => { + mockOriginRemote('https://github.com/team/orca.git') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('declines a custom host the user is not gh-authenticated to (leaves it for Gitea)', async () => { + mockOriginRemote('https://gitea.example.com/team/orca.git') + mockHostNotAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) + + it('returns null for an unparseable remote', async () => { + mockOriginRemote('not-a-remote-url') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('returns null when the origin remote lookup fails', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote')) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) +}) + +describe('isGitHubHostAuthenticated', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => { + mockHostAuthenticated() + + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') + ).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + {} + ) + }) + + it('caches per runtime+host so detection polling does not re-spawn gh', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not share cache state across WSL distros', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Ubuntu' }) + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Debian' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('treats a listed host as authenticated even when gh exits non-zero', async () => { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired' + }) + ) + + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true) + }) + + it('does not cache a hard gh failure so a later probe can recover', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false) + + mockHostAuthenticated() + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true) + }) +}) diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts new file mode 100644 index 00000000000..94cffe725fa --- /dev/null +++ b/src/main/github/github-enterprise-repository.ts @@ -0,0 +1,130 @@ +import { ghExecFileAsync } from '../git/runner' +import type { GitHubOwnerRepo } from '../../shared/types' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { parseAuthStatus } from './auth-diagnose' +import { + ghRepoExecOptions, + getRemoteUrlForRepo, + githubRepoContext, + parseGitHubRemoteIdentity, + type LocalGitExecOptions +} from './github-repository-identity' + +export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } + +// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a +// host `gh auth status` reports as logged-in is definitively a GitHub host. This +// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a +// GHES remote is not left to fall through to Gitea (#8312). +const HOST_AUTH_TTL_MS = 60_000 + +type HostAuthCacheEntry = { + authenticated: boolean + expiresAt: number +} + +const hostAuthCache = new Map() + +// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an +// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the +// runtime that executes gh, not shared under one "local" bucket. Mirrors the +// runtime scoping used by owner/repo resolution. +function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { + return connectionId ?? `local:${wslDistro ?? 'host'}` +} + +/** @internal - exposed for tests only */ +export function _resetGitHubHostAuthCache(): void { + hostAuthCache.clear() +} + +// Only gh's own stdout/stderr — not the Error.message — counts as an +// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a +// message and no command output, and must stay indeterminate rather than be +// read as "host not authenticated". +function ghCommandOutput(error: unknown): string { + const execErr = error as { stdout?: unknown; stderr?: unknown } + return [execErr?.stdout, execErr?.stderr] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') +} + +/** + * Whether `gh` is authenticated to `host` from the repository's own runtime. + * + * The probe runs `gh auth status --hostname ` with the repo's execution + * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login + * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred + * from it — is honored instead of the host/default-distro gh. Cached briefly per + * runtime+host so provider-detection polling does not re-spawn gh each time. + */ +export async function isGitHubHostAuthenticated( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = host.toLowerCase() + const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticated + } + const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + let authenticated: boolean + try { + await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) + authenticated = true + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return false + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; treat the host as GitHub only when it is actually listed. + authenticated = parseAuthStatus(output).some( + (account) => account.host.toLowerCase() === normalizedHost + ) + } + hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) + return authenticated +} + +/** + * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom + * host the user is gh-authenticated to. Returns null for github.com (already + * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider + * without a GitHub provider stealing another forge's remote. + */ +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitOptions = getHostedReviewLocalGitOptions(options) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + let remoteUrl: string | null + try { + remoteUrl = await getRemoteUrlForRepo(context, 'origin') + } catch { + return null + } + const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null + if (!identity || identity.host === 'github.com') { + return null + } + const authenticated = await isGitHubHostAuthenticated( + identity.host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null +} diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 0317d6fc9e9..95c5c9d2676 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -11,7 +11,8 @@ const { getMergeRequestForBranchMock, getProjectSlugMock, getPRForBranchOutcomeMock, - getRepoSlugMock + getRepoSlugMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -23,7 +24,8 @@ const { getMergeRequestForBranchMock: vi.fn(), getProjectSlugMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), - getRepoSlugMock: vi.fn() + getRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ @@ -42,6 +44,10 @@ vi.mock('../github/client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock, getBitbucketPullRequestForBranch: vi.fn(), @@ -88,6 +94,7 @@ describe('forge provider interface', () => { getProjectSlugMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() }) it('preserves the existing hosted provider detection order', async () => { @@ -101,6 +108,45 @@ describe('forge provider interface', () => { expect(getRepoSlugMock).not.toHaveBeenCalled() }) + it('detects a GitHub Enterprise Server remote as the GitHub provider, not Gitea', async () => { + // Regression for #8312: a GHES host is not github.com, so github.com-only + // slug parsing returns null. Detection must claim it via the enterprise + // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('github') + await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({ + id: 'github' + }) + // Gitea must never be consulted once GitHub claims the enterprise host. + expect(getGiteaRepoSlugMock).not.toHaveBeenCalled() + }) + + it('leaves a genuinely non-GitHub remote for later providers when gh is not authenticated', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + // gh is not logged in to this host, so the enterprise resolver declines and + // the Gitea provider is free to claim its own self-hosted remote. + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'gitea.example.com', + owner: 'team', + repo: 'orca', + apiBaseUrl: 'https://gitea.example.com/api/v1', + webBaseUrl: 'https://gitea.example.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitea') + }) + it('keeps review creation capability scoped to providers with creation support', async () => { expect( FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation]) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index d87efe9dcf1..a7d22ad48ae 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,6 +22,7 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -122,8 +123,25 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: (context) => - getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), + resolveRepository: async (context) => { + const slug = await getRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + if (slug) { + return slug + } + // Why: GHES remotes live on a custom host, so github.com-only slug parsing + // misses them and detection would otherwise fall through to Gitea (#8312). + // Claim the repo when gh is authenticated to its host — the same signal + // GitLab uses for self-hosted instances. + return getEnterpriseGitHubRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + }, async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index c03447d4043..3ae82fd4010 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -17,7 +17,8 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -35,7 +36,8 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -44,6 +46,10 @@ vi.mock('../github/client', () => ({ getPRForBranch: vi.fn() })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: vi.fn(), @@ -129,7 +135,8 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() } @@ -141,6 +148,22 @@ function mockGitHubProvider(): void { getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) } function mockGitLabProvider(): void { @@ -349,6 +372,29 @@ describe('createHostedReview', () => { ) }) + it('creates a pull request on a GitHub Enterprise Server remote (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + + // Detection already confirmed gh is authed to the GHES host, so the auth + // gate must not fire a second (rate-limited) gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(createGitHubPullRequestMock).toHaveBeenCalled() + expect(createGiteaPullRequestMock).not.toHaveBeenCalled() + }) + it('creates a GitLab merge request after fresh main-process validation passes', async () => { mockGitLabProvider() @@ -636,6 +682,31 @@ describe('getHostedReviewCreationEligibility', () => { }) }) + it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null + }) + + // Enterprise auth was already confirmed during detection; the gate must not + // fire a redundant gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { const remoteGit = { exec: vi.fn(async () => ({ stdout: '', stderr: '' })) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 7b6709d2dd3..01caac804d8 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -18,6 +18,7 @@ import { } from '../../shared/hosted-review-creation-providers' import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation' import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -59,6 +60,14 @@ async function isGitHubAuthenticated( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { + // Why: a GHES remote is only routed to the GitHub provider once detection has + // confirmed gh is authenticated to its enterprise host, so a non-null slug + // already means authenticated — skip a redundant, rate-limited gh probe. + // Reaching the github.com check below therefore means the remote is github.com + // (its own custom host would have resolved above) (#8312). + if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) { + return true + } await acquire() try { await ghExecFileAsync( From 5886ffab63efd2bbc241957e1c916a5097f61c65 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:13:36 -0700 Subject: [PATCH 22/52] Fix orchestration skill coverage for provider-home skill roots (#8256) (#8510) Co-authored-by: Orca --- src/main/skills/discovery.test.ts | 47 +++++++++ src/main/skills/skill-discovery-sources.ts | 18 +++- .../lib/orchestration-skill-coverage.test.ts | 98 ++++++++++++++++++- .../src/lib/orchestration-skill-coverage.ts | 53 +++++++++- 4 files changed, 213 insertions(+), 3 deletions(-) diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index dcee058f8e2..68ff5e9d017 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -57,6 +57,32 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) + it('scans each provider home skill root that npx skills --global writes to', () => { + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current' + }) + + const rootPaths = roots.map((root) => root.path.replace(/\\/g, '/')) + expect(rootPaths).toEqual( + expect.arrayContaining([ + '/home/test/.grok/skills', + '/home/test/.config/opencode/skills', + '/home/test/.pi/agent/skills', + '/home/test/.gemini/skills', + '/home/test/.gemini/antigravity/skills', + '/home/test/.cursor/skills' + ]) + ) + // Why: these live outside ~/.agents/skills, so they must carry the shared + // agent-skills provider to feed per-agent orchestration coverage. + for (const root of roots) { + if (root.path.replace(/\\/g, '/') === '/home/test/.grok/skills') { + expect(root.providers).toEqual(['agent-skills']) + } + } + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') @@ -77,6 +103,27 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers a symlinked skill inside a provider home root (#8256/#8503)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const realSkill = join(root, 'central-skills', 'orchestration') + const linkedSkill = join(home, '.pi', 'agent', 'skills', 'orchestration') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(home, '.pi', 'agent', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# orchestration\n\nCoordinate agents.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + const skill = result.skills.find((entry) => entry.name === 'orchestration') + expect(skill?.sourceKind).toBe('home') + expect(skill?.directoryPath).toBe(linkedSkill) + expect(skill?.providers).toEqual(['agent-skills']) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index c1bb1072b2e..bec34117c4c 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -41,7 +41,23 @@ export function buildSkillDiscoverySources( join(home, '.codex', 'plugins', 'cache'), 'plugin', ['codex', 'agent-skills'] - ) + ), + // Why: `npx skills add --global` writes into each agent's own home skills + // directory, so coverage misses them unless we scan every provider root. + source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), + source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), + source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-antigravity', + 'Antigravity home', + join(home, '.gemini', 'antigravity', 'skills'), + 'home', + ['agent-skills'] + ), + source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) ] const projectPaths = new Set() diff --git a/src/renderer/src/lib/orchestration-skill-coverage.test.ts b/src/renderer/src/lib/orchestration-skill-coverage.test.ts index aea4a26a975..bcb68ff74b5 100644 --- a/src/renderer/src/lib/orchestration-skill-coverage.test.ts +++ b/src/renderer/src/lib/orchestration-skill-coverage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { DiscoveredSkill } from '../../../shared/skills' +import type { TuiAgent } from '../../../shared/types' import { agentHasOrchestrationSkill, getOrchestrationSkillAgentStatuses @@ -97,7 +98,102 @@ describe('orchestration skill agent coverage', () => { ).toBe(true) }) - it('matches Windows skill paths', () => { + it('marks each provider-home agent from its own global skills location', () => { + const cases: { agent: TuiAgent; rootPath: string; directoryPath: string }[] = [ + { + agent: 'grok', + rootPath: '/Users/test/.grok/skills', + directoryPath: '/Users/test/.grok/skills/orchestration' + }, + { + agent: 'opencode', + rootPath: '/Users/test/.config/opencode/skills', + directoryPath: '/Users/test/.config/opencode/skills/orchestration' + }, + { + agent: 'pi', + rootPath: '/Users/test/.pi/agent/skills', + directoryPath: '/Users/test/.pi/agent/skills/orchestration' + }, + { + agent: 'gemini', + rootPath: '/Users/test/.gemini/skills', + directoryPath: '/Users/test/.gemini/skills/orchestration' + }, + { + agent: 'antigravity', + rootPath: '/Users/test/.gemini/antigravity/skills', + directoryPath: '/Users/test/.gemini/antigravity/skills/orchestration' + }, + { + agent: 'cursor', + rootPath: '/Users/test/.cursor/skills', + directoryPath: '/Users/test/.cursor/skills/orchestration' + } + ] + for (const { agent, rootPath, directoryPath } of cases) { + const skills = [ + skill({ providers: ['agent-skills'], sourceKind: 'home', rootPath, directoryPath }) + ] + expect(agentHasOrchestrationSkill(agent, skills)).toBe(true) + // Why: a provider-home install must not leak coverage to unrelated agents. + expect(agentHasOrchestrationSkill('claude', skills)).toBe(false) + } + }) + + it('marks a multi-segment provider-home agent from a Windows-style path', () => { + expect( + agentHasOrchestrationSkill('opencode', [ + skill({ + providers: ['agent-skills'], + sourceKind: 'home', + rootPath: 'C:\\Users\\test\\.config\\opencode\\skills', + directoryPath: 'C:\\Users\\test\\.config\\opencode\\skills\\orchestration' + }) + ]) + ).toBe(true) + }) + + it('keeps Gemini and Antigravity distinct despite sharing the ~/.gemini root', () => { + const geminiInstall = [ + skill({ + providers: ['agent-skills'], + sourceKind: 'home', + rootPath: '/Users/test/.gemini/skills', + directoryPath: '/Users/test/.gemini/skills/orchestration' + }) + ] + const antigravityInstall = [ + skill({ + providers: ['agent-skills'], + sourceKind: 'home', + rootPath: '/Users/test/.gemini/antigravity/skills', + directoryPath: '/Users/test/.gemini/antigravity/skills/orchestration' + }) + ] + + // Why: `.gemini/skills` and `.gemini/antigravity/skills` are siblings, so a + // segment matcher must not let one provider's install mark the other. + expect(agentHasOrchestrationSkill('gemini', geminiInstall)).toBe(true) + expect(agentHasOrchestrationSkill('antigravity', geminiInstall)).toBe(false) + expect(agentHasOrchestrationSkill('antigravity', antigravityInstall)).toBe(true) + expect(agentHasOrchestrationSkill('gemini', antigravityInstall)).toBe(false) + }) + + it('marks Claude Agent Teams from ~/.claude/skills like Claude Code', () => { + const skills = [ + skill({ + providers: ['claude'], + sourceKind: 'home', + rootPath: '/Users/test/.claude/skills', + directoryPath: '/Users/test/.claude/skills/orchestration' + }) + ] + + expect(agentHasOrchestrationSkill('claude-agent-teams', skills)).toBe(true) + }) + + it('marks Windows skill paths', () => { expect( agentHasOrchestrationSkill('codex', [ skill({ diff --git a/src/renderer/src/lib/orchestration-skill-coverage.ts b/src/renderer/src/lib/orchestration-skill-coverage.ts index 4cabdb095a6..28300112837 100644 --- a/src/renderer/src/lib/orchestration-skill-coverage.ts +++ b/src/renderer/src/lib/orchestration-skill-coverage.ts @@ -9,6 +9,12 @@ export type OrchestrationSkillLocationId = | 'codex-home' | 'codex-plugin-cache' | 'agents-home' + | 'grok-home' + | 'opencode-home' + | 'pi-home' + | 'gemini-home' + | 'antigravity-home' + | 'cursor-home' export type OrchestrationSkillAgentStatus = { agent: TuiAgent @@ -45,6 +51,43 @@ const ORCHESTRATION_SKILL_LOCATIONS: readonly OrchestrationSkillLocationDefiniti matchesSkill: (skill) => isGlobalOrchestrationSkill(skill) && pathContainsSegments(skill.rootPath, ['.agents', 'skills']) + }, + { + id: 'grok-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && pathContainsSegments(skill.rootPath, ['.grok', 'skills']) + }, + { + id: 'opencode-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && + pathContainsSegments(skill.rootPath, ['.config', 'opencode', 'skills']) + }, + { + id: 'pi-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && + pathContainsSegments(skill.rootPath, ['.pi', 'agent', 'skills']) + }, + { + // Why: segment matching keeps `.gemini/skills` (Gemini CLI) distinct from the + // sibling `.gemini/antigravity/skills`, so the two never cross-mark each other. + id: 'gemini-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && + pathContainsSegments(skill.rootPath, ['.gemini', 'skills']) + }, + { + id: 'antigravity-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && + pathContainsSegments(skill.rootPath, ['.gemini', 'antigravity', 'skills']) + }, + { + id: 'cursor-home', + matchesSkill: (skill) => + isGlobalOrchestrationSkill(skill) && + pathContainsSegments(skill.rootPath, ['.cursor', 'skills']) } ] @@ -52,8 +95,16 @@ const ORCHESTRATION_SKILL_LOCATION_IDS_BY_AGENT: Partial< Record > = { claude: ['claude-home', 'agents-home'], + // Why: Agent Teams runs Claude Code, so it reads the same ~/.claude skills. + 'claude-agent-teams': ['claude-home', 'agents-home'], openclaude: ['claude-home', 'agents-home'], - codex: ['codex-home', 'codex-plugin-cache', 'agents-home'] + codex: ['codex-home', 'codex-plugin-cache', 'agents-home'], + grok: ['grok-home', 'agents-home'], + opencode: ['opencode-home', 'agents-home'], + pi: ['pi-home', 'agents-home'], + gemini: ['gemini-home', 'agents-home'], + antigravity: ['antigravity-home', 'agents-home'], + cursor: ['cursor-home', 'agents-home'] } function normalizeSkillName(value: string): string { From f96fa32ad8d5ec1c9568916def858bef24d8c2d0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:14:50 -0700 Subject: [PATCH 23/52] fix(codex): debounce auto-resolved 'Approve for me' approval notifications (#8387) (#8519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): debounce auto-resolved approval notifications (#8387) Codex fires its PermissionRequest hook at the human-input boundary before the approval decision, so under 'Approve for me' the review agent approves and Codex resumes within ~1s. The completion coordinator dispatched the OS attention notification immediately on that 'waiting'/'blocked' hook, so the self-resolving pause raised a false 'approval required' notification. Debounce the Codex OS attention notification behind a 1500ms quiet window and cancel it when a working/completion hook lands inside the window. Scoped strictly to agentType==='codex'; every other agent's pause still notifies immediately. The visual sidebar status is set upstream (store.setAgentStatus) before the coordinator runs, so it stays immediate. Co-authored-by: Orca * review: harden #8387 fix — protect debounce evidence + cancel on title resume Two adversarial-review hardening fixes to the Codex attention debounce, both preserving the fail-open contract (a genuine 'needs input' pause must always notify): - handleProcessInspectionResult: extend the evidence-teardown guard to also hold while a pendingCodexAttentionTimer is armed. A transient null/shell foreground blip (or a remote/SSH inspection that returns null foreground) could otherwise tear down agent evidence mid-window and make the timer's hasAgentRunEvidence guard silently drop a genuine pause banner — or fire a false process-exit completion racing the pause. Mirrors the pendingHookDone guard. - recordTitleWorking: cancel the debounced attention on a genuine title-driven resume (placed after the replay guard, so a stale post-completion title replay never drops a still-pending genuine pause banner). Adds 6 discriminating fake-timer tests (guard, title resume, blocked parity, done cancels attention, dispose clears timer, second distinct pause re-arms). Co-authored-by: Orca --------- Co-authored-by: Orca --- .../agent-completion-coordinator.test.ts | 278 ++++++++++++++++++ .../agent-completion-coordinator.ts | 70 ++++- ...gent-hook-completion-notifications.test.ts | 88 ++---- 3 files changed, 372 insertions(+), 64 deletions(-) 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 d48f509c9ac..6a4c8b197cf 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 @@ -40,6 +40,7 @@ function createRejectableDeferred(): { } const HOOK_DONE_QUIET_MS = 1_500 +const CODEX_ATTENTION_QUIET_MS = 1_500 describe('agent completion coordinator', () => { beforeEach(() => { @@ -1638,6 +1639,283 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) }) + it('cancels the debounced Codex attention notification when work resumes in the quiet window', () => { + // Why: Codex fires PermissionRequest at the human-input boundary *before* the + // approval decision. Under "Approve for me" the review agent approves and + // Codex resumes within the quiet window, so the OS notification must be + // debounced and canceled — no false "approval required" banner (issue #8387). + const dispatchAttention = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + dispatchHookLifecycle, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'git status' + }) + + // Visual status still updates immediately even though the notification waits. + expect(dispatchHookLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ state: 'waiting', agentType: 'codex' }) + ) + expect(dispatchAttention).not.toHaveBeenCalled() + + coordinator.observeHookStatus({ + state: 'working', + ...turn, + toolName: 'exec_command', + toolInput: 'git status' + }) + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + + expect(dispatchAttention).not.toHaveBeenCalled() + }) + + it('dispatches the debounced Codex attention notification after the quiet window elapses', () => { + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'apply patch' + }) + expect(dispatchAttention).not.toHaveBeenCalled() + + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(dispatchAttention).toHaveBeenCalledWith( + 'codex', + expect.objectContaining({ + source: 'hook', + agentStatus: expect.objectContaining({ + state: 'waiting', + agentType: 'codex', + toolInput: 'apply patch' + }) + }) + ) + }) + + it('dispatches a non-Codex attention notification immediately without debounce', () => { + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'cursor' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'Shell', + toolInput: 'pnpm test' + }) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + // Non-Codex attention must not arm the debounce timer at all. + expect(vi.getTimerCount()).toBe(0) + }) + + it('debounces a blocked Codex pause like waiting and fires after the quiet window', () => { + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'blocked', ...turn, toolName: 'exec_command' }) + expect(dispatchAttention).not.toHaveBeenCalled() + + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + expect(dispatchAttention).toHaveBeenCalledTimes(1) + }) + + it('cancels the debounced Codex attention when a completion lands in the window (no double notify)', () => { + const dispatchAttention = vi.fn() + const dispatchCompletion = 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: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) + // A 'done' completing the turn inside the window must cancel the pending + // attention so the pause never co-fires with the completion notification. + coordinator.observeHookStatus({ state: 'done', ...turn }) + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + + expect(dispatchAttention).not.toHaveBeenCalled() + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears the pending Codex attention timer on dispose (no leak, no late fire)', () => { + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) + expect(vi.getTimerCount()).toBe(1) + + coordinator.dispose() + expect(vi.getTimerCount()).toBe(0) + + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + expect(dispatchAttention).not.toHaveBeenCalled() + }) + + it('re-arms and fires a second distinct Codex pause after work resumed', () => { + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + // First pause auto-resolves before the window elapses. + coordinator.observeHookStatus({ state: 'working', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + expect(dispatchAttention).not.toHaveBeenCalled() + + // A later, genuinely-distinct pause must re-arm the debounce and fire. + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'apply_patch', toolInput: 'diff' }) + expect(dispatchAttention).not.toHaveBeenCalled() + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + expect(dispatchAttention).toHaveBeenCalledTimes(1) + }) + + it('cancels the debounced Codex attention when a working-spinner title resumes', () => { + // Why: a Codex resume can surface as a working title before the resume + // 'working' hook lands; that title must also cancel the pending attention + // so the self-resolving pause never fires a false banner (issue #8387). + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => true + }) + + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) + expect(vi.getTimerCount()).toBe(1) + + coordinator.observeTitleWorking() + expect(vi.getTimerCount()).toBe(0) + + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + expect(dispatchAttention).not.toHaveBeenCalled() + }) + + it('does not let a null-foreground inspection blip drop the debounced Codex attention', async () => { + // Why: guard for #8387 fail-open. In the pty-connection coordinator (real + // process polling), a transient null/shell foreground blip — or a remote + // inspection that cannot resolve the foreground — must not convert a genuine + // Codex pause into a process-exit completion while the attention debounce is + // still pending. Mirrors the pendingHookDoneTimer evidence-teardown guard. + let foreground: string | null = 'codex' + const dispatchAttention = vi.fn() + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foreground)), + dispatchCompletion, + dispatchAttention, + isLive: () => true + }) + + coordinator.startProcessTracking() + // First cadence poll recognizes Codex as the foreground agent (active tier). + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks() + + // Codex pauses for a permission decision: the OS attention is debounced. + const turn = { prompt: 'apply patch', agentType: 'codex' as const } + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'rm -rf build' + }) + expect(dispatchAttention).not.toHaveBeenCalled() + + // Foreground reads null for the whole window; without the guard this would + // land a false process-exit completion racing/duplicating the pause banner. + foreground = null + await vi.advanceTimersByTimeAsync(CODEX_ATTENTION_QUIET_MS + 100) + await flushAsyncTicks() + + expect(dispatchCompletion).not.toHaveBeenCalled() + expect(dispatchAttention).toHaveBeenCalledTimes(1) + }) + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { const inspection = createDeferred() const dispatchCompletion = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index fe3e8208ada..f71141e1045 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -61,6 +61,12 @@ const PENDING_TITLE_TTL_MS = Math.max(2_000, INSPECTION_TIMEOUT_MS + 500) const PENDING_TITLE_MAX_TTL_MS = Math.max(30_000, PENDING_TITLE_TTL_MS) const COMPLETION_REPLAY_GUARD_MS = 1_000 const HOOK_DONE_QUIET_MS = 1_500 +// Why: Codex fires its PermissionRequest hook at the human-input boundary before +// the approval decision, so under "Approve for me" the review agent approves and +// Codex resumes almost immediately. Debounce the OS attention notification behind +// this quiet window so a self-resolving pause never raises a false "approval +// required" banner (issue #8387). The visual status still updates immediately. +const CODEX_ATTENTION_QUIET_MS = 1_500 const POLL_TIER_INTERVAL_MS: Record = { active: ACTIVE_POLL_INTERVAL_MS, @@ -106,6 +112,7 @@ export function createAgentCompletionCoordinator( let pendingHookDoneTimer: ReturnType | null = null let pendingHookDoneTitle: string | null = null let pendingHookDonePayload: AgentCompletionStatusSnapshot | null = null + let pendingCodexAttentionTimer: ReturnType | null = null let pendingProcessExitAgent: RecognizedAgentProcess | null = null let pendingTitleSequence = 0 let pendingTitle: { @@ -155,6 +162,13 @@ export function createAgentCompletionCoordinator( pendingHookDonePayload = null } + function clearPendingCodexAttention(): void { + if (pendingCodexAttentionTimer !== null) { + clearTimeout(pendingCodexAttentionTimer) + pendingCodexAttentionTimer = null + } + } + function establishAgentEvidence(): void { agentIdentityEstablished = true hasAgentRunEvidence = true @@ -304,6 +318,9 @@ export function createAgentCompletionCoordinator( lastCompletedTurn = currentTurn lastCompletionSource = source workingStatusObserved = false + // Why: any committed completion (hook/title/process-exit) ends the turn, so a + // debounced Codex attention from an earlier pause must never fire after it. + clearPendingCodexAttention() if (optionsOverride.completionIdentity) { lastCompletionIdentityByPaneKey.set(options.paneKey, optionsOverride.completionIdentity) } @@ -324,6 +341,13 @@ export function createAgentCompletionCoordinator( } } + function dispatchAttentionNotification(payload: AgentCompletionStatusSnapshot): void { + options.dispatchAttention?.(payload.agentType ?? options.paneKey, { + source: 'hook', + agentStatus: payload + }) + } + function dispatchAttention(payload: AgentCompletionStatusSnapshot): void { if (!options.dispatchAttention || !options.isLive() || !hasAgentRunEvidence) { return @@ -333,11 +357,25 @@ export function createAgentCompletionCoordinator( return } lastAttentionToken = token + // Why: the visual "needs input" status must update immediately for every + // agent; only the OS attention notification is debounced (Codex, below). options.dispatchHookLifecycle?.(payload) - options.dispatchAttention(payload.agentType ?? options.paneKey, { - source: 'hook', - agentStatus: payload - }) + if (payload.agentType === 'codex') { + // Why: a Codex PermissionRequest that "Approve for me" auto-resolves fires a + // working/completion hook inside this window, which cancels the pending + // notification (see CODEX_ATTENTION_QUIET_MS). Scoped to Codex so every other + // agent's genuine pause still notifies immediately. + clearPendingCodexAttention() + pendingCodexAttentionTimer = setTimeout(() => { + pendingCodexAttentionTimer = null + if (!options.isLive() || !hasAgentRunEvidence) { + return + } + dispatchAttentionNotification(payload) + }, CODEX_ATTENTION_QUIET_MS) + return + } + dispatchAttentionNotification(payload) } function scheduleHookDoneCompletion(title: string, payload: AgentCompletionStatusSnapshot): void { @@ -472,9 +510,12 @@ export function createAgentCompletionCoordinator( handleRecognizedProcess(recognized) return true } - if (pendingHookDoneTimer !== null) { - // Why: a pending quiet-window 'done' is the authoritative completion; - // tearing down agent evidence here would make the timer drop it. + if (pendingHookDoneTimer !== null || pendingCodexAttentionTimer !== null) { + // Why: a pending quiet-window 'done' or debounced Codex attention is the + // authoritative signal for this turn; tearing down agent evidence here (a + // transient null/shell foreground blip before the agent process is + // recognized) would make the timer's hasAgentRunEvidence guard silently + // drop it. Keep the fail-open contract for #8387 as for the done timer. scheduleNextPoll() return false } @@ -696,6 +737,12 @@ export function createAgentCompletionCoordinator( ) { return false } + // Why: a genuine Codex resume can surface as a working-spinner title before + // (or instead of) the resume 'working' hook, so cancel the debounced + // attention here or the self-resolving pause still fires a false banner + // (#8387). Placed after the replay guard so only an authoritative resume — + // not a stale post-completion title replay — drops a still-pending banner. + clearPendingCodexAttention() workingStatusObserved = true requiresFreshWorking = false lastCompletionIdentityByPaneKey.delete(options.paneKey) @@ -789,6 +836,7 @@ export function createAgentCompletionCoordinator( // so the quiet-window timer never fires a false completion notification. if (isAttentionHookState(payload.state)) { clearPendingHookDone() + clearPendingCodexAttention() } return } @@ -797,6 +845,9 @@ export function createAgentCompletionCoordinator( } if (payload.state === 'working') { clearPendingHookDone() + // Why: resumed work (e.g. Codex after "Approve for me") cancels the debounced + // attention notification so the self-resolving pause never notifies. + clearPendingCodexAttention() workingStatusObserved = true requiresFreshWorking = false lastCompletionIdentity = null @@ -814,6 +865,9 @@ export function createAgentCompletionCoordinator( return } if (isCompletionHookState(payload.state)) { + // Why: the turn is ending, so a debounced attention from an earlier pause in + // this turn must not fire after the completion notification. + clearPendingCodexAttention() if (isRecognizedAgentType(payload.agentType)) { establishAgentEvidence() } @@ -885,6 +939,7 @@ export function createAgentCompletionCoordinator( function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void { clearPendingHookDone() + clearPendingCodexAttention() dropPendingTitle() agentIdentityEstablished = false hasAgentRunEvidence = false @@ -905,6 +960,7 @@ export function createAgentCompletionCoordinator( disposed = true clearPollTimer() clearPendingHookDone() + clearPendingCodexAttention() dropPendingTitle() // Why: the dedup identity is module-scoped so it survives a live-stream remount // (dispose-then-recreate with the same paneKey while isLive() stays true). Only diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 75487c039b9..1801c78bace 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -56,6 +56,9 @@ type MockStoreState = { let mockStoreState: MockStoreState const HOOK_DONE_QUIET_MS = 1_500 +// Why: Codex attention notifications are debounced (issue #8387), so a genuine +// permission pause only notifies once this quiet window elapses without resuming. +const CODEX_ATTENTION_QUIET_MS = 1_500 vi.mock('@/store', () => ({ useAppStore: { @@ -106,6 +109,31 @@ function seedCodexPaneLaunchConfig( describe('agent hook completion notifications', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + // Why: the Codex permission-pause tests share a working→pause→quiet-window + // sequence; centralizing it keeps the debounce advance (issue #8387) in one spot. + async function observeCodexPermissionPause(state: 'waiting' | 'blocked'): Promise { + const { observeAgentHookCompletionForNotification } = await import( + './agent-hook-completion-notifications' + ) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: hookStatus('working') + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state, + prompt: 'implement notifications', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + } + }) + vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + } + beforeEach(() => { vi.resetModules() vi.useFakeTimers() @@ -560,50 +588,14 @@ describe('agent hook completion notifications', () => { it('fails open for Codex auto-approved permission requests without launch proof', async () => { seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') - const { observeAgentHookCompletionForNotification } = - await import('./agent-hook-completion-notifications') - - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: hookStatus('working') - }) - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: { - state: 'waiting', - prompt: 'implement notifications', - agentType: 'codex', - toolName: 'exec_command', - toolInput: 'git status' - } - }) + await observeCodexPermissionPause('waiting') expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) it('still notifies for manual Codex permission requests', async () => { seedCodexPaneLaunchConfig(paneKey, '') - const { observeAgentHookCompletionForNotification } = - await import('./agent-hook-completion-notifications') - - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: hookStatus('working') - }) - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: { - state: 'waiting', - prompt: 'implement notifications', - agentType: 'codex', - toolName: 'exec_command', - toolInput: 'git status' - } - }) + await observeCodexPermissionPause('waiting') expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) expect(dispatchTerminalNotification).toHaveBeenCalledWith( @@ -618,25 +610,7 @@ describe('agent hook completion notifications', () => { it('fails open for Codex auto-approved blocked permission requests without launch proof', async () => { seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') - const { observeAgentHookCompletionForNotification } = - await import('./agent-hook-completion-notifications') - - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: hookStatus('working') - }) - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: 'wt-1', - payload: { - state: 'blocked', - prompt: 'implement notifications', - agentType: 'codex', - toolName: 'exec_command', - toolInput: 'git status' - } - }) + await observeCodexPermissionPause('blocked') expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) From b667b72eb227471621de7ed560c34aa876cf2707 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:15:56 -0700 Subject: [PATCH 24/52] Route diffs by explicit worktree owner, not focused runtime (#8484) (#8509) Co-authored-by: Orca --- src/renderer/src/store/slices/editor.test.ts | 62 +++++++++++++++++++- src/renderer/src/store/slices/editor.ts | 10 ++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index ac54883ebe3..638fc8cb674 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -456,7 +456,7 @@ describe('createEditorSlice openDiff', () => { expect(store.getState().openFiles).toEqual([ expect.objectContaining({ id: 'wt-1::diff::unstaged::file.ts', - runtimeEnvironmentId: undefined + runtimeEnvironmentId: null }), expect.objectContaining({ id: 'editor-diff:wt-1:env-1:unstaged:file.ts', @@ -498,6 +498,66 @@ describe('createEditorSlice openDiff', () => { ) }) + it('keeps a diff for an owner-less worktree off the focused global runtime', () => { + const store = createEditorStore() + // A remote runtime is globally focused, but wt-1's repo names no explicit + // owner. The diff must stamp null (not undefined): null forces a LOCAL read + // in settingsForRuntimeOwner, while undefined would inherit 'focused-env'. + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as AppState['settings'] + }) + + store.getState().openDiff('wt-1', '/repo/file.ts', 'file.ts', 'typescript', false) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::diff::unstaged::file.ts', + runtimeEnvironmentId: null + }) + ) + }) + + it('routes an explicitly runtime-owned worktree diff to its owner over the focused runtime', () => { + const store = createEditorStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as AppState['settings'], + repos: [ + { id: 'repo-1', executionHostId: 'runtime:owner-env' } + ] as unknown as AppState['repos'], + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/srv/wt', repoId: 'repo-1', hostId: 'runtime:owner-env' }] + } as unknown as AppState['worktreesByRepo'] + }) + + store.getState().openDiff('repo-1::/srv/wt', '/srv/wt/file.ts', 'file.ts', 'typescript', false) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ runtimeEnvironmentId: 'owner-env' }) + ) + }) + + it('keeps an SSH-owned worktree diff off the focused runtime so it routes via its connection', () => { + const store = createEditorStore() + // An SSH worktree is owned by its connection, not the focused runtime. Its + // diff stamps null (not the focused env), so the read targets local IPC and + // flows over connectionId rather than the focused runtime's RPC. + store.setState({ + settings: { activeRuntimeEnvironmentId: 'focused-env' } as AppState['settings'], + repos: [{ id: 'repo-ssh', connectionId: 'conn-1' }] as unknown as AppState['repos'], + worktreesByRepo: { + 'repo-ssh': [{ id: 'repo-ssh::/srv/wt', repoId: 'repo-ssh', hostId: 'ssh:conn-1' }] + } as unknown as AppState['worktreesByRepo'] + }) + + store + .getState() + .openDiff('repo-ssh::/srv/wt', '/srv/wt/file.ts', 'file.ts', 'typescript', false) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ runtimeEnvironmentId: null }) + ) + }) + it('repairs an existing diff tab entry to the correct mode and staged state', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 7e825ec6f77..07bbab69f1c 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -63,7 +63,7 @@ import { import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -335,9 +335,11 @@ function resolveDiffRuntimeEnvironmentId( if (explicitRuntimeEnvironmentId !== undefined) { return explicitRuntimeEnvironmentId } - // Why: Source Control callers often know only the worktree. Runtime-host - // diffs still need their owner stamped so content loads through runtime RPC. - return getRuntimeEnvironmentIdForWorktree(state, worktreeId) ?? undefined + // Why: route diffs by the worktree's EXPLICIT owner (#6957); owner-less/local + // resolves to null so the read is forced LOCAL. undefined would instead + // inherit the focused runtime in settingsForRuntimeOwner and read the diff + // from the wrong host — the exact bug in #8484. + return getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) ?? null } export type PendingEditorReveal = { From 2266c1b03a91ccdfa1cf65b733eb268748eadc7a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:16:33 -0700 Subject: [PATCH 25/52] fix(sidebar): scope project display labels by execution host to prevent cross-host path collision (#8345) (#8511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): scope project display labels by execution host to prevent cross-host path collision (#8345) Co-authored-by: Orca * review: harden #8345 fix — pin cross-host key separateness for identical path+name Co-authored-by: Orca --------- Co-authored-by: Orca --- .../components/repo/NestedRepoChecklist.tsx | 4 +- .../sidebar/worktree-list-groups.ts | 6 +- .../src/lib/repo-display-labels.test.ts | 61 ++++++++++++++++--- src/renderer/src/lib/repo-display-labels.ts | 19 +++++- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/repo/NestedRepoChecklist.tsx b/src/renderer/src/components/repo/NestedRepoChecklist.tsx index dd20b9781aa..95ea1a41897 100644 --- a/src/renderer/src/components/repo/NestedRepoChecklist.tsx +++ b/src/renderer/src/components/repo/NestedRepoChecklist.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { GitBranch } from 'lucide-react' import type { NestedRepoScanResult } from '../../../../shared/types' import { cn } from '@/lib/utils' -import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' function NestedRepoSelectAllRow({ @@ -118,7 +118,7 @@ export function NestedRepoChecklist({ selectedPaths.has(repo.path) ? 'text-foreground' : 'text-muted-foreground' )} > - {displayLabelsByPath.get(repo.path) ?? repo.displayName} + {displayLabelsByPath.get(getRepoDisplayLabelKey(repo)) ?? repo.displayName} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index e7fb1749ed4..df31f09f8fb 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -32,7 +32,7 @@ import { import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' -import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' @@ -709,7 +709,9 @@ function withRepoSectionDisplayLabels(entries: readonly OrderedGroupEntry[]): Or const labelsByPath = getRepoDisplayLabelsByPath(repos) return entries.map(([key, group]) => [ key, - group.repo ? { ...group, label: labelsByPath.get(group.repo.path) ?? group.label } : group + group.repo + ? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label } + : group ]) } diff --git a/src/renderer/src/lib/repo-display-labels.test.ts b/src/renderer/src/lib/repo-display-labels.test.ts index f0007ffc114..4935f2da1f8 100644 --- a/src/renderer/src/lib/repo-display-labels.test.ts +++ b/src/renderer/src/lib/repo-display-labels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getRepoDisplayLabelsByPath } from './repo-display-labels' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from './repo-display-labels' describe('getRepoDisplayLabelsByPath', () => { it('keeps non-colliding repository names basename-only', () => { @@ -8,8 +8,10 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/platform/worker', displayName: 'worker' } ]) - expect(labels.get('/workspace/platform/web')).toBe('web') - expect(labels.get('/workspace/platform/worker')).toBe('worker') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/web' }))).toBe('web') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/worker' }))).toBe( + 'worker' + ) }) it('adds the minimal real parent suffix only for colliding basenames', () => { @@ -19,9 +21,13 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/platform/billing/api', displayName: 'api' } ]) - expect(labels.get('/workspace/platform/web')).toBe('web') - expect(labels.get('/workspace/platform/payments/api')).toBe('payments/api') - expect(labels.get('/workspace/platform/billing/api')).toBe('billing/api') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/web' }))).toBe('web') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/payments/api' }))).toBe( + 'payments/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/billing/api' }))).toBe( + 'billing/api' + ) }) it('expands colliding labels in lockstep without skipping shared segments', () => { @@ -30,8 +36,39 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/team2/shared/api', displayName: 'api' } ]) - expect(labels.get('/workspace/team1/shared/api')).toBe('team1/shared/api') - expect(labels.get('/workspace/team2/shared/api')).toBe('team2/shared/api') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/team1/shared/api' }))).toBe( + 'team1/shared/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/team2/shared/api' }))).toBe( + 'team2/shared/api' + ) + }) + + it('scopes labels by execution host so same-path repos on different hosts do not collide', () => { + // Real SSH folder-repo shape: connectionId set, executionHostId unset — so + // it must fall back to the connection host, not look identical to a local repo. + const localRepo = { path: '/Users/alice', displayName: 'alice' } + const sshRepo = { path: '/Users/alice', displayName: 'alice-prod', connectionId: 'prod-ssh' } + const labels = getRepoDisplayLabelsByPath([localRepo, sshRepo]) + + expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBe('alice') + expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBe('alice-prod') + expect(labels.size).toBe(2) + }) + + it('keeps cross-host repos with identical path AND name as separate entries', () => { + // Hardening: when paths are byte-identical the same-name collision loop runs + // and re-sets each entry, so host scoping must survive that pass too — neither + // host may overwrite the other. Label text can still coincide; that residual is + // disambiguated by the host section/badge, not this map. + const localRepo = { path: '/Users/alice', displayName: 'home' } + const sshRepo = { path: '/Users/alice', displayName: 'home', connectionId: 'prod-ssh' } + const labels = getRepoDisplayLabelsByPath([localRepo, sshRepo]) + + expect(getRepoDisplayLabelKey(localRepo)).not.toBe(getRepoDisplayLabelKey(sshRepo)) + expect(labels.size).toBe(2) + expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBeDefined() + expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBeDefined() }) it('normalizes Windows separators to slash display labels', () => { @@ -40,7 +77,11 @@ describe('getRepoDisplayLabelsByPath', () => { { path: 'C:\\workspace\\billing\\api', displayName: 'api' } ]) - expect(labels.get('C:\\workspace\\payments\\api')).toBe('payments/api') - expect(labels.get('C:\\workspace\\billing\\api')).toBe('billing/api') + expect(labels.get(getRepoDisplayLabelKey({ path: 'C:\\workspace\\payments\\api' }))).toBe( + 'payments/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: 'C:\\workspace\\billing\\api' }))).toBe( + 'billing/api' + ) }) }) diff --git a/src/renderer/src/lib/repo-display-labels.ts b/src/renderer/src/lib/repo-display-labels.ts index 995d8d65b36..89b7beeaeb6 100644 --- a/src/renderer/src/lib/repo-display-labels.ts +++ b/src/renderer/src/lib/repo-display-labels.ts @@ -1,6 +1,21 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + type RepoDisplayLabelItem = { path: string displayName: string + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +// Why: two repos can share the same absolute path across hosts (e.g. a local +// /Users/alice and an SSH host's /Users/alice). Keying labels by raw path alone +// lets one repo's label overwrite the other's, so scope the key by execution +// host. getRepoExecutionHostId returns 'local' for local repos and falls back to +// the connectionId (ssh:) for SSH folder-repos that leave executionHostId unset. +export function getRepoDisplayLabelKey( + item: Pick +): string { + return `${getRepoExecutionHostId(item)}::${item.path}` } function normalizePathSegments(path: string): string[] { @@ -29,7 +44,7 @@ export function getRepoDisplayLabelsByPath( for (const item of items) { const displayName = item.displayName || item.path - labels.set(item.path, displayName) + labels.set(getRepoDisplayLabelKey(item), displayName) const colliding = itemsByName.get(displayName) ?? [] colliding.push({ ...item, displayName }) itemsByName.set(displayName, colliding) @@ -49,7 +64,7 @@ export function getRepoDisplayLabelsByPath( nextLabels = collidingItems.map((item) => labelForDepth(item, depth)) } collidingItems.forEach((item, index) => { - labels.set(item.path, nextLabels[index] ?? item.displayName) + labels.set(getRepoDisplayLabelKey(item), nextLabels[index] ?? item.displayName) }) } From 9b092fd129b570837a0e2017607b422bc34c275a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:30:52 -0700 Subject: [PATCH 26/52] fix(orchestration): compare stale-dispatch timestamps with julianday (#8636) getStaleDispatches compared space-format datetime('now') columns (dispatched_at, last_heartbeat_at) against an ISO-Z threshold using raw TEXT ordering. The space (0x20) sorts below 'T' (0x54) at index 10, so every fresh same-UTC-date dispatch/heartbeat was ordered below the threshold and wrongly flagged stale, producing a false stale warning on each coordinator tick. Wrap both column comparisons and both bound threshold params in julianday(), which parses the space and ISO-Z formats as UTC for a correct numeric comparison. Fixes #8452 (cherry picked from commit 1d40312bf2eb83ebbe6079758578e81e97476a26) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca From 67fdb1c2b929927b07bdb2bfcdb66dc15f8a6f98 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:32:08 +0200 Subject: [PATCH 27/52] Show agent status in terminal tabs (#8142) * Show agent status in terminal tabs * Resolve terminal tab activity via the canonical worktree-status engine The first pass hand-rolled resolveTerminalTabAgentActivityState, a fourth parallel copy of the pane-iteration/freshness/title-heuristic loop that already lives in smart-attention.ts and worktree-agent-activity-summary.ts. It diverged from every existing surface (novel blocked>waiting split, a phantom 'interrupted' red state the sidebar treats as idle) and re-scanned the global agentStatusByPaneKey map per tab per store write (O(tabs*agents)). Replace it with resolveTerminalTabActivityStatus, which reuses resolveWorktreeStatus (the WorktreeCard resolver: freshness gate, live-PTY liveness, per-leaf title dedup, permission>working>done priority) over a per-tab flag summary bucketed once per store snapshot (O(tabs+agents)). Tabs now speak the same WorktreeStatus vocabulary as the sidebar, so their live states can't disagree with the worktree card. - Map WorktreeStatus -> AgentStateDot: working=spinner, permission=amber, done=check; active/inactive fall through to the agent/shell identity icon. - Parse legacy numeric pane keys too, matching the sidebar summary, so restored/ imported sessions light the tab. - Drop the bespoke resolver + its tests; add focused coverage for the new resolver and the leading-icon component. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../src/components/AgentStateDot.test.ts | 1 + src/renderer/src/components/AgentStateDot.tsx | 4 +- .../src/components/tab-bar/SortableTab.tsx | 97 +++++----- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 85 +++++++++ .../tab-bar/TerminalTabLeadingIcon.tsx | 114 ++++++++++++ .../terminal-tab-activity-status.test.ts | 171 ++++++++++++++++++ .../tab-bar/terminal-tab-activity-status.ts | 159 ++++++++++++++++ 7 files changed, 575 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index e02c5d496ce..2e17d0423df 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -23,6 +23,7 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('motion-reduce:animate-none') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 951e3be36f1..9a4fe792e13 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -30,6 +30,7 @@ export type AgentDotState = // worktree-level permission dot. | 'permission' +/** Return the accessible label shared by every visual agent-state marker. */ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': @@ -57,6 +58,7 @@ type Props = { className?: string } +/** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', @@ -76,7 +78,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]', + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', inner )} /> diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 00beaf35b69..abf4b49cabd 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' -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' @@ -11,7 +9,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' -import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' import { useAppStore } from '../../store' import { ACTIVE_TAB_INDICATOR_CLASSES, @@ -26,6 +23,12 @@ 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' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import { + hasUnreadAgentCompletionForTerminalTab, + isTerminalTabActivityLive, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' type SortableTabProps = { tab: TerminalTab @@ -82,11 +85,26 @@ export default function SortableTab({ isChatView = false, onToggleViewMode }: SortableTabProps): React.JSX.Element { - // Why: subscribe to the per-tab boolean directly so only the tab whose unread - // status actually flipped re-renders. Reading the whole `unreadTerminalTabs` - // map in TabBar would invalidate every SortableTab on every bell event - // because the slice returns a fresh object reference on each mark/clear. - const hasUnreadActivity = useAppStore((s) => s.unreadTerminalTabs[tab.id] === true) + // Why: agent-completion unread is pane-keyed and exists even when the + // experimental generic terminal-attention setting is off. Collapse both + // sources to one per-tab primitive so unrelated tabs do not re-render. + const hasUnreadActivity = useAppStore( + (s) => + s.unreadTerminalTabs[tab.id] === true || + hasUnreadAgentCompletionForTerminalTab(s.unreadAgentCompletionPanes, tab.id) + ) + // Why: the resolver returns a WorktreeStatus primitive, so unrelated agent + // updates can't repaint this tab. The per-tab pane bucketing it reads is + // memoized once per store snapshot, so this stays O(1) per tab per write. + const activityStatus = useAppStore((s) => + resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayout: s.terminalLayoutsByTabId?.[tab.id] + }) + ) const renamingTabId = useAppStore((s) => s.renamingTabId) const setRenamingTabId = useAppStore((s) => s.setRenamingTabId) @@ -118,11 +136,11 @@ export default function SortableTab({ const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [isEditing, setIsEditing] = useState(false) - // Why: single source of truth for the unread-activity visual treatment — - // drives BOTH the amber wash overlay and the bell icon swap below. Kept as - // one derived boolean so the two visual cues can never drift out of sync - // (e.g. showing the bell without the wash, or vice versa). - const showActivityAffordance = hasUnreadActivity && !isEditing + // Why: a live working/needs-input state is newer and more specific than an + // unread event from the prior turn. It owns the icon until the turn ends; + // the unread completion bell then returns if the tab is still unvisited. + const showUnreadActivity = + hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus) const [renameValue, setRenameValue] = useState('') const renameFocusFrameRef = useRef(null) // Why: React's synthetic onBlur fires during the Input's unmount when isEditing flips @@ -236,6 +254,7 @@ export default function SortableTab({ // pass even if the tab-bar render path had silently broken (the same // tautology that let PR #1186's render crash ship past E2E in #1193). data-active={isActive ? 'true' : 'false'} + data-agent-activity-status={activityStatus} {...attributes} {...dragListeners} // Why: on unread activity, tint the whole tab with a subtle amber @@ -284,50 +303,18 @@ export default function SortableTab({ }} > {isActive && } - {showActivityAffordance && ( - // Why: amber wash for unread tabs. Rendered as a real DOM child so - // both drop indicators (::before left / ::after right in - // drop-indicator.ts) stay free for drag-and-drop feedback — a prior - // ::after-based implementation collided with the right-edge drop - // indicator and hid it on unread tabs. pointer-events-none keeps - // clicks reaching the underlying tab handlers. + {showUnreadActivity && ( + // Why: a real DOM child leaves both drop-indicator pseudo-elements + // available and keeps pointer events reaching the tab beneath it. )} - {showActivityAffordance ? ( - // Why: the activity marker sits to the LEFT of the tab title using - // Orca's filled bell glyph (amber-500 with a subtle drop shadow) - // so it matches the worktree-level bell in the sidebar — keeping - // every "needs your attention" surface in Orca consistent. - - - - ) : tabAgent ? ( - // Why: coding-agent tabs should read as Claude/Codex/etc. while the - // harness is running; plain shells keep the generic terminal tile. - - - - ) : ( - // Why: ShellIcon renders a colored brand-style tile for PowerShell, - // CMD, Git Bash, and WSL so Windows users can distinguish shells at a glance. - // On mac/linux (or Windows tabs without a resolved shell) it falls - // back to a matching colored generic-terminal tile — keeping every - // tab's leading glyph in the same visual idiom instead of mixing a - // flat lucide chevron with the brand tiles. Opacity dims the icon - // on inactive tabs to match the existing text treatment without - // desaturating the brand colors beyond recognition. - - - - )} + {isPinned && !isEditing && ( )} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx new file mode 100644 index 00000000000..71508153243 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -0,0 +1,85 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +/** Render one activity status through the production leading-icon component. */ +function renderStatus(status: TerminalTabActivityStatus): string { + return renderToStaticMarkup( + + ) +} + +describe('TerminalTabLeadingIcon', () => { + it('shows a working spinner beside the provider icon', () => { + const markup = renderStatus('working') + + expect(markup).toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-activity-status="working"') + expect(markup).toContain('aria-label="Working"') + expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows completion as an emerald check', () => { + const markup = renderStatus('done') + + expect(markup).toContain('data-agent-activity-status="done"') + expect(markup).toContain('lucide-circle-check') + expect(markup).toContain('text-emerald-500') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows a needs-input (permission) state as an amber dot', () => { + const markup = renderStatus('permission') + + expect(markup).toContain('data-agent-activity-status="permission"') + expect(markup).toContain('bg-amber-500') + expect(markup).not.toContain('bg-red-500') + }) + + it('shows no activity glyph for an active shell — just the identity icon', () => { + const markup = renderStatus('active') + + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('falls back to the shell icon when a plain tab is inactive', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-shell-icon="generic"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) + + it('keeps the unread bell in the icon slot after an unvisited completion', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-testid="tab-activity-bell"') + expect(markup).toContain('aria-label="Unread agent completion"') + expect(markup).toContain('data-agent-icon="codex"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) +}) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx new file mode 100644 index 00000000000..5c3b0c7dc24 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -0,0 +1,114 @@ +import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import type { TerminalTab, TuiAgent } from '../../../../shared/types' +import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' +import { ShellIcon } from './shell-icons' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +type TerminalTabLeadingIconProps = { + agent: TuiAgent | null + activityStatus: TerminalTabActivityStatus + shell: TerminalTab['shellOverride'] + showUnreadActivity: boolean + isActive: boolean +} + +type TerminalTabAgentIdentityIconProps = { + agent: TuiAgent + isActive: boolean + className?: string +} + +/** + * Map the container status to the shared state-dot vocabulary. `active` and + * `inactive` carry no activity glyph — the tab falls through to its agent or + * shell identity icon instead. Uses the same WorktreeStatus vocabulary as the + * sidebar so live states read identically (tabs intentionally omit the card's + * retained-done promotion, so a stale green check can differ after cleanup). + */ +function activityDotState(status: TerminalTabActivityStatus): AgentDotState | null { + switch (status) { + case 'working': + return 'working' + case 'permission': + return 'permission' + case 'done': + return 'done' + default: + return null + } +} + +/** Keep the provider glyph treatment identical across every terminal-tab state. */ +function TerminalTabAgentIdentityIcon({ + agent, + isActive, + className +}: TerminalTabAgentIdentityIconProps): React.JSX.Element { + return ( + + + + ) +} + +/** Render a terminal tab's current state without hiding its agent or shell identity. */ +export function TerminalTabLeadingIcon({ + agent, + activityStatus, + shell, + showUnreadActivity, + isActive +}: TerminalTabLeadingIconProps): React.JSX.Element { + if (showUnreadActivity) { + return ( + + + {agent ? : null} + + ) + } + + const dotState = activityDotState(activityStatus) + if (dotState) { + return ( + + + {/* Why: status and identity answer different questions. Keep the agent + logo beside the state glyph so parallel tabs remain scannable. */} + {agent ? : null} + + ) + } + + if (agent) { + return ( + + ) + } + + // Why: ShellIcon renders a colored brand-style tile for PowerShell, CMD, + // Git Bash, and WSL while retaining the generic terminal fallback elsewhere. + return ( + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts new file mode 100644 index 00000000000..9dc16e8f70b --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { + hasUnreadAgentCompletionForTerminalTab, + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' + +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const NOW = 10_000 + +const TAB: Pick = { id: TAB_ID, title: 'Codex' } + +/** Build a canonical pane-status fixture for one tab leaf. */ +function entry( + leafId: string, + state: AgentStatusEntry['state'], + overrides: Partial = {} +): AgentStatusEntry { + const paneKey = `${TAB_ID}:${leafId}` + return { + paneKey, + state, + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'codex', + ...overrides + } +} + +/** One live PTY for the tab so title/liveness gates pass. */ +const LIVE_PTY = { [TAB_ID]: ['pty-1'] } + +beforeEach(() => { + resetTerminalTabActivityFlagsCacheForTest() + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() + resetTerminalTabActivityFlagsCacheForTest() +}) + +describe('resolveTerminalTabActivityStatus', () => { + it('reports a fresh hook working state', () => { + const working = entry(FIRST_LEAF_ID, 'working') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('lets a needs-input pane outrank a working sibling', () => { + const working = entry(FIRST_LEAF_ID, 'working') + const waiting = entry(SECOND_LEAF_ID, 'waiting') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [working.paneKey]: working, + [waiting.paneKey]: waiting + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reports a completed turn as done', () => { + const done = entry(FIRST_LEAF_ID, 'done') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [done.paneKey]: done }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('treats an interrupted done as done, matching the worktree card', () => { + const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [interrupted.paneKey]: interrupted }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('falls back to a live working title when hook status is stale', () => { + const stale = entry(FIRST_LEAF_ID, 'done', { updatedAt: 0 }) + vi.setSystemTime(31 * 60 * 1000) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + agentStatusByPaneKey: { [stale.paneKey]: stale }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('does not treat a preserved title on a sleeping tab as activity', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, + ptyIdsByTabId: { [TAB_ID]: [] } + }) + ).toBe('inactive') + }) + + it('reads a needs-input hook as permission', () => { + const blocked = entry(FIRST_LEAF_ID, 'blocked') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [blocked.paneKey]: blocked }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reads a legacy numeric pane key, matching the sidebar summary', () => { + const working = entry(FIRST_LEAF_ID, 'working', { paneKey: `${TAB_ID}:3` }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('reports a live shell with no agent as active (no activity glyph)', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'zsh' }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) +}) + +describe('hasUnreadAgentCompletionForTerminalTab', () => { + it('matches unread completion panes to their owning tab', () => { + expect( + hasUnreadAgentCompletionForTerminalTab( + { + [`${TAB_ID}:${FIRST_LEAF_ID}`]: true, + [`tab-2:${SECOND_LEAF_ID}`]: true + }, + TAB_ID + ) + ).toBe(true) + }) + + it('ignores completion panes owned by other tabs', () => { + expect( + hasUnreadAgentCompletionForTerminalTab({ [`tab-2:${SECOND_LEAF_ID}`]: true }, TAB_ID) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts new file mode 100644 index 00000000000..2e51d42e1d5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -0,0 +1,159 @@ +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' + +// Why: a terminal tab is a container of panes, exactly like a worktree card is +// a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so +// the tab's live states resolve identically to the sidebar (tabs intentionally +// skip the card's retained-done promotion — see resolveTerminalTabActivityStatus). +export type TerminalTabActivityStatus = WorktreeStatus + +// Per-tab live-hook flags, mirroring applyLiveAgentState in +// worktree-agent-activity-summary.ts. blocked/waiting collapse to permission, +// matching every other status surface in the app. +type TerminalTabActivityFlags = { + hasPermission: boolean + hasLiveWorking: boolean + hasLiveDone: boolean + paneIds: Set +} + +type FlagsCache = { + agentStatusByPaneKey: Record | undefined + flagsByTabId: Map +} + +// Why: Zustand reruns every tab's selector on each store write. Bucketing the +// full pane-status map by tab once per snapshot keeps the cost O(agents + tabs) +// instead of O(agents * tabs) — the same memo strategy the sidebar summaries +// use (worktree-agent-activity-summary.ts / worktree-agent-row-selectors.ts). +let flagsCache: FlagsCache | null = null + +function getTerminalTabActivityFlags( + agentStatusByPaneKey: Record | undefined +): Map { + if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) { + return flagsCache.flagsByTabId + } + + const flagsByTabId = new Map() + const now = Date.now() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey ?? {})) { + const identity = parseAgentStatusPaneKey(entry.paneKey || paneKey) + // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane + // must not keep a tab spinning. Same freshness gate as the sidebar. + if (!identity || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } + + let flags = flagsByTabId.get(identity.tabId) + if (!flags) { + flags = { + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + paneIds: new Set() + } + flagsByTabId.set(identity.tabId, flags) + } + flags.paneIds.add(identity.paneId) + if (entry.state === 'blocked' || entry.state === 'waiting') { + flags.hasPermission = true + } else if (entry.state === 'working') { + flags.hasLiveWorking = true + } else if (entry.state === 'done') { + // Why: an interrupted `done` still reads as completed here, matching the + // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only + // the smart-sort ordering treats interrupts as idle. + flags.hasLiveDone = true + } + } + + flagsCache = { agentStatusByPaneKey, flagsByTabId } + return flagsByTabId +} + +// Why: mirror the sidebar summary's parse — live entries on restored/imported +// sessions can still carry pre-UUID numeric pane keys. Keep the numeric pane id +// so the title-heuristic dedup in resolveWorktreeStatus can still match them. +function parseAgentStatusPaneKey(paneKey: string): { tabId: string; paneId: string } | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + return { tabId: parsed.tabId, paneId: parsed.leafId } + } + const legacy = parseLegacyNumericPaneKey(paneKey) + return legacy ? { tabId: legacy.tabId, paneId: legacy.numericPaneId } : null +} + +const EMPTY_PANE_IDS: ReadonlySet = new Set() + +type TerminalTabActivityInput = { + tab: Pick + agentStatusByPaneKey?: Record + runtimePaneTitlesByTabId?: Record> + ptyIdsByTabId?: Record + terminalLayout?: TerminalLayoutSnapshot +} + +/** + * Resolve a terminal tab's status glyph through the canonical WorktreeCard + * resolver. Fresh hook state is authoritative per pane; hookless-but-live panes + * fall back to the same title heuristic used by the sidebar and smart sort. + * Returns a `WorktreeStatus` primitive so the tab re-renders only when it flips. + */ +export function resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayout +}: TerminalTabActivityInput): TerminalTabActivityStatus { + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id) + return resolveWorktreeStatus({ + tabs: [tab], + browserTabs: [], + ptyIdsByTabId: ptyIdsByTabId ?? {}, + runtimePaneTitlesByTabId: runtimePaneTitlesByTabId ?? {}, + agentStatusPaneIdsByTabId: { [tab.id]: flags?.paneIds ?? EMPTY_PANE_IDS }, + terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, + hasPermission: flags?.hasPermission ?? false, + hasLiveWorking: flags?.hasLiveWorking ?? false, + hasLiveDone: flags?.hasLiveDone ?? false, + // Why: retained/orchestration promotions are worktree-aggregate concerns; + // a tab reflects its own live panes and title only. + hasRetainedDone: false + }) +} + +/** True while the tab shows a live in-turn signal (spinner or needs-input). */ +export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean { + return status === 'working' || status === 'permission' +} + +/** Match pane-level unread completion markers to their owning terminal tab. */ +export function hasUnreadAgentCompletionForTerminalTab( + unreadAgentCompletionPanes: Record | undefined, + tabId: string +): boolean { + for (const paneKey of Object.keys(unreadAgentCompletionPanes ?? {})) { + // paneKey is `${tabId}:${leafId}` and tab ids never contain ":", so the + // prefix up to the first ":" is the owning tab id (see + // selectFloatingWorkspaceHasUnread). Prefix-match to keep legacy keys. + const separatorIndex = paneKey.indexOf(':') + const owningTabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) + if (owningTabId === tabId) { + return true + } + } + return false +} + +/** Test-only: clear the memoized per-tab flag cache between cases. */ +export function resetTerminalTabActivityFlagsCacheForTest(): void { + flagsCache = null +} From 1c6098c214505a9a8786308bca0dfae970b21134 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:52:09 -0700 Subject: [PATCH 28/52] fix(terminal-tabs): invalidate activity-status cache on freshness update (#8641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store bumps agentStatusEpoch at the 30m stale boundary without replacing the agentStatusByPaneKey map. Keying the flag cache on map reference alone kept serving stale flags (old timestamp, abandoned spinning), while the sidebar — which keys on agentStatusEpoch — correctly de-spun. Invalidate on either changing. Co-authored-by: Orca --- .../src/components/tab-bar/SortableTab.tsx | 1 + .../terminal-tab-activity-status.test.ts | 27 +++++++++++++++++++ .../tab-bar/terminal-tab-activity-status.ts | 23 +++++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index abf4b49cabd..0b694fb6fd4 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -100,6 +100,7 @@ export default function SortableTab({ resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey: s.agentStatusByPaneKey, + agentStatusEpoch: s.agentStatusEpoch, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, ptyIdsByTabId: s.ptyIdsByTabId, terminalLayout: s.terminalLayoutsByTabId?.[tab.id] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 9dc16e8f70b..2f6cbcadcd8 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -108,6 +108,33 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('de-spins a stale working tab on an epoch bump without a new map reference', () => { + // Why: the freshness scheduler bumps agentStatusEpoch (not the map ref) at + // the 30m stale boundary. The flag cache must invalidate on that bump, or an + // abandoned tab keeps spinning while the sidebar (epoch-keyed) de-spins. + const working = entry(FIRST_LEAF_ID, 'working') + const agentStatusByPaneKey = { [working.paneKey]: working } + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey, + agentStatusEpoch: 0, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + + vi.setSystemTime(31 * 60 * 1000) + // Same map reference, bumped epoch — the entry is now stale. + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey, + agentStatusEpoch: 1, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) + it('does not treat a preserved title on a sleeping tab as activity', () => { expect( resolveTerminalTabActivityStatus({ diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 2e51d42e1d5..211aae2c3b2 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -25,6 +25,7 @@ type TerminalTabActivityFlags = { type FlagsCache = { agentStatusByPaneKey: Record | undefined + agentStatusEpoch: number | undefined flagsByTabId: Map } @@ -35,9 +36,19 @@ type FlagsCache = { let flagsCache: FlagsCache | null = null function getTerminalTabActivityFlags( - agentStatusByPaneKey: Record | undefined + agentStatusByPaneKey: Record | undefined, + agentStatusEpoch: number | undefined ): Map { - if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) { + // Why: freshness is time-based, so the store bumps agentStatusEpoch without + // replacing the map at the 30m stale boundary (createFreshnessScheduler). + // Keying on the map reference alone would keep serving flags computed at the + // old `now`, spinning an abandoned tab forever while the sidebar — which keys + // on agentStatusEpoch — correctly de-spins. Invalidate on either changing. + if ( + flagsCache && + flagsCache.agentStatusByPaneKey === agentStatusByPaneKey && + flagsCache.agentStatusEpoch === agentStatusEpoch + ) { return flagsCache.flagsByTabId } @@ -74,7 +85,7 @@ function getTerminalTabActivityFlags( } } - flagsCache = { agentStatusByPaneKey, flagsByTabId } + flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId } return flagsByTabId } @@ -95,6 +106,9 @@ const EMPTY_PANE_IDS: ReadonlySet = new Set() type TerminalTabActivityInput = { tab: Pick agentStatusByPaneKey?: Record + // Why: the store bumps this at the 30m stale boundary without replacing the + // pane-status map; it is the flag cache's invalidation key (see above). + agentStatusEpoch?: number runtimePaneTitlesByTabId?: Record> ptyIdsByTabId?: Record terminalLayout?: TerminalLayoutSnapshot @@ -109,11 +123,12 @@ type TerminalTabActivityInput = { export function resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey, + agentStatusEpoch, runtimePaneTitlesByTabId, ptyIdsByTabId, terminalLayout }: TerminalTabActivityInput): TerminalTabActivityStatus { - const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id) + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey, agentStatusEpoch).get(tab.id) return resolveWorktreeStatus({ tabs: [tab], browserTabs: [], From c408a3d85268181f22be9c84dee2de32fe97c3b7 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:56:36 -0700 Subject: [PATCH 29/52] feat(mobile): show usage reset countdown on accounts screen (#7954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): show usage reset countdown on accounts screen Surface the rate-limit reset time ("5h resets in 3h 54m · 7d resets in 6d 7h") under the usage bars on the mobile accounts screen, matching the desktop status-bar tooltip copy. The resetsAt timestamps already arrive in the accounts.subscribe snapshot; this only adds the presentation. Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * docs(mobile): JSDoc for new usage reset selectors Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * refactor(mobile): per-bar reset countdown instead of combined line Drop the redundant "5h/7d" prefixes — each countdown now renders under its own bar ("Resets in 3h 54m"), matching the desktop tooltip copy exactly. Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * Extract shared reset-countdown formatter for desktop and mobile - Move duration/countdown formatting out of tooltip.tsx into src/shared/rate-limit-reset-format.ts so mobile's account-usage-state can reuse it instead of a duplicated copy (with tests). - Re-export formatResetCountdown from tooltip.tsx to avoid touching existing import paths. - Resend the pairing deep link once more in start-emulator.mjs since the first can arrive before the Expo app's JS router is ready. --------- Co-authored-by: kaynan Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- mobile/app/h/[hostId]/accounts.tsx | 13 ++++ mobile/scripts/start-emulator.mjs | 5 ++ mobile/src/components/AccountUsage.tsx | 64 +++++++++++++------ .../components/account-usage-state.test.ts | 60 +++++++++++++++++ mobile/src/components/account-usage-state.ts | 23 +++++++ .../src/components/status-bar/tooltip.tsx | 31 +++------ src/shared/rate-limit-reset-format.test.ts | 30 +++++++++ src/shared/rate-limit-reset-format.ts | 32 ++++++++++ 8 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 src/shared/rate-limit-reset-format.test.ts create mode 100644 src/shared/rate-limit-reset-format.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abfa8..652dfffee75 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -23,6 +23,7 @@ import { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' @@ -40,6 +41,14 @@ export default function AccountsScreen() { const [refreshing, setRefreshing] = useState(false) const [busyAccountId, setBusyAccountId] = useState(null) + // Why: the reset countdown must stay fresh while the screen sits open — + // snapshot pushes only arrive when the desktop's rate-limit poll completes. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(id) + }, []) + useEffect(() => { if (!hostId) { return @@ -163,12 +172,14 @@ export default function AccountsScreen() { usedPercent={activeSessionBar.usedPercent} unavailable={activeSessionBar.unavailable} loading={activeSessionBar.loading} + resetText={getWindowResetLabel(activeUsage, 'session', now)} />
) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} />
{usage?.error ? ( diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45c0..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the first deep link can arrive while the freshly opened Expo app is + // still mounting, so resend it once the JS router is ready to receive URLs. + await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) + await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the mobile app intentionally asks for a trust confirmation before // saving a host. This lands on the Pair button on current iPhone simulators. await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e607487..713a2dd8a46 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -17,6 +17,7 @@ export { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage } from './account-usage-state' @@ -28,12 +29,14 @@ export function UsageBar({ label, usedPercent, unavailable, - loading + loading, + resetText }: { label: string usedPercent: number | null unavailable: boolean loading?: boolean + resetText?: string | null }) { // Why: round then clamp so bar width, color, and label share one value (desktop parity). const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent))) @@ -47,34 +50,48 @@ export function UsageBar({ ? colors.statusAmber : colors.statusGreen return ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } const styles = StyleSheet.create({ + usageBarColumn: { + flex: 1, + gap: 2 + }, usageBar: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, - flex: 1 + gap: spacing.xs }, usageLabel: { fontSize: typography.metaSize, @@ -100,5 +117,12 @@ const styles = StyleSheet.create({ }, usageSpinner: { width: 36 + }, + // Why: indented past the window label so the countdown aligns with the + // start of the track above it. + usageResetText: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginLeft: 22 + spacing.xs } }) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f567143584e..3e128b7b099 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage, type AccountsSnapshot, @@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => { }) }) +describe('getWindowResetLabel', () => { + const now = 1_700_000_000_000 + const min = 60_000 + const hour = 60 * min + const day = 24 * hour + + function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] { + return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null } + } + + it('is null when there are no limits or the window has no reset timestamp', () => { + expect(getWindowResetLabel(null, 'session', now)).toBe(null) + expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null) + expect( + getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now) + ).toBe(null) + }) + + it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now) + ).toBe('Resets in 47m') + expect( + getWindowResetLabel( + makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }), + 'session', + now + ) + ).toBe('Resets in 3h 54m') + expect( + getWindowResetLabel( + makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }), + 'weekly', + now + ) + ).toBe('Resets in 6d 7h') + }) + + it('formats exact hours and exact days without a zero remainder', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now) + ).toBe('Resets in 2h') + expect( + getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now) + ).toBe('Resets in 7d') + }) + + it('reports "Resets now" for a reset timestamp in the past', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now) + ).toBe('Resets now') + }) + + it('reads the requested window only', () => { + const limits = makeLimits({ session: makeWindow(now + hour) }) + expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null) + }) +}) + describe('getUsageBarState', () => { it('keeps stale window data visible during a transient error', () => { const bar = getUsageBarState( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731cd0..fa5b4a516b3 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -5,6 +5,8 @@ // Pure state/selectors live here (no React Native imports) so they can be // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. +import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' + export type RateLimitWindow = { usedPercent: number windowMinutes: number @@ -117,6 +119,27 @@ export function getUsageBarState( } } +/** + * Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now", + * or null when the window has no reset timestamp (so the UI degrades to + * today's bars-only layout). + * + * Why: shares formatResetCountdown with the desktop status-bar tooltip so the + * copy stays identical across surfaces. `now` is a parameter so the function + * stays pure and unit-testable. + */ +export function getWindowResetLabel( + limits: ProviderRateLimits | null, + windowKey: 'session' | 'weekly', + now: number +): string | null { + const resetsAt = limits?.[windowKey]?.resetsAt + if (resetsAt == null) { + return null + } + return formatResetCountdown(resetsAt - now) +} + // Why: the usage UI must render for the system-default login, not only for // Orca-managed accounts. Show a provider when it has at least one managed // account OR active rate-limit data for the system-default target. diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c2fde5de91a..849dc7beda3 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,4 +1,8 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { + formatResetCountdown, + formatResetDuration +} from '../../../../shared/rate-limit-reset-format' import { AgentIcon } from '@/lib/agent-catalog' import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' @@ -39,28 +43,9 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } -function formatDuration(ms: number): string { - if (ms <= 0) { - return 'now' - } - const totalMins = Math.floor(ms / 60_000) - if (totalMins < 60) { - return `${totalMins}m` - } - const hours = Math.floor(totalMins / 60) - const mins = totalMins % 60 - if (hours >= 24) { - const days = Math.floor(hours / 24) - const remHours = hours % 24 - return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` - } - return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` -} - -export function formatResetCountdown(ms: number): string { - const duration = formatDuration(ms) - return duration === 'now' ? 'Resets now' : `Resets in ${duration}` -} +// Re-export so existing tooltip consumers/tests keep their import path; the +// implementation is shared with mobile in src/shared/rate-limit-reset-format. +export { formatResetCountdown } export function formatResetCreditExpiry( expiresAt: number | null | undefined, @@ -69,7 +54,7 @@ export function formatResetCreditExpiry( if (!expiresAt) { return null } - const duration = formatDuration(expiresAt - Date.now()) + const duration = formatResetDuration(expiresAt - Date.now()) if (duration === 'now') { return count > 1 ? translate('auto.components.status.bar.tooltip.7ec6e030a0', 'Next expires now') diff --git a/src/shared/rate-limit-reset-format.test.ts b/src/shared/rate-limit-reset-format.test.ts new file mode 100644 index 00000000000..1598c4c420f --- /dev/null +++ b/src/shared/rate-limit-reset-format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { formatResetCountdown, formatResetDuration } from './rate-limit-reset-format' + +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +describe('formatResetDuration', () => { + it('returns "now" for non-positive deltas', () => { + expect(formatResetDuration(0)).toBe('now') + expect(formatResetDuration(-1)).toBe('now') + }) + + it('floors to whole units and drops zero remainders', () => { + expect(formatResetDuration(47 * MIN)).toBe('47m') + expect(formatResetDuration(3 * HOUR + 54 * MIN)).toBe('3h 54m') + expect(formatResetDuration(2 * HOUR)).toBe('2h') + expect(formatResetDuration(6 * DAY + 7 * HOUR)).toBe('6d 7h') + expect(formatResetDuration(7 * DAY)).toBe('7d') + }) +}) + +describe('formatResetCountdown', () => { + it('prefixes the duration or reports "Resets now"', () => { + expect(formatResetCountdown(0)).toBe('Resets now') + expect(formatResetCountdown(3 * HOUR + 54 * MIN)).toBe('Resets in 3h 54m') + expect(formatResetCountdown(6 * DAY + 7 * HOUR)).toBe('Resets in 6d 7h') + }) +}) diff --git a/src/shared/rate-limit-reset-format.ts b/src/shared/rate-limit-reset-format.ts new file mode 100644 index 00000000000..2c1b48809c7 --- /dev/null +++ b/src/shared/rate-limit-reset-format.ts @@ -0,0 +1,32 @@ +// Why: shared by the desktop status-bar tooltip and the mobile accounts screen +// so rate-limit reset/expiry countdown copy stays identical across surfaces. +// Pure (no platform imports) — safe to bundle in both the renderer and mobile. + +/** + * Compact human duration for a rate-limit window, flooring to whole units: + * "47m", "3h 54m", "6d 7h". Returns "now" for a non-positive delta so callers + * can special-case the "already reset" copy. + */ +export function formatResetDuration(ms: number): string { + if (ms <= 0) { + return 'now' + } + const totalMins = Math.floor(ms / 60_000) + if (totalMins < 60) { + return `${totalMins}m` + } + const hours = Math.floor(totalMins / 60) + const mins = totalMins % 60 + if (hours >= 24) { + const days = Math.floor(hours / 24) + const remHours = hours % 24 + return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` + } + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` +} + +/** "Resets in 3h 54m" / "Resets now" for a window's time-until-reset (ms). */ +export function formatResetCountdown(ms: number): string { + const duration = formatResetDuration(ms) + return duration === 'now' ? 'Resets now' : `Resets in ${duration}` +} From 302b97029a60de1c16595a144119513f2ca723e3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:58:21 -0700 Subject: [PATCH 30/52] P2 windows cli hardening (#8638) * fix(cli): harden Windows launcher transports * Fix csc.exe compile failures on space-bearing Windows install paths - Legacy csc.exe mangles absolute paths containing spaces, so the compile step now cd's into the bin directory and passes bare file names for /out and the source file instead of full paths --- .github/workflows/computer-e2e.yml | 6 + config/scripts/build-windows-cli-launcher.mjs | 6 +- .../build-windows-cli-launcher.test.mjs | 18 ++ src/main/cli/cli-installer.test.ts | 18 +- src/main/cli/cli-installer.ts | 4 +- src/main/ssh/ssh-relay-session.test.ts | 20 +- src/main/ssh/ssh-relay-session.ts | 81 ++---- src/main/ssh/ssh-remote-cli-launcher.test.ts | 143 +++++++++++ src/main/ssh/ssh-remote-cli-launcher.ts | 237 ++++++++++++++++++ 9 files changed, 451 insertions(+), 82 deletions(-) create mode 100644 src/main/ssh/ssh-remote-cli-launcher.test.ts create mode 100644 src/main/ssh/ssh-remote-cli-launcher.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 235868f6155..76595f338a6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,8 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -22,12 +24,15 @@ on: - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' + - 'native/windows-cli-launcher/**' - 'skills/computer-use/SKILL.md' - 'src/cli/**' - 'src/main/computer/**' - 'src/main/runtime/rpc/dispatcher.ts' - 'src/main/runtime/rpc/errors.ts' - 'src/main/runtime/rpc/methods/computer*.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.test.ts' - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-mac.e2e.ts' @@ -73,6 +78,7 @@ jobs: - run: >- pnpm vitest run config/scripts/build-windows-cli-launcher.test.mjs + src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs config/scripts/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index c8478f3aee0..6f9b86bdb4b 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' if (process.platform !== 'win32') { - process.exit(0) + // Why: electron-builder treats a skipped native build like success and can + // continue toward a Windows package whose declared orca.exe does not exist. + throw new Error( + 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' + ) } const repoRoot = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e5bcbaa557b..4af5a68dd5a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -5,9 +5,27 @@ import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' const itWindows = process.platform === 'win32' ? it : it.skip +const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') describe('Windows CLI launcher', () => { + itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { + const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher ')) + try { + const result = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')], + { cwd: projectRoot, encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows CLI launcher') + expect(result.stderr).toContain('Windows host') + } finally { + rmSync(outputRoot, { recursive: true, force: true }) + } + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 596cb8bbfc8..a42805313c0 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1174,10 +1174,10 @@ describe('CliInstaller', () => { } ) - it('resolves packaged Windows command path to resources/bin/orca.exe', async () => { + it('resolves custom-install packaged Windows command path from resourcesPath', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(fixture.root, 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') await mkdir(join(resourcesPath, 'bin'), { recursive: true }) await writeFile(join(resourcesPath, 'bin', 'orca.exe'), 'native launcher', 'utf8') @@ -1187,22 +1187,20 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => null, userPathWriter: async () => {} }) const status = await installer.getStatus() - expect(status.commandPath).toBe( - join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') - ) + expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') const bundledContent = 'native launcher' await mkdir(dirname(bundledLauncher), { recursive: true }) @@ -1215,7 +1213,7 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => userPath, userPathWriter: async (value) => { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index da8a3cc076a..c5f1286126b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -356,7 +356,9 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') + // Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged + // resources directory is the authoritative native launcher location. + return getBundledLauncherPath(this.platform, this.resourcesPath) } return null diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 82cbfaee42c..da490ae7be0 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -424,7 +424,7 @@ describe('SshRelaySession', () => { expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => { + it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { writeFile: vi.fn().mockResolvedValue(undefined) @@ -447,20 +447,20 @@ describe('SshRelaySession', () => { await session.establish(mockConn) - expect(execCommand).toHaveBeenCalledTimes(1) + expect(execCommand).toHaveBeenCalledTimes(2) expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca.cmd', - expect.stringContaining('@echo off'), + 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', + expect.stringContaining('ProcessStartInfo'), { hostPlatform: getRemoteHostPlatform('win32-x64') } ) - const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1') - expect(shim).toContain('\\\\.\\pipe\\orca-relay-123') - expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"') - expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket') - expect(shim).not.toContain('#!/usr/bin/env sh') + const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string + expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') + expect(launcherSource).not.toContain('cmd.exe') + expect(launcherSource).not.toContain('%*') + expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') + expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( false ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 05b0068cade..b449daabb70 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -61,7 +61,8 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -653,14 +654,14 @@ export class SshRelaySession { } try { - await this.installRemoteOrcaCliShim() + await this.installRemoteOrcaCliLauncher() } catch (error) { - // Why: the remote `orca` CLI shim is a convenience bridge. On session- + // Why: the remote `orca` CLI launcher is a convenience bridge. On session- // limited remotes (MaxSessions=1) the relay bridge holds the only slot, // so this raw-connection install can fail — that must not fail the // whole connection, matching the managed-hook install above. console.warn( - `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${ + `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) @@ -775,34 +776,38 @@ export class SshRelaySession { } } - private async installRemoteOrcaCliShim(): Promise { + private async installRemoteOrcaCliLauncher(): Promise { if (!this.remoteCliBridgeEnv) { return } const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const shim = buildRemoteCliShim(this.remoteCliBridgeEnv) + const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) const conn = this.requireReadyConnection() await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) if (typeof conn.writeFile === 'function') { - await conn.writeFile(shim.path, shim.contents, { hostPlatform }) + for (const file of plan.files) { + await conn.writeFile(file.path, file.contents, { hostPlatform }) + } } else { const sftp = await conn.sftp() try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(shim.path) - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(shim.contents) - }) + for (const file of plan.files) { + await new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(file.path) + sftp.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(file.contents) + }) + } } finally { sftp.end() } } - if (!isWindowsRemoteHost(hostPlatform)) { - await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path)) + for (const command of plan.postWriteCommands) { + await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) } } @@ -1264,47 +1269,3 @@ export class SshRelaySession { } } } - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function buildRemoteCliShim(env: RemoteCliBridgeEnv): { - path: string - contents: string -} { - if (isWindowsRemoteHost(env.hostPlatform)) { - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - return { - path: shimPath, - contents: [ - '@echo off', - 'setlocal', - `if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`, - `if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`, - `if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`, - '"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*', - 'exit /b %ERRORLEVEL%', - '' - ].join('\r\n') - } - } - - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - path: shimPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', - '' - ].join('\n') - } -} diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts new file mode 100644 index 00000000000..ae0a2b18619 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -0,0 +1,143 @@ +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const itWindows = process.platform === 'win32' ? it : it.skip + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded) { + throw new Error(`Expected an encoded PowerShell command: ${command}`) + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH remote Orca CLI launcher', () => { + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const hostPlatform = getRemoteHostPlatform('win32-x64') + const plan = createRemoteCliInstallPlan({ + binDir: 'C:/Users/me user/.orca-relay/bin', + relayDir: 'C:/Users/me user/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + hostPlatform + }) + + expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') + expect(plan.files).toHaveLength(1) + expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(plan.files[0]?.contents).toContain('ProcessStartInfo') + expect(plan.files[0]?.contents).toContain('"--orca-cli"') + expect(plan.files[0]?.contents).toContain("value[index] == '\"'") + expect(plan.files[0]?.contents).toContain("character == '\\\\'") + expect(plan.files[0]?.contents).not.toContain('cmd.exe') + expect(plan.files[0]?.contents).not.toContain('%*') + + expect(plan.postWriteCommands).toHaveLength(1) + const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') + expect(compileScript).toContain('v4.0.30319\\csc.exe') + // Why: legacy csc.exe is invoked from the bin directory with bare, space-free + // file names so PowerShell 5.1 never mangles a space-bearing absolute path. + expect(compileScript).toContain( + "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" + ) + expect(compileScript).toContain('/out:orca.exe') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') + expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + itWindows('preserves a multiline argument through the compiled remote launcher', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + const relayDir = join(root, 'relay').replaceAll('\\', '/') + const sockPath = '\\\\.\\pipe\\orca-relay-test' + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir, + nodePath: process.execPath, + sockPath, + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8' } + ) + expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) + + mkdirSync(relayDir, { recursive: true }) + writeFileSync( + join(relayDir, 'relay.js'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', + 'utf8' + ) + const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' + const launched = spawnSync( + plan.launcherPath, + ['orchestration', 'send', '--body', body, '--json'], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_RELAY_NODE_PATH: process.execPath, + ORCA_RELAY_DIR: relayDir, + ORCA_RELAY_SOCKET_PATH: sockPath + } + } + ) + + expect(launched.status, launched.stderr).toBe(0) + expect(JSON.parse(launched.stdout)).toEqual([ + '--sock-path', + sockPath, + '--orca-cli', + 'orchestration', + 'send', + '--body', + body, + '--json' + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { + const plan = createRemoteCliInstallPlan({ + binDir: '/home/me/.orca-relay/bin', + relayDir: '/home/me/.orca-remote/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') + expect(plan.files).toEqual([ + expect.objectContaining({ + path: '/home/me/.orca-relay/bin/orca', + contents: expect.stringContaining('--orca-cli "$@"') + }) + ]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts new file mode 100644 index 00000000000..871cc6076ac --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -0,0 +1,237 @@ +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' + +type RemoteCliInstallEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + hostPlatform: RemoteHostPlatform +} + +type RemoteCliInstallFile = { + path: string + contents: string +} + +export type RemoteCliInstallPlan = { + launcherPath: string + files: RemoteCliInstallFile[] + postWriteCommands: string[] +} + +const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaRemoteCliLauncher +{ + private static int Main(string[] args) + { + try + { + string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); + string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); + string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); + string relayPath = Path.Combine(relayDirectory, "relay.js"); + + if (!File.Exists(nodePath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); + return 1; + } + if (!File.Exists(relayPath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = nodePath, + Arguments = BuildArguments(relayPath, socketPath, args), + UseShellExecute = false + }; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); + return 1; + } + } + + private static string RequireEnvironmentVariable(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (String.IsNullOrEmpty(value)) + { + throw new InvalidOperationException(name + " is not set."); + } + return value; + } + + private static string BuildArguments(string relayPath, string socketPath, string[] args) + { + StringBuilder commandLine = new StringBuilder(); + AppendArgument(commandLine, relayPath); + AppendArgument(commandLine, "--sock-path"); + AppendArgument(commandLine, socketPath); + AppendArgument(commandLine, "--orca-cli"); + foreach (string arg in args) + { + AppendArgument(commandLine, arg); + } + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string value) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(value)); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +` + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function createWindowsLauncherCompileCommand( + binDir: string, + sourceFileName: string, + launcherFileName: string, + launcherPath: string, + sourcePath: string, + legacyShimPath: string +): string { + // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by + // Windows PowerShell 5.1's native-argument quoting, so compile from the bin + // directory and pass only the bare, space-free launcher file names. + const compilerArgs = [ + '/nologo', + '/target:exe', + '/optimize+', + '/warnaserror+', + `/out:${launcherFileName}`, + sourceFileName + ] + .map(powerShellNativeArg) + .join(' ') + return powerShellCommand( + [ + // Why: an upgrade must not leave the old %* batch bridge callable when + // compiler discovery fails; losing the convenience CLI is safer. + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, + `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, + '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', + `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, + '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + `& $compiler ${compilerArgs}`, + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` + ].join('; ') + ) +} + +export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { + if (isWindowsRemoteHost(env.hostPlatform)) { + const launcherFileName = 'orca.exe' + const sourceFileName = 'orca-launcher.cs' + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) + const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) + const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') + const binDir = joinRemotePath(env.hostPlatform, env.binDir) + return { + launcherPath, + files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], + // Why: compiling on the Windows target avoids shipping an unsigned + // cross-host binary while ensuring argv never crosses cmd.exe's parser. + postWriteCommands: [ + createWindowsLauncherCompileCommand( + binDir, + sourceFileName, + launcherFileName, + launcherPath, + sourcePath, + legacyShimPath + ) + ] + } + } + + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') + return { + launcherPath, + files: [ + { + path: launcherPath, + contents: [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', + '' + ].join('\n') + } + ], + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`] + } +} From 01d7cd779f3e949e0e4189f76e9648df029ae517 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:02:52 -0700 Subject: [PATCH 31/52] P2 mobile firewall scope (#8639) * fix(mobile): validate Windows firewall remote scope * fix(review): simplify string-guard ternary to boolean AND The ternary returned only boolean literals, so cond ? f() : false is equivalent to cond && f() (addressScopeIsSufficient returns boolean). Co-authored-by: Orca * fix(mobile): accept dotted-netmask firewall scopes and lock in fail-safe edges - Parse dotted-netmask CIDR (192.168.0.0/255.255.255.0) via a contiguous-mask check, failing closed on holey masks. - Factor subnetFromParsed so CIDR parsing no longer re-parses the address. - Document why single-host (/32, /128) subnets and family-specific keywords with an unknown interface family fail closed, and add regression tests covering those deliberate false-deny edges plus policy-defined keywords (Intranet, DNS). Co-authored-by: Orca * Add explanatory comment on why firewall scope check isn't unioned Documents the fail-safe rationale behind checking coverage per rule instead of merging rule scopes, so future edits don't "fix" this into a less conservative union check. --------- Co-authored-by: Orca --- .../windows-firewall-remote-scope.test.ts | 155 +++++++++++++ .../runtime/windows-firewall-remote-scope.ts | 216 ++++++++++++++++++ .../runtime/windows-mobile-firewall.test.ts | 43 +++- src/main/runtime/windows-mobile-firewall.ts | 28 ++- 4 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/windows-firewall-remote-scope.test.ts create mode 100644 src/main/runtime/windows-firewall-remote-scope.ts diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts new file mode 100644 index 00000000000..2c9a013d964 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' + +type RuleScope = { remoteAddresses: unknown } + +function rule(remoteAddresses: unknown): RuleScope { + return { remoteAddresses } +} + +describe('Windows firewall remote-address scope', () => { + it.each([['Any'], ['any'], ['LocalSubnet']])('accepts the documented %s scope', (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(scope)], undefined, undefined)).toBe(true) + }) + + it.each([['Any4'], ['LocalSubnet4']])( + 'accepts address-family-specific %s on the selected IPv4 interface', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + true + ) + } + ) + + it('keeps address-family-specific keywords on the selected interface family', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], 'fd7a:115c:a1e0::5', 64)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], 'fd7a:115c:a1e0::5', 64) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], '192.168.0.108', 24) + ).toBe(false) + }) + + it.each([ + ['192.168.0.0/24', '192.168.0.108', 24], + ['192.168.0.0-192.168.0.255', '192.168.0.108', 24], + ['fd7a:115c:a1e0::/64', 'fd7a:115c:a1e0::5', 64], + ['fd7a:115c:a1e0::-fd7a:115c:a1e0:0:ffff:ffff:ffff:ffff', 'fd7a:115c:a1e0::5', 64] + ])('accepts explicit scope %s covering the selected local subnet', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(true) + }) + + it.each([ + ['192.168.1.0/24', '192.168.0.108', 24], + ['192.168.0.64/26', '192.168.0.108', 24], + ['192.168.0.108', '192.168.0.108', 24], + ['fd7a:115c:a1e1::/64', 'fd7a:115c:a1e0::5', 64], + ['Internet', '192.168.0.108', 24] + ])('rejects restrictive or unsupported scope %s', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(false) + }) + + it('does not infer explicit scope coverage without selected interface subnet data', () => { + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], undefined, undefined) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], '192.168.0.108', 40) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it.each([ + undefined, + null, + [], + {}, + [rule(undefined)], + [rule([])], + [rule([''])], + [rule(['not-an-address'])], + [{ remoteAddresses: [42] }] + ])('rejects malformed or empty structured output %#', (rules) => { + expect(hasSufficientWindowsFirewallRemoteScope(rules, '192.168.0.108', 24)).toBe(false) + }) + + it('evaluates each rule independently instead of merging partial ranges', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0-192.168.0.127']), rule(['192.168.0.128-192.168.0.255'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.1.0/24']), rule(['192.168.0.0/24'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts PowerShell single-object and single-string JSON shapes', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + { remoteAddresses: '192.168.0.0/24' }, + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts dotted-netmask CIDR with a contiguous mask and rejects a holey one', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.255.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.0.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + }) + + it('treats a single-host (/32) interface as coverable only by Any/LocalSubnet keywords', () => { + // A /32 subnet is just the desktop itself, so an explicit range cannot prove + // the phone (a different host) is allowed — only the keywords can. + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it('fails address-family keywords closed when the interface family is unknown', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( + false + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], undefined, undefined)).toBe( + false + ) + }) + + it.each([['Intranet'], ['DNS'], ['DHCP'], ['DefaultGateway'], ['PlayToDevice']])( + 'fails the policy-defined %s keyword closed rather than assuming subnet coverage', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + false + ) + } + ) +}) diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts new file mode 100644 index 00000000000..9845413d079 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -0,0 +1,216 @@ +type IpVersion = 4 | 6 + +type ParsedIpAddress = { + version: IpVersion + bits: 32 | 128 + value: bigint +} + +type IpRange = { + version: IpVersion + start: bigint + end: bigint +} + +export function hasSufficientWindowsFirewallRemoteScope( + ruleScopes: unknown, + localAddress: unknown, + localPrefixLength: unknown +): boolean { + const rules = Array.isArray(ruleScopes) ? ruleScopes : [ruleScopes] + const localSubnet = parseSubnet(localAddress, localPrefixLength) + + // Why: coverage is checked per scope, never unioned across rules — this + // advisory check fails safe, so accepting fragmented rules only adds risk. + return rules.some((rule) => ruleHasSufficientScope(rule, localSubnet)) +} + +function ruleHasSufficientScope(rule: unknown, localSubnet: IpRange | null): boolean { + if (!isRecord(rule)) { + return false + } + const addresses = Array.isArray(rule.remoteAddresses) + ? rule.remoteAddresses + : [rule.remoteAddresses] + + return addresses.some( + (address) => typeof address === 'string' && addressScopeIsSufficient(address, localSubnet) + ) +} + +function addressScopeIsSufficient(scope: string, localSubnet: IpRange | null): boolean { + const normalized = scope.trim().toLowerCase() + if (normalized === 'any' || normalized === 'localsubnet') { + return true + } + if ( + normalized === 'any4' || + normalized === 'any6' || + normalized === 'localsubnet4' || + normalized === 'localsubnet6' + ) { + // Why: these keywords cover a single family, so they need the selected + // interface's family; an unknown family (null subnet) fails closed. + return localSubnet?.version === (normalized.endsWith('4') ? 4 : 6) + } + if (!localSubnet) { + return false + } + + // Why: without a phone IP we can only prove coverage when the rule spans the + // whole selected subnet; a single-host subnet (/32, /128 VPN/Tailscale) is just + // the desktop, so start !== end blocks a desktop-only rule from a false-allow. + const explicitRange = parseIpRange(scope) + return ( + localSubnet.start !== localSubnet.end && + explicitRange?.version === localSubnet.version && + explicitRange.start <= localSubnet.start && + explicitRange.end >= localSubnet.end + ) +} + +function parseSubnet(address: unknown, prefixLength: unknown): IpRange | null { + if (typeof address !== 'string' || typeof prefixLength !== 'number') { + return null + } + const parsed = parseIpAddress(address) + return parsed ? subnetFromParsed(parsed, prefixLength) : null +} + +function subnetFromParsed(parsed: ParsedIpAddress, prefixLength: number): IpRange | null { + if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > parsed.bits) { + return null + } + const hostBits = BigInt(parsed.bits - prefixLength) + const hostMask = hostBits === 0n ? 0n : (1n << hostBits) - 1n + const start = parsed.value & ~hostMask + return { version: parsed.version, start, end: start | hostMask } +} + +// Why: Windows also accepts dotted-netmask CIDR (192.168.0.0/255.255.255.0); +// convert a contiguous mask to a prefix length and fail closed on holey masks. +function maskPrefixLength(maskText: string, version: IpVersion): number | null { + const mask = parseIpAddress(maskText) + if (!mask || mask.version !== version) { + return null + } + const fullMask = (1n << BigInt(mask.bits)) - 1n + const hostPart = ~mask.value & fullMask + if ((hostPart & (hostPart + 1n)) !== 0n) { + return null + } + let hostBits = 0 + for (let remaining = hostPart; remaining > 0n; remaining >>= 1n) { + hostBits += 1 + } + return mask.bits - hostBits +} + +function parseIpRange(scope: string): IpRange | null { + const trimmed = scope.trim() + const dashIndex = trimmed.indexOf('-') + if (dashIndex >= 0) { + if (dashIndex !== trimmed.lastIndexOf('-')) { + return null + } + const start = parseIpAddress(trimmed.slice(0, dashIndex)) + const end = parseIpAddress(trimmed.slice(dashIndex + 1)) + if (!start || !end || start.version !== end.version || start.value > end.value) { + return null + } + return { version: start.version, start: start.value, end: end.value } + } + + const slashIndex = trimmed.indexOf('/') + if (slashIndex >= 0) { + if (slashIndex !== trimmed.lastIndexOf('/')) { + return null + } + const address = parseIpAddress(trimmed.slice(0, slashIndex)) + if (!address) { + return null + } + const suffix = trimmed.slice(slashIndex + 1) + const prefixLength = /^\d+$/.test(suffix) + ? Number(suffix) + : maskPrefixLength(suffix, address.version) + return prefixLength === null ? null : subnetFromParsed(address, prefixLength) + } + + const address = parseIpAddress(trimmed) + return address ? { version: address.version, start: address.value, end: address.value } : null +} + +function parseIpAddress(input: string): ParsedIpAddress | null { + const trimmed = input.trim() + const bracketed = trimmed.startsWith('[') || trimmed.endsWith(']') + if (bracketed && !(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return null + } + const address = (bracketed ? trimmed.slice(1, -1) : trimmed).split('%', 1)[0] ?? '' + return address.includes(':') ? parseIpv6(address) : parseIpv4(address) +} + +function parseIpv4(address: string): ParsedIpAddress | null { + const octets = address.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return null + } + const values = octets.map(Number) + if (values.some((octet) => octet > 255)) { + return null + } + const value = values.reduce((result, octet) => (result << 8n) | BigInt(octet), 0n) + return { version: 4, bits: 32, value } +} + +function parseIpv6(address: string): ParsedIpAddress | null { + const expandedAddress = expandEmbeddedIpv4(address) + if (!expandedAddress) { + return null + } + const halves = expandedAddress.split('::') + if (halves.length > 2) { + return null + } + const left = splitIpv6Half(halves[0] ?? '') + const right = splitIpv6Half(halves[1] ?? '') + if (!left || !right) { + return null + } + + const hasCompression = halves.length === 2 + const missingGroups = 8 - left.length - right.length + if ((!hasCompression && missingGroups !== 0) || (hasCompression && missingGroups < 1)) { + return null + } + const groups = [...left, ...Array(missingGroups).fill('0'), ...right] + const value = groups.reduce((result, group) => (result << 16n) | BigInt(`0x${group}`), 0n) + return { version: 6, bits: 128, value } +} + +function expandEmbeddedIpv4(address: string): string | null { + if (!address.includes('.')) { + return address + } + const lastColon = address.lastIndexOf(':') + const ipv4 = parseIpv4(address.slice(lastColon + 1)) + if (lastColon < 0 || !ipv4) { + return null + } + const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) + const low = (ipv4.value & 0xffffn).toString(16) + return `${address.slice(0, lastColon)}:${high}:${low}` +} + +function splitIpv6Half(half: string): string[] | null { + if (half === '') { + return [] + } + const groups = half.split(':') + return groups.every((group) => /^[\da-f]{1,4}$/i.test(group)) ? groups : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 4a7cc8a1219..56464c15bf1 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -24,7 +24,9 @@ describe('windows mobile firewall', () => { it('inspects the exact executable, port, and selected interface profile', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ - ruleAllowed: true, + matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, privateFirewallEnabled: true, networkCategory: 'Private' }) @@ -47,6 +49,29 @@ describe('windows mobile firewall', () => { expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") expect(script).toContain("$profile -match 'Private'") expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + expect(script).toContain('Get-NetFirewallAddressFilter') + expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress') + expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') + }) + + it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['192.168.1.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: true + }) }) it('does not support non-Windows or unpackaged development builds', async () => { @@ -81,6 +106,22 @@ describe('windows mobile firewall', () => { }) }) + it('returns an actionable status for malformed or empty PowerShell output', async () => { + for (const stdout of ['', 'not json']) { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockResolvedValue(stdout)) + ) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: false + }) + } + }) + it('repairs only Orca mobile pairing on private networks after elevation', async () => { const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index b6419da9f5d..36797b83eb2 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -5,6 +5,7 @@ import type { WindowsMobileFirewallStatus, WindowsNetworkCategory } from '../../shared/windows-mobile-firewall' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' const FIREWALL_RULE_NAME = 'Orca.MobilePairing' const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' @@ -22,7 +23,9 @@ export type WindowsMobileFirewallEnvironment = { } type FirewallInspection = { - ruleAllowed: boolean + matchingRuleScopes?: unknown + localAddress?: unknown + localPrefixLength?: unknown privateFirewallEnabled: boolean networkCategory: string } @@ -51,7 +54,11 @@ export async function inspectWindowsMobileFirewall( return { supported: true, port, - ruleAllowed: result.ruleAllowed === true, + ruleAllowed: hasSufficientWindowsFirewallRemoteScope( + result.matchingRuleScopes, + result.localAddress, + result.localPrefixLength + ), privateFirewallEnabled: result.privateFirewallEnabled !== false, networkCategory: parseNetworkCategory(result.networkCategory), inspectionAvailable: true @@ -152,12 +159,16 @@ function buildInspectionScript(port: number, executablePath: string, address?: s ? ` try { $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $localAddress = [string]$ip.IPAddress + $localPrefixLength = [int]$ip.PrefixLength $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 if ($profile) { $networkCategory = [string]$profile.NetworkCategory } } catch {}` : '' + // Why: NetSecurity filter properties are stable across localized Windows + // display output and keep every rule's address scope independent. return `$ErrorActionPreference = 'Stop' -$ruleAllowed = $false +$matchingRuleScopes = @() $rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) foreach ($rule in $rules) { $portFilter = $rule | Get-NetFirewallPortFilter @@ -165,16 +176,21 @@ foreach ($rule in $rules) { $profile = [string]$rule.Profile $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { - $ruleAllowed = $true + $addressFilter = $rule | Get-NetFirewallAddressFilter + $matchingRuleScopes += [pscustomobject]@{ + remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + } } } $privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled $networkCategory = 'Unknown'${addressLookup} [pscustomobject]@{ - ruleAllowed = $ruleAllowed + matchingRuleScopes = @($matchingRuleScopes) + localAddress = $localAddress + localPrefixLength = $localPrefixLength privateFirewallEnabled = $privateFirewallEnabled networkCategory = $networkCategory -} | ConvertTo-Json -Compress` +} | ConvertTo-Json -Depth 4 -Compress` } function buildRepairScript(port: number, executablePath: string): string { From 79369d5396355ed1e119a008e89089f28295f3de Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:08:46 -0700 Subject: [PATCH 32/52] Fail loudly on chmod errors in remote CLI launcher install (#8648) Silently swallowing chmod failures let installs proceed with a non-executable launcher, surfacing as a confusing runtime error later instead of a clear install-time failure. --- src/main/ssh/ssh-remote-cli-launcher.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts index 871cc6076ac..a246ce9c9b8 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -232,6 +232,7 @@ export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliI ].join('\n') } ], - postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`] + // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] } } From f93e92646cb3c3791e8aec584974f6a41da8a246 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:12:30 -0700 Subject: [PATCH 33/52] P2 watcher lifecycle bounds (#8640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix watcher lifecycle cancellation bounds * fix(review): guard remote watcher installs against post-shutdown resurrection closeAllWatchers aborted the in-flight install *tokens* it could see, but a same-key joiner awaiting a 'cancelled' resolution (and a fired retry tick) calls installRemoteWatcher directly and, on the fresh-generation recursion, builds a brand-new non-aborted AbortController and calls provider.watch() after teardown — leaking an SSH watcher into the just-cleared remoteWatchers map. Latch the subsystem shut in closeAllWatchers and refuse installs while latched; a genuine new fs:watchWorktree clears it. Adds a regression test (fails without the latch) plus a test for the same-tick handoff-revival guard that had no coverage. Also extract the duplicated isolated-quarantine-vs-fuse branch shared by retireSlot and releaseFailedRoot into quarantineOrFuse (behavior-preserving). * Add lifecycle generation guard to refuse stale remote-watcher joiners - A boolean latch alone can't distinguish a pre-shutdown joiner from a fresh call once a genuine new watch reopens the subsystem, letting a stale joiner recurse and register a post-shutdown provider.watch() - Each installRemoteWatcher call now captures a generation counter that closeAllWatchers bumps, so a waiter that resumes after a later shutdown+reopen is refused instead of resurrecting - Adds a regression test covering the shutdown-then-reopen race --- ...system-watcher-remote-cancellation.test.ts | 299 ++++++++++++++++++ src/main/ipc/filesystem-watcher.test.ts | 8 +- src/main/ipc/filesystem-watcher.ts | 121 +++++-- .../ipc/runtime-watcher-process-pool.test.ts | 55 ++++ src/main/ipc/runtime-watcher-process-pool.ts | 44 ++- 5 files changed, 483 insertions(+), 44 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-remote-cancellation.test.ts diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts new file mode 100644 index 00000000000..3e22cb6b835 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('remote filesystem watcher cancellation', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + try { + expect(watchMock).toHaveBeenCalledTimes(1) + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(true) + } finally { + resolveInstall?.(lateUnwatch) + await Promise.all([first, second]) + } + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('starts a fresh same-root generation when a listener arrives after physical abort', async () => { + let firstSignal: AbortSignal | undefined + let secondCallback: ((events: unknown[]) => void) | undefined + const secondUnwatch = vi.fn() + const watchMock = vi + .fn() + .mockImplementationOnce( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((_resolve, reject) => { + firstSignal = options?.signal + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + .mockImplementationOnce((_rootPath, callback) => { + secondCallback = callback + return Promise.resolve(secondUnwatch) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + + await Promise.all([first, second]) + expect(watchMock).toHaveBeenCalledTimes(2) + secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(secondSender.send).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + expect(secondUnwatch).toHaveBeenCalledTimes(1) + }) + + it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const destroyedCallbacks: (() => void)[] = [] + const destroyedSender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' } + const destroyedWatch = handlers['fs:watchWorktree']( + { sender: destroyedSender }, + destroyedArgs + ) as Promise + + await Promise.resolve() + destroyedCallbacks[0]() + await Promise.resolve() + expect(installs.get('/destroyed')?.signal?.aborted).toBe(true) + installs.get('/destroyed')?.resolve(vi.fn()) + await destroyedWatch + + const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' } + const shutdownWatch = handlers['fs:watchWorktree']( + { sender: shutdownSender }, + shutdownArgs + ) as Promise + + await Promise.resolve() + await closeAllWatchers() + expect(installs.get('/shutdown')?.signal?.aborted).toBe(true) + installs.get('/shutdown')?.resolve(vi.fn()) + await shutdownWatch + }) + + it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // The last listener leaves and a replacement joins in the SAME tick — before + // the queued abort microtask runs. The shared install must survive. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + expect(watchMock).toHaveBeenCalledTimes(1) + + resolveInstall?.(vi.fn()) + await Promise.all([first, second]) + }) + + it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => { + let firstSignal: AbortSignal | undefined + let resolveFirst: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + firstSignal = options?.signal + resolveFirst = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it awaits the + // 'cancelled' resolution and would recurse into a fresh install. + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + await Promise.resolve() + + // Shutdown latches the subsystem before the joiner's recursion runs. + await closeAllWatchers() + + // Late success of the aborted generation must be unwatched, not registered. + resolveFirst?.(lateUnwatch) + await Promise.all([first, second]) + + // The recursion is refused post-shutdown: provider.watch() is never called again. + expect(watchMock).toHaveBeenCalledTimes(1) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' } + const lateUnwatch = vi.fn() + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it captures the + // current lifecycle generation and awaits the 'cancelled' resolution. + const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise + await Promise.resolve() + + // Shutdown bumps the generation, then a genuine new watch reopens the subsystem + // (clearing the boolean latch) before the joiner resumes. + await closeAllWatchers() + const reopen = handlers['fs:watchWorktree']( + { sender: reopenSender }, + reopenArgs + ) as Promise + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(2) + + // Now let the aborted install resolve; the joiner recurses on the stale generation. + installs.get('/home/me/repo')?.resolve(lateUnwatch) + installs.get('/home/me/other')?.resolve(vi.fn()) + await Promise.all([first, joiner, reopen]) + + // The joiner's recursion is refused despite the reopen: no third provider.watch(). + expect(watchMock).toHaveBeenCalledTimes(2) + expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength( + 1 + ) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 0b3b07ca071..f776e0d494d 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) await vi.advanceTimersByTimeAsync(1_000) - expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) const onEvents = watchMock.mock.calls[0][1] onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { @@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) - expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) handlers['fs:unwatchWorktree']( { sender: { id: 1 } }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 983fc9ce985..665b543779f 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -652,22 +652,31 @@ type RemoteWatcherState = { type RemoteWatcherInstallToken = { cancelled: boolean listeners: Map + abortController: AbortController + abortScheduled: boolean } // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() -// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that -// arrives while a watch is still resolving can mark the install cancelled. -// Without this, the awaited unwatch handle would be installed after the -// renderer thinks the watch is gone, leaking a native watcher. +// Why: track in-flight `provider.watch()` calls so last-listener cleanup can +// abort relay setup, while late success is still unwatched instead of leaked. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls for the same key so // overlapping fs:watchWorktree IPCs share one native watcher and one listener // map, instead of each call independently invoking provider.watch() and // overwriting the per-key state on resolution. const pendingRemoteInstallPromises = new Map>() +// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner +// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing +// the token-abort loop. A genuine new fs:watchWorktree clears the latch. +let remoteWatchersClosed = false +// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a +// fresh call once a genuine new watch reopens the subsystem. Each call captures +// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited +// across a shutdown+reopen recurses on a stale generation and is refused. +let remoteWatcherLifecycleGeneration = 0 const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -675,7 +684,7 @@ function addInFlightRemoteInstallListener( token: RemoteWatcherInstallToken, sender: WebContents ): void { - if (sender.isDestroyed()) { + if (sender.isDestroyed() || token.abortController.signal.aborted) { return } token.listeners.set(sender.id, sender) @@ -683,12 +692,26 @@ function addInFlightRemoteInstallListener( registerSenderCleanup(sender) } +function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void { + token.cancelled = token.listeners.size === 0 + if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) { + return + } + token.abortScheduled = true + // Why: a replacement sender can synchronously revive the shared install + // during a renderer handoff; otherwise stop the relay crawl next microtask. + queueMicrotask(() => { + token.abortScheduled = false + if (token.cancelled && token.listeners.size === 0) { + token.abortController.abort() + } + }) +} + function cleanupInFlightRemoteInstallsForSender(senderId: number): void { for (const token of inFlightRemoteInstalls.values()) { token.listeners.delete(senderId) - if (token.listeners.size === 0) { - token.cancelled = true - } + cancelInFlightRemoteInstallIfUnowned(token) } } @@ -726,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' async function installRemoteWatcher( sender: WebContents, connectionId: string, - worktreePath: string + worktreePath: string, + generation = remoteWatcherLifecycleGeneration ): Promise { + // Why: refuse installs racing in after teardown (joiner recursion, fired retry + // tick) so provider.watch() is never called and registered post-shutdown. The + // generation guard also refuses a waiter that captured an earlier lifecycle, + // even after a new watch reopened the subsystem. + if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) { + return 'cancelled' + } const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { return 'unavailable' @@ -747,7 +778,8 @@ async function installRemoteWatcher( const pendingInstall = pendingRemoteInstallPromises.get(key) if (pendingInstall) { const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { + const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted + if (canJoinInstall) { // Why: a new watcher can join after all previous pending listeners // unwatched but before provider.watch() resolves; revive that install // instead of inheriting the stale cancellation. @@ -762,9 +794,27 @@ async function installRemoteWatcher( ) { addRemoteWatchListener(key, sender) } + if ( + result === 'cancelled' && + !canJoinInstall && + !sender.isDestroyed() && + generation === remoteWatcherLifecycleGeneration + ) { + // Why: AbortSignal cannot be revived. A listener arriving after physical + // cancellation waits out that generation, then owns a fresh install. + if (pendingRemoteInstallPromises.get(key) === pendingInstall) { + pendingRemoteInstallPromises.delete(key) + } + return installRemoteWatcher(sender, connectionId, worktreePath, generation) + } return result } - const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() } + const cancelToken: RemoteWatcherInstallToken = { + cancelled: false, + listeners: new Map(), + abortController: new AbortController(), + abortScheduled: false + } inFlightRemoteInstalls.set(key, cancelToken) addInFlightRemoteInstallListener(cancelToken, sender) const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) @@ -786,22 +836,29 @@ async function doInstallRemoteWatcher( ): Promise { let unwatch: () => void try { - unwatch = await provider.watch(worktreePath, (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return - } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue + unwatch = await provider.watch( + worktreePath, + (events) => { + const state = remoteWatchers.get(key) + if (!state) { + return } - listener.send('fs:changed', { - worktreePath, - events - } satisfies FsChangedPayload) - } - }) + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue + } + listener.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }, + { signal: cancelToken.abortController.signal } + ) } catch (err) { + if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { + return 'cancelled' + } console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err) return 'unavailable' } finally { @@ -884,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { + // Why: a real new watch reopens the subsystem after closeAllWatchers + // latched it shut (also how tests reset between cases). + remoteWatchersClosed = false const key = `${args.connectionId}:${args.worktreePath}` const result = await installRemoteWatcher( event.sender, @@ -923,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void { const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.delete(_event.sender.id) - inFlight.cancelled = inFlight.listeners.size === 0 + cancelInFlightRemoteInstallIfUnowned(inFlight) } loggedUnavailableRemoteWatchers.delete(key) releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) @@ -951,10 +1011,19 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() loggedUnavailableRemoteWatchers.clear() + // Why: latch the subsystem shut and drop the dedup map so a late install that + // begins after teardown is refused instead of registering post-shutdown. Bump + // the generation so a waiter that resumes after a later reopen still recurses + // on a stale lifecycle and is refused. + remoteWatchersClosed = true + remoteWatcherLifecycleGeneration += 1 + pendingRemoteInstallPromises.clear() // Why: cancel any in-flight provider.watch() calls so their resolved // unwatch handles are discarded instead of being installed after shutdown. for (const token of inFlightRemoteInstalls.values()) { + token.listeners.clear() token.cancelled = true + token.abortController.abort() } for (const token of inFlightLocalInstalls.values()) { token.listeners.clear() diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts index e43112e4ef4..5cd624589b2 100644 --- a/src/main/ipc/runtime-watcher-process-pool.test.ts +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -162,6 +162,30 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow']) }) + it('allows only one quarantine generation when isolated setup also times out', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher subscription timed out', + 'subscription', + 'subscribe_timeout' + ) + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisor.subscribeError = timeout + supervisors.push(supervisor) + return supervisor + } + }) + + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('moves a live root into quarantine when crash resubscription times out', async () => { const timeout = new WatcherProcessFailure( 'file watcher resubscription timed out', @@ -179,6 +203,37 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery']) }) + it('does not replace an isolated live root after its resubscription times out', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const fused = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + const timeout = new WatcherProcessFailure( + 'file watcher resubscription timed out', + 'subscription', + 'subscribe_timeout' + ) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + + supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout) + + await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('keeps healthy shard assignments after a root-specific failure', async () => { pool = new RuntimeWatcherProcessPool({ maxSharedSupervisors: 1, diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index b5a2bf45d44..b4e46cb3d2c 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -77,10 +77,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } hooks.onTerminalError?.(error) } @@ -94,10 +91,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } throw error } @@ -207,14 +201,7 @@ export class RuntimeWatcherProcessPool { if (this.assignments.get(root)?.slot === slot) { this.assignments.delete(root) } - if (slot.isolated) { - // Why: one bounded quarantine attempt is the recovery budget for a - // watch lifetime; repeated fused replacements would recreate churn. - this.isolatedRoots.delete(root) - this.failedQuarantineRoots.add(root) - } else { - this.isolatedRoots.add(root) - } + this.quarantineOrFuse(root, slot.isolated) } slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal @@ -240,6 +227,31 @@ export class RuntimeWatcherProcessPool { } } + private releaseFailedRoot( + assignment: RuntimeWatcherPoolAssignment, + dir: string, + error: unknown, + releaseAssignment: () => void + ): void { + releaseAssignment() + if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') { + return + } + this.quarantineOrFuse(dir, assignment.slot.isolated) + } + + // Why: one bounded quarantine attempt is the recovery budget per watch + // lifetime; an already-isolated root that fails again is fused, not re-isolated, + // so it cannot spawn another child generation. + private quarantineOrFuse(dir: string, isolated: boolean): void { + if (isolated) { + this.isolatedRoots.delete(dir) + this.failedQuarantineRoots.add(dir) + return + } + this.isolatedRoots.add(dir) + } + private disposeSlot(slot: RuntimeWatcherPoolSlot): void { if (slot.disposed) { return From 2801c6b03b2ebb176f937a4e78297daf939a3ed8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:13:19 -0700 Subject: [PATCH 34/52] P2 live log undo memory (#8644) * fix(editor): avoid undo history for read-only live tails * Add reliability-gate evidence for read-only live-tail undo-history fix - Records the passing vitest run that verifies live-tail appends leave canUndo false while ordinary external updates stay undoable, backing the recent editor undo-history fix. --- config/reliability-gates.jsonc | 53 +++++++++++-- .../EditorContent.monaco-lifecycle.test.tsx | 31 +++++++- .../src/components/editor/EditorContent.tsx | 1 + .../src/components/editor/MonacoEditor.tsx | 18 ++++- .../editor/monaco-content-sync.test.ts | 60 +++++++++++++- .../components/editor/monaco-content-sync.ts | 79 +++++++++++-------- .../monaco-content-sync.undo-history.test.ts | 51 ++++++++++++ ...onaco-content-sync.undo-retention.bench.ts | 78 ++++++++++++++++++ .../src/components/editor/monaco-e2e-probe.ts | 2 + .../agent-session-log-tail-stability.spec.ts | 14 ++-- 10 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts create mode 100644 src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 6a9ba8db139..64ac34ed173 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-12", + "updatedAt": "2026-07-13", "policy": { "maturityLevels": [ "experimental", @@ -172,20 +172,24 @@ "providers": ["local"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", + "coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"], - "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.", - "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.", + "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.", + "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1" ], "testFiles": [ "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", "tests/e2e/agent-session-log-tail-stability.spec.ts" ], "assertionRefs": [ @@ -194,9 +198,14 @@ "assertions": [ "append-only drift reads the current model once and inserts only at the previous model end", "identical content emits no edit and non-append drift retains full replacement plus undo stops", + "read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits", "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated" ] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "assertions": ["a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"] + }, { "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"] @@ -209,12 +218,17 @@ "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts", "assertions": ["with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", + "assertions": ["five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB"] + }, { "file": "tests/e2e/agent-session-log-tail-stability.spec.ts", "assertions": [ "production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check", "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm", "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content", + "the production read-only live-tail model remains non-undoable before and after every watcher append", "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls" ] } @@ -246,12 +260,39 @@ "result": "passed", "durationSeconds": 78, "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 5, + "summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false." } ], "runtimeBudget": { "p95Seconds": 600, "scope": "local focused renderer tests plus one Electron production-journey scenario" }, "flakeHistory": { "status": "unknown", "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." }, - "redGreenEvidence": { "status": "complete", "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5." }, - "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls." }, + "redGreenEvidence": { "status": "complete", "evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control." }, + "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass." }, "promotionCriteria": [ "Collect stable soak history on macOS, Linux, and Windows.", "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes." diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 77c78d0a472..3251b43ac1a 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor' const lifecycle = vi.hoisted(() => ({ events: [] as string[], - models: new Map() + models: new Map(), + mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[] })) vi.mock('@/lib/lazy-with-retry', async () => { @@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => { if (!factory.toString().includes('/MonacoEditor.tsx')) { return () => null } - return function MockRetainedMonaco(props: { filePath: string; content: string }) { + return function MockRetainedMonaco(props: { + filePath: string + content: string + readOnly?: boolean + liveTail?: boolean + }) { /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ React.useEffect(() => { lifecycle.events.push(`mount:${props.filePath}`) + lifecycle.mountedProps.push({ + filePath: props.filePath, + readOnly: props.readOnly, + liveTail: props.liveTail + }) const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } const model = { getValue: () => retained.content, @@ -93,7 +104,7 @@ vi.mock('@/store', () => ({ import { EditorContent } from './EditorContent' -function file(filePath: string): OpenFile { +function file(filePath: string, overrides: Partial = {}): OpenFile { return { id: filePath, filePath, @@ -101,7 +112,8 @@ function file(filePath: string): OpenFile { worktreeId: 'repo::/repo', language: 'typescript', isDirty: false, - mode: 'edit' + mode: 'edit', + ...overrides } } @@ -136,6 +148,7 @@ afterEach(() => { cleanup() lifecycle.events.length = 0 lifecycle.models.clear() + lifecycle.mountedProps.length = 0 }) describe('EditorContent Monaco lifecycle boundary', () => { @@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => { undo: ['first undo'] }) }) + + it('passes live-tail ownership only for a read-only live log', () => { + const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true }) + + render() + + expect(lifecycle.mountedProps).toEqual([ + { filePath: liveLog.filePath, readOnly: true, liveTail: true } + ]) + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 3e56b7c58da..023c0b9fcae 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -327,6 +327,7 @@ export function EditorContent({ // the change/save callbacks so no draft, dirty state, or write can occur — // mirrors the conflict-review read-only rendering pattern. readOnly={activeFile.readOnly === true} + liveTail={activeFile.liveTail === true} onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange} onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave} worktreeId={activeFile.worktreeId} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index d8f92a00cf3..daf288d8ce9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select import { useContextualCopySetup } from './useContextualCopySetup' import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal' -import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' +import { + syncContentOnMount, + syncContentUpdate, + type MonacoContentSyncMode +} from './monaco-content-sync' import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search' import { beginProgrammaticContentSync, @@ -77,6 +81,7 @@ type MonacoEditorProps = { markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean readOnly?: boolean + liveTail?: boolean autoHeight?: boolean } @@ -101,6 +106,7 @@ export default function MonacoEditor({ markdownAnnotationsEnabled = false, conflictDecorationsEnabled = false, readOnly = false, + liveTail = false, autoHeight = false }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) @@ -131,6 +137,8 @@ export default function MonacoEditor({ propsRef.current = { relativePath, language, onSave, onContentChange } const readOnlyRef = useRef(readOnly) readOnlyRef.current = readOnly + const contentSyncModeRef = useRef('undoable') + contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable' const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) @@ -370,7 +378,11 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current) + const didSyncOnMount = syncContentOnMount( + editorInstance, + contentRef.current, + contentSyncModeRef.current + ) if (didSyncOnMount) { lastSyncedContentRef.current = contentRef.current } @@ -672,7 +684,7 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - syncContentUpdate(ed, content) + syncContentUpdate(ed, content, contentSyncModeRef.current) lastSyncedContentRef.current = content } finally { isApplyingProgrammaticContentRef.current = false diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts index ccf958f122e..30f833d379c 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.test.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -9,6 +9,7 @@ function createHarness( editorInstance: editor.IStandaloneCodeEditor getValue: ReturnType getFullModelRange: ReturnType + applyEdits: ReturnType pushEditOperations: ReturnType pushUndoStop: ReturnType } { @@ -20,18 +21,27 @@ function createHarness( endColumn: 5 })) const pushEditOperations = vi.fn() + const applyEdits = vi.fn() const model = { getValue, getEOL: () => eol, getFullModelRange, - pushEditOperations + pushEditOperations, + applyEdits } as unknown as editor.ITextModel const pushUndoStop = vi.fn() const editorInstance = { getModel: () => model, pushUndoStop } as unknown as editor.IStandaloneCodeEditor - return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop } + return { + editorInstance, + getValue, + getFullModelRange, + applyEdits, + pushEditOperations, + pushUndoStop + } } describe('monaco content sync', () => { @@ -60,6 +70,26 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it('inserts a read-only live-tail suffix without recording undo history', () => { + const harness = createHarness('first\nsecond\nlast') + + syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { + range: { + startLineNumber: 3, + startColumn: 5, + endLineNumber: 3, + endColumn: 5 + }, + text: '\nnext' + } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing for identical content', () => { const harness = createHarness('unchanged') @@ -127,6 +157,22 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it.each([ + ['same-length rewrite', 'before', 'after!'], + ['prefix mismatch', 'before', 'changed content'], + ['truncation', 'longer content', 'short'] + ])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => { + const harness = createHarness(initialContent) + + syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('reconciles a stale retained model on mount without undo stops', () => { const harness = createHarness('stale') @@ -136,6 +182,16 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).not.toHaveBeenCalled() }) + it('reconciles a stale read-only live-tail model on mount without undo history', () => { + const harness = createHarness('stale') + + expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true) + + expect(harness.applyEdits).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing on mount when content already matches', () => { const harness = createHarness('same') diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index b7894af432c..fa19928b89d 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -1,5 +1,7 @@ import type { editor } from 'monaco-editor' +export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail' + function normalizeToModelEol(content: string, model: editor.ITextModel): string { const eol = model.getEOL() // Why: Monaco normalizes model line endings, while filesystem content keeps @@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string return content.replace(/\r\n|\r|\n/g, eol) } -/** - * Overwrite a Monaco model's text via pushEditOperations so the change - * participates in the undo stack (unlike setValue, which blows it away). - */ +function applyModelEdit( + editorInstance: editor.IStandaloneCodeEditor, + model: editor.ITextModel, + edit: editor.IIdentifiedSingleEditOperation, + mode: MonacoContentSyncMode, + withUndoStops: boolean +): void { + if (mode === 'read-only-live-tail') { + // Why: live-tail updates are machine-owned and cannot be undone by users; + // recording them would retain the growing log again in Monaco's undo service. + model.applyEdits([edit]) + return + } + if (withUndoStops) { + editorInstance.pushUndoStop() + } + model.pushEditOperations([], [edit], () => null) + if (withUndoStops) { + editorInstance.pushUndoStop() + } +} + function replaceModelContent( editorInstance: editor.IStandaloneCodeEditor, model: editor.ITextModel, currentContent: string, content: string, + mode: MonacoContentSyncMode, withUndoStops: boolean ): void { if (currentContent === content) { return } const fullRange = model.getFullModelRange() - if (withUndoStops) { - editorInstance.pushUndoStop() - } - model.pushEditOperations([], [{ range: fullRange, text: content }], () => null) - if (withUndoStops) { - editorInstance.pushUndoStop() - } + applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops) } /** @@ -46,7 +61,8 @@ function replaceModelContent( */ export function syncContentOnMount( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): boolean { const model = editorInstance.getModel() if (!model) { @@ -60,7 +76,7 @@ export function syncContentOnMount( // Why: no undo stop on mount — the retained model's text was already the // user's last-known state, and adding an undo entry here would make Cmd+Z // revert to the pre-remount text, which is confusing. - replaceModelContent(editorInstance, model, currentContent, normalizedContent, false) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false) return true } @@ -74,7 +90,8 @@ export function syncContentOnMount( */ export function syncContentUpdate( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): void { const model = editorInstance.getModel() if (!model) { @@ -83,7 +100,7 @@ export function syncContentUpdate( const currentContent = model.getValue() const normalizedContent = normalizeToModelEol(content, model) if (currentContent.length === normalizedContent.length) { - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) return } if ( @@ -93,24 +110,22 @@ export function syncContentUpdate( // Why: preserving the existing prefix lets Monaco retain viewport, // selection, find-widget, and tokenization state above a live-file append. const fullRange = model.getFullModelRange() - editorInstance.pushUndoStop() - model.pushEditOperations( - [], - [ - { - range: { - startLineNumber: fullRange.endLineNumber, - startColumn: fullRange.endColumn, - endLineNumber: fullRange.endLineNumber, - endColumn: fullRange.endColumn - }, - text: normalizedContent.slice(currentContent.length) - } - ], - () => null + applyModelEdit( + editorInstance, + model, + { + range: { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }, + text: normalizedContent.slice(currentContent.length) + }, + mode, + true ) - editorInstance.pushUndoStop() return } - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) } diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts new file mode 100644 index 00000000000..7118634a3b1 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import * as monaco from 'monaco-editor' +import { afterEach, describe, expect, it } from 'vitest' +import { syncContentUpdate } from './monaco-content-sync' + +const models: monaco.editor.ITextModel[] = [] + +function createEditor(initialContent: string): { + editorInstance: monaco.editor.IStandaloneCodeEditor + model: monaco.editor.ITextModel +} { + const model = monaco.editor.createModel(initialContent, 'plaintext') + models.push(model) + return { + model, + editorInstance: { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + } +} + +afterEach(() => { + for (const model of models.splice(0)) { + model.dispose() + } +}) + +describe('Monaco external-content undo history', () => { + it('does not make a read-only live-tail append undoable', () => { + const { editorInstance, model } = createEditor('first line') + + syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail') + + expect(model.getValue()).toBe('first line\nappended') + expect(model.canUndo()).toBe(false) + }) + + it('keeps ordinary external updates undoable', async () => { + const { editorInstance, model } = createEditor('editable') + + syncContentUpdate(editorInstance, 'external update') + + expect(model.canUndo()).toBe(true) + await model.undo() + expect(model.getValue()).toBe('editable') + }) +}) diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts new file mode 100644 index 00000000000..a5fdbe1de34 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { bench, expect } from 'vitest' +import * as monaco from 'monaco-editor' +import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync' + +const MEBIBYTE = 1024 * 1024 +const BATCH_COUNT = 5 +const BATCH_BYTES = 10 * MEBIBYTE + +type UndoStackElement = { heapSize: () => number } +type UndoRedoService = { + getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] } +} + +function undoHistoryBytes(model: monaco.editor.ITextModel): number { + const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService + const elements = service.getElements(model.uri) + return [...elements.past, ...elements.future].reduce( + (total, element) => total + element.heapSize(), + 0 + ) +} + +async function forceGcAndSettle(): Promise { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc') + } + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{ + arrayBufferDelta: number + canUndo: boolean + undoBytes: number +}> { + await forceGcAndSettle() + const beforeArrayBuffers = process.memoryUsage().arrayBuffers + const model = monaco.editor.createModel('', 'plaintext') + const editorInstance = { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + let content = '' + for (let batch = 0; batch < BATCH_COUNT; batch++) { + content += String(batch % 10).repeat(BATCH_BYTES) + syncContentUpdate(editorInstance, content, mode) + } + const canUndo = model.canUndo() + const undoBytes = undoHistoryBytes(model) + await forceGcAndSettle() + const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers + model.dispose() + await forceGcAndSettle() + return { arrayBufferDelta, canUndo, undoBytes } +} + +bench( + '50 MiB read-only live-tail undo retention', + async () => { + const undoable = await measureUndoRetention('undoable') + const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail') + console.log( + `[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}` + ) + expect(undoable.canUndo).toBe(true) + expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(readOnlyLiveTail.canUndo).toBe(false) + expect(readOnlyLiveTail.undoBytes).toBe(0) + expect(readOnlyLiveTail.arrayBufferDelta).toBe(0) + }, + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) diff --git a/src/renderer/src/components/editor/monaco-e2e-probe.ts b/src/renderer/src/components/editor/monaco-e2e-probe.ts index f6284a51771..788a2204794 100644 --- a/src/renderer/src/components/editor/monaco-e2e-probe.ts +++ b/src/renderer/src/components/editor/monaco-e2e-probe.ts @@ -1,6 +1,7 @@ import type { editor, IRange, ISelection } from 'monaco-editor' export type MonacoE2ESnapshot = { + canUndo: boolean contentHeight: number scrollHeight: number scrollTop: number @@ -74,6 +75,7 @@ export function installMonacoE2EProbe( ? lastLine : (model?.getLineContent(lastLineNumber - 1) ?? '') return { + canUndo: model?.canUndo() ?? false, contentHeight: editorInstance.getContentHeight(), scrollHeight: editorInstance.getScrollHeight(), scrollTop: editorInstance.getScrollTop(), diff --git a/tests/e2e/agent-session-log-tail-stability.spec.ts b/tests/e2e/agent-session-log-tail-stability.spec.ts index 01ef02bcf8e..e631a77d197 100644 --- a/tests/e2e/agent-session-log-tail-stability.spec.ts +++ b/tests/e2e/agent-session-log-tail-stability.spec.ts @@ -66,6 +66,7 @@ test.describe('Agent Session History live log', () => { // metrics (macOS baseline 2,775,880px; Linux CI ~2,498,292px), so keep only a // generous content-height floor as a collapsed/truncated-render smoke check. expect(baseline.valueLength).toBe(fixture.initialLength) + expect(baseline.canUndo).toBe(false) expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_000_000) expect(baseline.visibleRanges.length).toBeGreaterThan(0) expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN }) @@ -105,6 +106,7 @@ test.describe('Agent Session History live log', () => { const current = await readProbe(orcaPage) console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`) expect(current.visibleRanges).toEqual(baseline.visibleRanges) + expect(current.canUndo).toBe(false) expect(current.valueTail).toBe(suffix.trimEnd()) expect(current.selection).toEqual(baseline.selection) expect(current.find).toEqual(baseline.find) @@ -173,14 +175,9 @@ async function seedSyntheticSession( } async function openSessionHistory(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') - }) + // Why: startup hydration can overwrite a direct store route; use the same + // activity-bar action a user takes so the Agents panel wins that race. + await page.getByRole('button', { name: 'Agents', exact: true }).click() await page.evaluate(async () => window.api.aiVault.listSessions({ force: true })) await page.getByRole('button', { name: 'Refresh Session History' }).click() } @@ -233,6 +230,7 @@ async function restoreAnchorState( } async function readProbe(page: Page): Promise<{ + canUndo: boolean contentHeight: number filePath: string scrollTop: number From a68bb39ab13ea814f143ce1204321c9233c590af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:18:37 -0700 Subject: [PATCH 35/52] =?UTF-8?q?perf(emulator):=20stop=20serve-sim=20watc?= =?UTF-8?q?her=20waking=20the=20daemon=204=C3=97/sec=20when=20unused=20(#8?= =?UTF-8?q?525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Orca --- .../emulator/serve-sim-state-watcher.test.ts | 33 +++++++++++++++++-- src/main/emulator/serve-sim-state-watcher.ts | 26 +++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 69b57d60b3b..e528ea324bb 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -2,7 +2,11 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher' +import { + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, + ServeSimStateWatcher, + type ServeSimStateDetectedEvent +} from './serve-sim-state-watcher' const TEST_UDID = '11111111-2222-3333-4444-555555555555' @@ -45,7 +49,9 @@ 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({ stateDir }) + // Force darwin (the poll is macOS-only) and a fast interval so this + // cross-platform test exercises the existence-poll -> attach path quickly. + const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) const events: ServeSimStateDetectedEvent[] = [] watcher.bindPty('pty-1', 'worktree-1') @@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + it('does not arm the existence poll on non-macOS platforms', () => { + // serve-sim state never appears off macOS, so start() must not leave a + // recurring timer waking the daemon for a directory that can never exist. + for (const platform of ['win32', 'linux'] as const) { + const watcher = new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), + platform + }) + watcher.start() + const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll + expect(poll).toBeNull() + watcher.stop() + } + }) + + it('defaults the existence poll to a coarse (battery-friendly) interval', () => { + expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) + const watcher = new ServeSimStateWatcher() + expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS + ) + }) + it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { const watcher = createIsolatedWatcher() const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index 1a42b2d0353..dae8e57bc39 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = { } const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') +// Why: this only waits for a rarely-created dir to appear; sub-second detection +// of a detached emulator is not user-perceptible, so a coarse interval avoids +// waking the daemon 4x/sec for the whole session on machines that never use it. +export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g @@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string { export class ServeSimStateWatcher { private readonly stateDir: string + private readonly platform: NodeJS.Platform + private readonly existencePollMs: number private readonly ptyToWorktree = new Map() private readonly ptyBuffers = new Map() private readonly seenExternalKeys = new Set() @@ -81,8 +87,12 @@ export class ServeSimStateWatcher { private stateWatcher: FSWatcher | null = null private stateDirPoll: ReturnType | null = null - constructor(options: { stateDir?: string } = {}) { + constructor( + options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} + ) { this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR + this.platform = options.platform ?? process.platform + this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS } onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { @@ -171,6 +181,13 @@ export class ServeSimStateWatcher { if (this.stateDirPoll || this.stateWatcher) { return } + // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, + // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the + // existence poll there instead of waking the daemon every interval for a + // directory that can never exist. + if (this.platform !== 'darwin') { + return + } try { // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR @@ -182,13 +199,16 @@ export class ServeSimStateWatcher { return } + // attachStateDirWatch() clears this poll once the dir appears and the + // native watcher takes over, so it only ticks while waiting for a + // rarely-created dir — a coarse interval keeps that wait off the idle floor. this.stateDirPoll = setInterval(() => { this.attachStateDirWatch() this.scanExistingStateFiles() - }, 250) + }, this.existencePollMs) this.stateDirPoll.unref?.() } catch { - // Non-mac or permission issues: watcher is best-effort. + // Permission issues: watcher is best-effort. } } From 6e1aa8503c688f85d16b4c72981e2f5e862e491f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:19:17 -0700 Subject: [PATCH 36/52] perf(renderer): pause always-on idle timers while the window is hidden (#8528) Co-authored-by: Orca --- .../src/components/automations/AutomationsPage.tsx | 8 ++++++-- .../components/status-bar/WorkspaceSpaceManagerPanel.tsx | 7 +++++-- .../worktree-creation/WorktreeCreationPanel.tsx | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 305e89ba35b..98a78ef9845 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { ContextMenu, ContextMenuContent, @@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element { }, [fetchAllWorktrees, refresh]) useEffect(() => { - const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000) - return () => window.clearInterval(timer) + // Pause the relative-time clock while the window is hidden. + return installWindowVisibilityInterval({ + run: () => setRelativeNow(Date.now()), + intervalMs: 60 * 1000 + }) }, []) useEffect(() => { diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 734a531fc01..dcfce654488 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -35,6 +35,7 @@ import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' import { cn } from '@/lib/utils' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { toast } from 'sonner' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' @@ -321,9 +322,11 @@ function UpdatedMetric({ if (scannedAt === null) { return } + // Refresh once immediately (unconditional, as before) so a rescan that lands + // while hidden isn't shown stale, then pause the ongoing 60s tick while the + // window is hidden — same visibility-gated pattern as useNow. setNow(Date.now()) - const timer = window.setInterval(() => setNow(Date.now()), 60_000) - return () => window.clearInterval(timer) + return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 60_000 }) }, [scannedAt]) return ( diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index 3a167caf757..c56d78a44e0 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -1,6 +1,7 @@ import React from 'react' import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react' import { useAppStore } from '@/store' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { getCreationProgressLabel } from '@/lib/pending-worktree-creation' import { translate } from '@/i18n/i18n' @@ -31,8 +32,9 @@ export default function WorktreeCreationPanel({ if (entryStatus !== 'creating') { return } - const timer = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(timer) + // Pause the 1s clock while the window is hidden so a backgrounded creation + // panel stops re-rendering for ticks no one can see. + return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 1000 }) }, [entryStatus]) if (!entry) { return null From 1d2aaf1bf52bde008561b36414548c62feb05ca4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:41:24 -0700 Subject: [PATCH 37/52] Fix recipe serve desktop promotion (#8646) * fix(runtime): preserve terminals during headless desktop activation * rm design doc * Fix desktop activation launch ordering and blocked-window status resolut - Check desktopWindowStatus before spawning the Orca app so a blocked runtime no longer launches a doomed second instance. - Reuse resolveDesktopWindowStatus for remote runtime status so it honors the same authoritativeWindowId fallback as local status. - Re-check the authoritative window at spawn time instead of trusting a possibly-stale snapshot, since it can be destroyed mid-await. - Harden the e2e activation spec against silent spawn failures. --------- Co-authored-by: bbingz --- config/reliability-gates.jsonc | 159 +++++++++++++++ src/cli/format.ts | 1 + src/cli/runtime-client.test.ts | 91 ++++++++- src/cli/runtime/client.ts | 35 +++- src/cli/runtime/status.ts | 17 +- src/cli/runtime/websocket-transport.test.ts | 32 ++- src/main/index.ts | 121 +++++------ src/main/runtime/orca-runtime.test.ts | 174 +++++++++++++++- src/main/runtime/orca-runtime.ts | 118 ++++++++++- src/main/ssh/ssh-remote-cli-format.ts | 1 + src/main/ssh/ssh-remote-orca-cli.ts | 6 +- .../serve-desktop-activation-wiring.test.ts | 49 +++++ .../startup/serve-desktop-activation.test.ts | 54 +++++ src/main/startup/serve-desktop-activation.ts | 56 ++++++ src/main/startup/single-instance-lock.test.ts | 19 ++ src/main/startup/single-instance-lock.ts | 10 + .../window-all-closed-quit-policy.test.ts | 10 + .../startup/window-all-closed-quit-policy.ts | 2 +- src/shared/runtime-types.ts | 8 + .../headless-serve-desktop-activation.spec.ts | 188 ++++++++++++++++++ 20 files changed, 1075 insertions(+), 76 deletions(-) create mode 100644 src/main/startup/serve-desktop-activation-wiring.test.ts create mode 100644 src/main/startup/serve-desktop-activation.test.ts create mode 100644 src/main/startup/serve-desktop-activation.ts create mode 100644 tests/e2e/headless-serve-desktop-activation.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 64ac34ed173..5307bc71292 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -160,6 +160,165 @@ ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, + { + "id": "runtime.headless-desktop-promotion-continuity", + "title": "Headless serve opens its desktop without replacing live terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "headless orca serve", + "single-instance desktop activation", + "CLI open", + "persistent terminal reattach" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8457" + ], + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.", + "oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec electron-vite build --mode e2e", + "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/startup/serve-desktop-activation.test.ts", + "src/main/startup/serve-desktop-activation-wiring.test.ts", + "src/main/startup/single-instance-lock.test.ts", + "src/main/startup/window-all-closed-quit-policy.test.ts", + "src/cli/runtime-client.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/startup/serve-desktop-activation.test.ts", + "assertions": [ + "early activation requests coalesce until the persistent provider is ready", + "a blocked provider drops pending activation and never opens a window" + ] + }, + { + "file": "src/main/startup/serve-desktop-activation-wiring.test.ts", + "assertions": [ + "second-instance and macOS app activation use the same safety gate", + "headless PTY registration waits for provider settlement and promotion waits for RPC startup" + ] + }, + { + "file": "src/main/startup/single-instance-lock.test.ts", + "assertions": [ + "serve never skips the single-instance lock even in development", + "the isolated E2E profile can opt into the production ownership path" + ] + }, + { + "file": "src/main/startup/window-all-closed-quit-policy.test.ts", + "assertions": [ + "a promoted serve owner remains alive after an ordinary window close but exits after a committed quit" + ] + }, + { + "file": "src/cli/runtime-client.test.ts", + "assertions": [ + "local open activates a reachable headless owner and waits for a desktop window", + "unsafe promotion returns an explicit blocked error instead of launching a second owner" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "remote-paired open reports remote desktop state without launching a local app" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless sentinel transfers authority to the first real window", + "headless local and SSH PTY bindings are persisted on first promotion and later windowless reattach without changing ordinary desktop spawn persistence", + "status distinguishes available, openable, initializing, and blocked desktop states", + "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id", + "terminal output written before promotion remains visible and post-promotion input/output still works", + "the activating second process exits instead of becoming another owner" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13, + "summary": "Seven activation, ownership, quit, local/remote CLI, and runtime contract files passed with 704 tests, including first and repeated windowless reattach, local/SSH identity transfer, ordinary desktop persistence isolation, and dynamic side-effect scanner gating." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 52, + "summary": "The isolated Electron journey passed repeatedly on the final source; the latest 51.6-second run retained the same main owner, runtime, daemon, and PTY, restored pre-promotion output, accepted post-promotion input, and observed the activating process exit." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit contracts plus one isolated Electron headless-to-desktop journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic contracts and two consecutive local macOS Electron passes; CI and cross-platform soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added." + }, + "performanceBudget": { + "required": false, + "evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Add live packaged activation coverage on macOS plus representative Linux and Windows single-instance journeys.", + "Add an Electron SSH promotion journey in addition to the deterministic identity-transfer unit contract." + ], + "knownGaps": [ + "The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.", + "Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.", + "SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider." + ], + "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/src/cli/format.ts b/src/cli/format.ts index 80c621418e8..9d608a5b0f4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string { return [ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 7243d19fa94..c11292453c1 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' +import { launchOrcaApp } from './runtime/launch' + +vi.mock('./runtime/launch', () => ({ + launchOrcaApp: vi.fn() +})) const servers = new Set>() const sockets = new Set() afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() for (const socket of sockets) { socket.destroy() } @@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.graph.state).toBe('unavailable') }) - it('openOrca succeeds immediately when the runtime is already reachable', async () => { + it('openOrca activates the app even when a desktop runtime is already reachable', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { @@ -204,6 +210,87 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.runtime.state).toBe('ready') expect(status.result.runtime.reachable).toBe(true) + expect(launchOrcaApp).toHaveBeenCalledOnce() + }) + + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + let statusRequests = 0 + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + statusRequests += 1 + const available = statusRequests > 1 + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: available ? 1 : 0, + graphStatus: available ? 'reloading' : 'ready', + authoritativeWindowId: available ? 1 : 0, + desktopWindowStatus: available ? 'available' : 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + const status = await client.openOrca(1_000) + + expect(launchOrcaApp).toHaveBeenCalledOnce() + expect(status.result.app.desktopWindowStatus).toBe('available') + expect(statusRequests).toBeGreaterThan(1) + }) + + it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'blocked', + liveTabCount: 1, + liveLeafCount: 1 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'desktop_activation_blocked' + }) + // A blocked runtime can't promote, so we bail before spawning the app. + expect(launchOrcaApp).not.toHaveBeenCalled() }) it('times out if the runtime never responds', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9211173be2..db0744aaa86 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus } from './status' +import { getCliStatus, resolveDesktopWindowStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { sendWebSocketRequest } from './websocket-transport' @@ -115,7 +115,13 @@ export class RuntimeClient { // that this client machine has a local Orca desktop process. app: { running: false, - pid: null + pid: null, + // Why: reuse the shared resolver so remote status honors the same + // authoritativeWindowId fallback as local status for old runtimes. + ...(() => { + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) + return desktopWindowStatus ? { desktopWindowStatus } : {} + })() }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -169,15 +175,27 @@ export class RuntimeClient { async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { + if (this.remotePairing) { return initial } + // Why: a blocked runtime can't open a window, so spawning the app would + // only hit the single-instance lock and exit — bail before launching. + if (initial.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } launchOrcaApp() + if (initial.result.app.desktopWindowStatus === 'available') { + return initial + } + const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() - if (status.result.runtime.reachable) { + if (status.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status.result.app.desktopWindowStatus === 'available') { return status } await delay(250) @@ -185,11 +203,18 @@ export class RuntimeClient { throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' ) } } +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + function resolveRemotePairing( userDataPath: string, pairingCode: string | null, diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index dff8c231fc9..189297bf05f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -35,10 +35,12 @@ export async function getCliStatus( throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ app: { running: true, - pid: metadata.pid + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -68,6 +70,19 @@ export async function getCliStatus( } } +export function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 6a60c97b310..364fc05c508 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { @@ -13,6 +13,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { @@ -20,6 +21,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +vi.mock('./launch', () => ({ + launchOrcaApp: vi.fn() +})) + type TestRuntime = { endpoint: string publicKeyB64: string @@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => { const servers: TestRuntime[] = [] afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() await Promise.all(servers.splice(0).map((server) => server.close())) }) @@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) + it('does not launch a local desktop app for remote-paired open', async () => { + const runtime = await startTestRuntime('runtime-remote-headless', { + desktopWindowStatus: 'initializing' + }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + const status = await client.openOrca() + + expect(status.result.app.desktopWindowStatus).toBe('initializing') + expect(launchOrcaApp).not.toHaveBeenCalled() + }) + it('connects through a saved environment selector', async () => { const runtime = await startTestRuntime('runtime-env-1') servers.push(runtime) @@ -128,6 +156,7 @@ async function startTestRuntime( statusOverrides: { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number + desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -177,6 +206,7 @@ async function startTestRuntime( rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, + desktopWindowStatus: statusOverrides.desktopWindowStatus, liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: diff --git a/src/main/index.ts b/src/main/index.ts index 211bd473653..eb2ac4a7668 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,7 +92,8 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, - shouldBypassSingleInstanceLock + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -103,6 +104,7 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -183,6 +185,11 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../shared/runtime-types' +import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -244,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const desktopActivationGate = createServeDesktopActivationGate({ + initialState: isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) +}) // Why: on Windows a CLI-shaped launch (Orca.exe ) that lost // ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance // lock to a running window, and exit silently. Redirect it to node mode here, @@ -461,6 +478,23 @@ function focusExistingWindow(): void { }) } +function requestDesktopActivation(): void { + desktopActivationGate.requestActivation() +} + +function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const state = desktopActivationGate.getState() + return state === 'ready' ? 'openable' : state +} + +function settleServeDesktopActivation(): void { + if (getLocalPtyProvider() instanceof LocalPtyProvider) { + desktopActivationGate.markBlocked('persistent PTY provider unavailable') + return + } + desktopActivationGate.markReady() +} + // Why: a webContents-scoped flag that auto-expires so an intent set for one renderer // can't leak to a later load. `consume` clears on a positive match for one-shot // signals (the recovery reload fires exactly one did-finish-load). @@ -551,22 +585,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode }) +const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) if (bypassSingleInstanceLock) { // Why: this is an explicit diagnostic escape hatch for macOS builds where // Electron reports a false lock loss before any normal app logs exist. logSingleInstanceLockBypass() } -const hasSingleInstanceLock = - is.dev && !isServeMode +const hasSingleInstanceLock = skipSingleInstanceLock + ? true + : bypassSingleInstanceLock ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, focusExistingWindow) + : acquireSingleInstanceLock(app, requestDesktopActivation) if (startupDiagnosticsEnabled) { logStartupDiagnostic('single-instance-lock-result', { acquired: hasSingleInstanceLock, bypassed: bypassSingleInstanceLock, - skippedForDev: is.dev && !isServeMode + skippedForDev: skipSingleInstanceLock }) } if (!hasSingleInstanceLock) { @@ -633,12 +670,11 @@ ipcMain.handle( } ) -function startDesktopFirstWindowStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` - // registers its PTY runtime separately and must not spawn the desktop daemon - // or hook loopback listener. + // Why: desktop and headless serve must adopt the same persistent provider + // before either path is allowed to create terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) await initDaemonPtyProvider(signal) @@ -647,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise { // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so // the renderer awaits this barrier before restored terminals reconnect. startAgentHookServer: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -687,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise { return firstWindowStartupServicesReady } -async function startServeAgentHookServer(): Promise { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - try { - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - } catch (error) { - // Why: remote hook callbacks enrich agent status only. A headless runtime - // should still serve terminals if the loopback receiver cannot bind. - console.error('[agent-hooks] Failed to start serve hook server:', error) - } -} - function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hookTarget = @@ -1791,20 +1813,14 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, - // Why: derived title/bell/agent facts ride one batched main→renderer - // channel (terminal-side-effect-authority.md). The renderer's authority - // kill switch decides whether to consume. Headless serve never creates a - // window, so the dep is omitted entirely — the runtime then skips fact - // batch construction and the per-chunk bell walk. - ...(isServeMode - ? {} - : { - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - } - }), + // Why: serve can be promoted in place, so keep the listener wired from + // startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus: getDesktopWindowStatus, // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), @@ -2095,9 +2111,8 @@ app.whenReady().then(async () => { }) registerMobileHandlers(runtimeRpc) - if (!isServeMode) { - startDesktopFirstWindowStartupServices() - } + startTerminalRuntimeStartupServices() + app.on('activate', requestDesktopActivation) if (serveOptions) { // Why: give managed WSL launchers a brief chance to migrate before headless @@ -2107,7 +2122,9 @@ app.whenReady().then(async () => { logStartupMilestone('wsl-cli-barrier-resolved', { reconciliation: managedWslCliReconciliationStatus }) - await startServeAgentHookServer() + // Why: headless PTYs must never start on the fallback provider and then be + // swept when an activated renderer registers desktop lifecycle handlers. + await localPtyStartupReady registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, @@ -2125,11 +2142,12 @@ app.whenReady().then(async () => { // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + settleServeDesktopActivation() installServeSignalHandlers() // Why: the orca CLI command is normally installed by the renderer onboarding / // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no @@ -2212,15 +2230,6 @@ app.whenReady().then(async () => { triggerStartupNotificationRegistration(store) } }) - - app.on('activate', () => { - // Don't re-open a window while Squirrel's ShipIt is replacing the .app - // bundle. Without this guard the old version gets resurrected and the - // update never applies. - if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) { - openMainWindow() - } - }) }) app.on('before-quit', () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 96ba22d5cea..e7b11ef8bb2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,7 +56,10 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -1454,6 +1457,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable', authoritativeWindowId: null, + desktopWindowStatus: 'openable', rendererGraphEpoch: 0 }) expect(runtime.getRuntimeId()).toBeTruthy() @@ -1548,6 +1552,118 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID) }) + it('transfers authority from the headless sentinel to the first real window', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.attachWindow(TEST_WINDOW_ID) + runtime.attachWindow(2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + desktopWindowStatus: 'available', + graphStatus: 'reloading', + rendererGraphEpoch: 1 + }) + }) + + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('marks live bindings again when reopening after a promoted window closes', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + ;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({ + ...getSession(), + activeWorktreeIdsOnShutdown: [] + }) + runtime.markGraphUnavailable(TEST_WINDOW_ID) + + runtime.attachWindow(2) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('preserves live SSH session identities when promoting a headless runtime', () => { + const remotePtyId = 'ssh:ssh-1@@persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [], + activeConnectionIdsAtShutdown: [], + remoteSessionIdsByTabId: {}, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: remotePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession()).toMatchObject({ + activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID], + activeConnectionIdsAtShutdown: ['ssh-1'], + remoteSessionIdsByTabId: { 'host-tab': remotePtyId } + }) + }) + + it('reports the activation gate state while no desktop window is available', () => { + const runtime = new OrcaRuntimeService(store, undefined, { + getDesktopWindowStatus: () => 'blocked' + }) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus().desktopWindowStatus).toBe('blocked') + }) + it('bumps the epoch and enters reloading when the authoritative window reloads', () => { const runtime = createRuntime() @@ -6875,6 +6991,34 @@ describe('OrcaRuntimeService', () => { return { runtime, batches } } + it('defers desktop-only output scanners until a headless runtime is promoted', () => { + const { runtime, batches } = createSideEffectRuntime() + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.onPtyData('pty-1', '\x07', 100) + + expect(batches).toEqual([]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty-1', '\x07', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull() + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-1', '\x07', 102) + + expect(batches).toHaveLength(1) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9932,11 +10076,37 @@ describe('OrcaRuntimeService', () => { }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - worktreeId: TEST_WORKTREE_ID + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true }) ) }) + it('keeps ordinary desktop background terminal persistence opt-in', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const webContents = { send: vi.fn() } + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const spawnOptions = spawn.mock.calls[0]?.[0] as + | { persistHostSessionBinding?: boolean } + | undefined + expect(spawnOptions?.persistHostSessionBinding).toBeUndefined() + }) + it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6f1ca862fbe..96e73e3f965 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -171,6 +171,10 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -2502,8 +2506,10 @@ export class OrcaRuntimeService { private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2537,6 +2543,7 @@ export class OrcaRuntimeService { // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } ) { this.store = store @@ -2563,6 +2570,7 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -2965,6 +2973,7 @@ export class OrcaRuntimeService { rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -3079,11 +3088,78 @@ export class OrcaRuntimeService { } attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.authoritativeWindowId = windowId + return + } if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } + private persistWindowlessPtyBindingsForDesktopAttach(): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const promotablePtys = [...this.ptysById.values()].filter((pty) => { + if (!pty.connected || !pty.tabId) { + return false + } + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + return false + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) + }) + if (promotablePtys.length === 0) { + return + } + + // Why: renderer hydration treats an explicitly-present shutdown list as + // authoritative. A windowless owner has no renderer shutdown pass, so seed + // that existing reattach contract before its next desktop window loads. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...promotablePtys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...promotablePtys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of promotablePtys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession({ + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }) + } + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId @@ -3208,6 +3284,7 @@ export class OrcaRuntimeService { this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) @@ -5985,7 +6062,7 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.onTerminalSideEffects) { + if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -6001,7 +6078,11 @@ export class OrcaRuntimeService { facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { - if (!this.onTerminalSideEffects || facts.length === 0) { + if ( + !this.onTerminalSideEffects || + !this.terminalSideEffectConsumerAvailable || + facts.length === 0 + ) { return } const batch: TerminalSideEffectBatch = { @@ -6180,7 +6261,7 @@ export class OrcaRuntimeService { // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. - ...(this.onTerminalSideEffects + ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -6213,7 +6294,7 @@ export class OrcaRuntimeService { // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.onTerminalSideEffects + commandCodeDetector: this.terminalSideEffectConsumerAvailable ? createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { @@ -6312,6 +6393,19 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + const nextAvailable = available && this.onTerminalSideEffects !== null + if (nextAvailable === this.terminalSideEffectConsumerAvailable) { + return + } + this.terminalSideEffectConsumerAvailable = nextAvailable + // Why: optional bell/command/link scanners are selected when a tracker is + // created. Rebuild at the window boundary so pure headless output stays cheap. + for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { + this.disposePtyTitleTracker(ptyId) + } + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -17578,12 +17672,12 @@ export class OrcaRuntimeService { ): Promise { const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. - const rendererWindow = - opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && ((!requiresRendererFocus && opts.rendererBacked !== true) || @@ -17704,7 +17798,14 @@ export class OrcaRuntimeService { tabId, leafId, ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) + // Why: a headless-created pane has no renderer session writer. Persist + // its tab/leaf binding at spawn so a later promoted window reattaches + // the live daemon or SSH PTY instead of replacing it with a fresh one. + // Re-check freshly: the entry-time snapshot can go stale across the + // awaits above if the authoritative window is destroyed mid-spawn. + ...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null + ? { persistHostSessionBinding: true } + : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) this.registerPty(result.id, workspace.id, workspace.connectionId) @@ -19049,6 +19150,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() @@ -19065,6 +19167,7 @@ export class OrcaRuntimeService { return } this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } @@ -19078,6 +19181,7 @@ export class OrcaRuntimeService { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index c654af79b74..744c9f8e6cf 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: stdout: `${[ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 205723321da..e43956dfc1c 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -158,7 +158,11 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { running: true, pid: null }, + app: { + running: true, + pid: null, + ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) + }, runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts new file mode 100644 index 00000000000..9008ef4014e --- /dev/null +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('serve desktop activation wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('routes second-instance and app activation through one safety gate', () => { + expect(source).toContain('createServeDesktopActivationGate({') + expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') + expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + }) + + it('settles the persistent provider before headless PTY registration', () => { + const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') + const startupIndex = source.indexOf( + '\n startTerminalRuntimeStartupServices()\n', + appReadyIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) + const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + + expect(startupIndex).toBeGreaterThanOrEqual(0) + expect(startupIndex).toBeLessThan(serveIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(source).not.toContain( + 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' + ) + }) + + it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const sentinelIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(serveIndex).toBeGreaterThanOrEqual(0) + expect(sentinelIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(sentinelIndex) + expect(settleIndex).toBeGreaterThan(rpcIndex) + expect(source).not.toContain('runtime.syncWindowGraph(0,') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.test.ts b/src/main/startup/serve-desktop-activation.test.ts new file mode 100644 index 00000000000..659e7b382d4 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createServeDesktopActivationGate } from './serve-desktop-activation' + +describe('createServeDesktopActivationGate', () => { + it('coalesces activation requests while serve is initializing and drains once when ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(gate.getState()).toBe('initializing') + + gate.markReady() + + expect(activateWindow).toHaveBeenCalledOnce() + expect(gate.getState()).toBe('ready') + }) + + it('activates immediately after the persistent provider is ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).toHaveBeenCalledTimes(2) + }) + + it('drops pending activation and fails closed when promotion is blocked', () => { + const activateWindow = vi.fn() + const onBlocked = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow, + onBlocked + }) + + gate.requestActivation() + gate.markBlocked('persistent PTY provider unavailable') + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(onBlocked).toHaveBeenCalledTimes(2) + expect(gate.getState()).toBe('blocked') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts new file mode 100644 index 00000000000..8ee6a83a0e8 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.ts @@ -0,0 +1,56 @@ +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' + +type ActivationGateState = Exclude | 'ready' + +export type ServeDesktopActivationGate = { + getState: () => ActivationGateState + requestActivation: () => void + markReady: () => void + markBlocked: (reason: string) => void +} + +export function createServeDesktopActivationGate(options: { + initialState: 'initializing' | 'ready' + activateWindow: () => void + onBlocked?: (reason: string) => void +}): ServeDesktopActivationGate { + let state: ActivationGateState = options.initialState + let pendingActivation = false + let blockedReason = 'desktop activation is unavailable' + + return { + getState: () => state, + requestActivation: () => { + if (state === 'ready') { + options.activateWindow() + return + } + if (state === 'initializing') { + pendingActivation = true + return + } + options.onBlocked?.(blockedReason) + }, + markReady: () => { + if (state !== 'initializing') { + return + } + state = 'ready' + if (pendingActivation) { + pendingActivation = false + options.activateWindow() + } + }, + markBlocked: (reason) => { + if (state !== 'initializing') { + return + } + state = 'blocked' + blockedReason = reason + if (pendingActivation) { + pendingActivation = false + options.onBlocked?.(blockedReason) + } + } + } +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index f5318b7b54b..5ccd70e6a4a 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -5,6 +5,7 @@ import { logSingleInstanceLockBypass, logSingleInstanceLockFailure, shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => { }) }) +describe('shouldSkipSingleInstanceLock', () => { + it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + }) + + it('lets isolated E2E exercise the production single-instance path', () => { + expect( + shouldSkipSingleInstanceLock({ + isDev: true, + isServeMode: false, + env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + }) + ).toBe(false) + }) +}) + describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 5fd9cc27610..078500284a2 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' @@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: { ) } +export function shouldSkipSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean +}): boolean { + const env = options.env ?? process.env + return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' +} + export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) } diff --git a/src/main/startup/window-all-closed-quit-policy.test.ts b/src/main/startup/window-all-closed-quit-policy.test.ts index b7aab9e13ae..6c451f7e4ad 100644 --- a/src/main/startup/window-all-closed-quit-policy.test.ts +++ b/src/main/startup/window-all-closed-quit-policy.test.ts @@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => { }) ).toBe(true) }) + + it('continues a committed quit after a serve owner was promoted to desktop', () => { + expect( + shouldQuitWhenAllWindowsClosed({ + platform: 'darwin', + isQuitting: true, + isServeMode: true + }) + ).toBe(true) + }) }) diff --git a/src/main/startup/window-all-closed-quit-policy.ts b/src/main/startup/window-all-closed-quit-policy.ts index 8b23f436140..f17881ea989 100644 --- a/src/main/startup/window-all-closed-quit-policy.ts +++ b/src/main/startup/window-all-closed-quit-policy.ts @@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: { isQuitting: boolean isServeMode: boolean }): boolean { - if (options.isServeMode) { + if (options.isServeMode && !options.isQuitting) { return false } return options.platform !== 'darwin' || options.isQuitting diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 028327eb226..41a800eb3da 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -37,6 +37,12 @@ export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' +export type RuntimeDesktopWindowStatus = 'available' | 'openable' | 'initializing' | 'blocked' + +// Why: headless serve still owns one runtime graph, but zero can never collide +// with Electron BrowserWindow ids and can be transferred safely on promotion. +export const HEADLESS_RUNTIME_WINDOW_ID = 0 + // Why: the access scope a paired device token grants. Lives in shared so // pairing offers, status.get, and the device registry use one vocabulary. export type DeviceScope = 'mobile' | 'runtime' @@ -55,6 +61,7 @@ export type RuntimeStatus = { rendererGraphEpoch: number graphStatus: RuntimeGraphStatus authoritativeWindowId: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus liveTabCount: number liveLeafCount: number // Why: optional so clients can read both new and pre-contract runtimes. @@ -85,6 +92,7 @@ export type CliStatusResult = { app: { running: boolean pid: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus } runtime: { state: CliRuntimeState diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts new file mode 100644 index 00000000000..04d11db2768 --- /dev/null +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -0,0 +1,188 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeStatus, + RuntimeTerminalCreate, + RuntimeTerminalRead +} from '../../src/shared/runtime-types' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') +const electronPath = path.join( + electronPackageDir, + 'dist', + readFileSync(path.join(electronPackageDir, 'path.txt'), 'utf8').trim() +) + +function createLaunchEnv(userDataDir: string): NodeJS.ProcessEnv { + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + return { + ...cleanEnv, + NODE_ENV: 'development', + ORCA_E2E_USER_DATA_DIR: userDataDir, + ORCA_E2E_HEADLESS: '1', + // Why: production builds always use the lock; this opt-in makes the dev + // E2E bundle exercise the same second-instance ownership path. + ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' + } +} + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return true + } + return await new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout) + resolve(true) + } + const timeout = setTimeout(() => { + child.off('exit', onExit) + resolve(false) + }, timeoutMs) + child.once('exit', onExit) + }) +} + +test.describe.configure({ mode: 'serial' }) + +test('promotes the headless owner without replacing its daemon terminal', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both launches and intentionally opts out of the default app fixture. +{}) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-serve-promotion-')) + const env = createLaunchEnv(userDataDir) + let serveApp: ElectronApplication | null = null + let activatingProcess: ChildProcess | null = null + + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` + ) + + try { + serveApp = await electron.launch({ + args: [...getOrcaElectronLaunchArgs(mainPath, false), '--serve', '--serve-no-pairing'], + env + }) + const ownerPid = serveApp.process().pid + const client = new RuntimeClient(userDataDir, 5_000) + + await expect + .poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, { + timeout: 60_000, + message: 'headless serve never became safely openable' + }) + .toBe('openable') + + const beforeStatus = await client.call('status.get') + const daemonPidBefore = readDaemonPid(userDataDir) + await client.call('repo.add', { path: repoPath, kind: 'git' }) + const created = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: `path:${repoPath}`, + title: 'Serve promotion continuity' + }) + const terminal = created.result.terminal + if (!terminal.ptyId) { + throw new Error('Headless terminal did not expose its daemon PTY id') + } + + const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}` + await client.call('terminal.send', { + terminal: terminal.handle, + text: `echo ${beforeMarker}`, + enter: true + }) + await expect + .poll( + async () => { + const response = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: terminal.handle, + limit: 200 + }) + return response.result.terminal.tail.join('\n') + }, + { timeout: 15_000 } + ) + .toContain(beforeMarker) + + activatingProcess = spawn(electronPath, getOrcaElectronLaunchArgs(mainPath, false), { + env, + stdio: 'ignore' + }) + activatingProcess.on('error', (error) => { + console.error('[e2e] activating process failed to spawn:', error) + }) + + const page = await serveApp.firstWindow({ timeout: 60_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + + const promotedPtyId = await discoverActivePtyId(page) + const afterStatus = await client.call('status.get') + expect(serveApp.process().pid).toBe(ownerPid) + expect(afterStatus.result.runtimeId).toBe(beforeStatus.result.runtimeId) + expect(afterStatus.result.desktopWindowStatus).toBe('available') + expect(promotedPtyId).toBe(terminal.ptyId) + expect(readDaemonPid(userDataDir)).toBe(daemonPidBefore) + expect(await waitForProcessExit(activatingProcess, 10_000)).toBe(true) + await waitForTerminalOutput(page, beforeMarker, 30_000) + + const afterMarker = `SERVE_PROMOTION_AFTER_${Date.now()}` + await execInTerminal(page, promotedPtyId, `echo ${afterMarker}`) + await waitForTerminalOutput(page, afterMarker, 15_000) + await expect(page.locator('.xterm:visible').first()).toBeVisible() + expect(await getTerminalContent(page)).toContain(beforeMarker) + } finally { + if (activatingProcess && activatingProcess.exitCode === null) { + activatingProcess.kill('SIGKILL') + await waitForProcessExit(activatingProcess, 5_000) + } + if (serveApp) { + await closeElectronAppForE2E(serveApp) + } + await cleanupE2EDaemons(userDataDir) + rmSync(userDataDir, { recursive: true, force: true }) + } +}) From 8662e5a7ab448063f102888e7b00052cd6465080 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:46:08 -0700 Subject: [PATCH 38/52] perf(ssh): merge the two identical 5s keepalive timers into one per connection (#8653) Co-authored-by: Orca --- src/main/ssh/ssh-channel-multiplexer.test.ts | 19 +++++++++++ src/main/ssh/ssh-channel-multiplexer.ts | 34 +++++++++----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 10123d56a25..16de2b94a46 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => { expect(() => vi.advanceTimersByTime(5_000)).not.toThrow() expect(mux.isDisposed()).toBe(true) }) + + it('drives keepalive sends AND dead-link detection from a single interval', () => { + // The keepalive send and the liveness/timeout check were merged from two + // 5s intervals into one; there must be exactly one recurring timer. + expect(vi.getTimerCount()).toBe(1) + + // Send half: a keepalive is written on the tick. + const before = transport.written.length + vi.advanceTimersByTime(5_000) + expect(transport.written.length).toBeGreaterThan(before) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + expect(vi.getTimerCount()).toBe(1) + + // Check half: with no inbound frames or acks, the same interval declares + // the link dead (no-data + oldest-unacked both exceed the 20s window). + expect(mux.isDisposed()).toBe(false) + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) }) describe('wake guard (timer pause across system sleep, #7773)', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 8cb0a24f302..25e6e2c6f00 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -54,8 +54,7 @@ export class SshChannelMultiplexer { // generic notification listener that already serves fs.changed. private methodNotificationHandlers = new Map>() private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] - private keepaliveTimer: ReturnType | null = null - private timeoutTimer: ReturnType | null = null + private connectionHealthTimer: ReturnType | null = null private disposed = false // Track the oldest unacked outgoing message timestamp @@ -88,8 +87,7 @@ export class SshChannelMultiplexer { if (this.disposed) { return } - this.startKeepalive() - this.startTimeoutCheck() + this.startConnectionHealthTimer() } onNotification(handler: NotificationHandler): () => void { @@ -276,13 +274,9 @@ export class SshChannelMultiplexer { } this.disposed = true - if (this.keepaliveTimer) { - clearInterval(this.keepaliveTimer) - this.keepaliveTimer = null - } - if (this.timeoutTimer) { - clearInterval(this.timeoutTimer) - this.timeoutTimer = null + if (this.connectionHealthTimer) { + clearInterval(this.connectionHealthTimer) + this.connectionHealthTimer = null } // Why: the renderer uses the error code to distinguish temporary disconnects @@ -488,15 +482,17 @@ export class SshChannelMultiplexer { } } - private startKeepalive(): void { - this.keepaliveTimer = setInterval(() => { - this.sendKeepAlive() - }, KEEPALIVE_SEND_MS) - } - - private startTimeoutCheck(): void { + // Why: one 5s interval owns BOTH the periodic keepalive send and the + // liveness/dead-link check. They used to be two separate 5s intervals that + // always fired back-to-back (keepalive created first); folding them into one + // tick — send first (as the keepalive timer did), then run the check (as the + // timeout timer did) — halves the per-connection timer count with identical + // behavior, including the #7773 wake-gap recovery below. + private startConnectionHealthTimer(): void { let lastTickAt = Date.now() - this.timeoutTimer = setInterval(() => { + this.connectionHealthTimer = setInterval(() => { + this.sendKeepAlive() + if (this.disposed) { return } From 1ef0551bc138724d736583eac788f4183bef07e2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:10:50 -0700 Subject: [PATCH 39/52] Create pr not working for stacked worktree (#8651) * Fix stacked-worktree PR creation targeting a local-only parent branch - Resolve the eligibility default base to a remote-tracking ref instead of blindly trusting the submitted parent branch, since a stacked worktree's base is often a local-only branch the remote can't resolve - Add a create-time hard block (base_not_on_remote) so a stale or unpushed submitted base fails with actionable copy instead of the provider's opaque error - Update the dialog's default-base resolution and blocked-action/ dropdown copy to match the new remote-validated default * Split hosted-review-creation.test.ts to fix max-lines lint error Moved getHostedReviewCreationEligibility tests to a separate file (hosted-review-creation-eligibility.test.ts) to reduce the original file size from 880 to 579 lines, satisfying the max-lines lint constraint. Co-authored-by: Orca * Fix Create PR intent flow to use remote-validated eligibility default fo Prefer eligibilityDefaultBaseRef over the raw compare base when resolving the review base for the one-click Create PR intent flow, since eligibility is recomputed from the same compare base right before creation and already corrects a local-only stacked parent to the repo default. Falls back to the compare base only when eligibility supplies no default. * Simplify base-ref remote existence check into a single for-each-ref call Combine the wildcard and exact-tracking-ref lookups into one for-each-ref invocation with multiple patterns instead of two sequential git calls, removing the redundant rev-parse fallback path. --------- Co-authored-by: Orca --- src/cli/handlers/core.test.ts | 4 +- .../windows-firewall-remote-scope.test.ts | 10 +- ...hosted-review-creation-eligibility.test.ts | 529 ++++++++++++++++++ .../hosted-review-creation.test.ts | 282 ++-------- .../source-control/hosted-review-creation.ts | 108 +++- ...urce-control-create-pr-intent-flow.test.ts | 23 +- .../source-control-create-pr-intent-flow.ts | 11 +- ...ce-control-create-review-blocked-action.ts | 3 + .../source-control-dropdown-items.ts | 2 + ...control-primary-create-pr-intent-action.ts | 9 +- .../useCreatePullRequestDialogFields.test.ts | 28 +- .../useCreatePullRequestDialogFields.ts | 8 +- .../agent-completion-coordinator.test.ts | 21 +- ...gent-hook-completion-notifications.test.ts | 5 +- src/shared/hosted-review.ts | 4 + 15 files changed, 767 insertions(+), 280 deletions(-) create mode 100644 src/main/source-control/hosted-review-creation-eligibility.test.ts diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts index 69f27dc9bd6..ac5444566d9 100644 --- a/src/cli/handlers/core.test.ts +++ b/src/cli/handlers/core.test.ts @@ -64,7 +64,9 @@ describe('orca claude-teams CLI handler', () => { spawnMock.mockImplementation(() => mockClaudeChild()) callMock.mockReset() callMock.mockResolvedValue({ - result: { launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } } + result: { + launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } + } }) previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE previousPaneKey = process.env.ORCA_PANE_KEY diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts index 2c9a013d964..6e2462c0334 100644 --- a/src/main/runtime/windows-firewall-remote-scope.test.ts +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -126,16 +126,18 @@ describe('Windows firewall remote-address scope', () => { // A /32 subnet is just the desktop itself, so an explicit range cannot prove // the phone (a different host) is allowed — only the keywords can. expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) - expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe( - true - ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32) + ).toBe(true) expect( hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) ).toBe(false) }) it('fails address-family keywords closed when the interface family is unknown', () => { - expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe( + true + ) expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( false ) diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts new file mode 100644 index 00000000000..010d950d50f --- /dev/null +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + createAzureDevOpsPullRequestMock: vi.fn(), + createGiteaPullRequestMock: vi.fn(), + isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(), + isGiteaReviewCreationAuthenticatedMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/pull-request-creation', () => ({ + createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock, + isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../gitea/pull-request-creation', () => ({ + createGiteaPullRequest: createGiteaPullRequestMock, + isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } +})) + +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock + ]) { + mock.mockReset() + } +} + +function mockGitHubProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) +} + +function mockGitLabProvider(): void { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockAzureDevOpsProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue({ + host: 'dev.azure.com', + project: 'Project', + repository: 'orca', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca' + }) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockGiteaProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'git.example.com', + owner: 'acme', + repo: 'orca', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) +} + +describe('getHostedReviewCreationEligibility', () => { + beforeEach(() => { + resetMocks() + + mockGitHubProvider() + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) + isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) + }) + + it('treats short remote base refs as the default branch name', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'main', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'default_branch', + defaultBaseRef: 'origin/main' + }) + }) + + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a + // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. + const stackedArgs = ( + overrides: Partial[0]> = {} + ): Parameters[0] => ({ + repoPath: '/repo', + branch: 'feature/stacked', + base: 'stacked-parent', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0, + ...overrides + }) + + const mockRefs = (opts: { + symbolicRef?: string + forEachRef?: string + forEachThrows?: boolean + revParseThrows?: boolean + }): void => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: opts.symbolicRef ?? '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + if (opts.forEachThrows) { + throw new Error('ssh: connect: connection refused') + } + return { stdout: opts.forEachRef ?? '', stderr: '' } + } + if (args[0] === 'rev-parse' && opts.revParseThrows) { + throw new Error('unknown revision') + } + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + } + + it('falls back to the repo default when a stacked parent base is local-only', async () => { + mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'origin/main' + }) + }) + + it('preserves a stacked parent base that exists on the remote', async () => { + mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'parent-pushed' + }) + }) + + it('keeps the candidate base when no repo default can be resolved', async () => { + mockRefs({ revParseThrows: true }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'stacked-parent' + }) + }) + + it('preserves the candidate base when the remote probe cannot reach the host', async () => { + // Transport failure must not be read as "absent" — that would demote a + // legitimately-pushed parent to the repo default on a transient SSH blip. + mockRefs({ forEachThrows: true }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' }) + }) + + it('blocks dirty tracked GitHub branches before PR creation', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { + getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'refs/heads/feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'origin/main', + head: 'feature/create-pr' + }) + }) + + it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null + }) + + // Enterprise auth was already confirmed during detection; the gate must not + // fire a redundant gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { + const remoteGit = { + exec: vi.fn(async () => ({ stdout: '', stderr: '' })) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + head: 'feature/create-pr' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) + ) + // Why: the base-on-remote probe must run on the SSH host that will execute + // the provider create, so it flows through the relay exec, not local git. + expect(remoteGit.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/remotes/*/main'], + '/remote/repo' + ) + }) + + it('offers push as the next action for authenticated branches with local-only commits', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { + mockGitLabProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitlab', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitlab' + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) + }) + + it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { + mockAzureDevOpsProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/azure', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'azure-devops', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/azure' + }) + expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { + mockGiteaProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitea', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitea', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitea' + }) + expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 3ae82fd4010..04c751d7c3b 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -115,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -223,6 +223,11 @@ describe('createHostedReview', () => { if (args[0] === 'status') { return { stdout: '', stderr: '' } } + // Why: base-on-remote probe (Change 2 enforcement) — the default base + // resolves to a remote-tracking branch so create-time validation passes. + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -301,6 +306,32 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) + it('blocks creation with actionable copy when the submitted base is local-only', async () => { + // for-each-ref falls through to '' → the submitted stacked parent is not on + // the remote, so create-time enforcement blocks with actionable copy. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'stacked-parent', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: + 'Create PR failed: the base branch "stacked-parent" hasn\'t been pushed to the remote. Choose a pushed base or push it first.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('creates the pull request after fresh main-process validation passes', async () => { await expect( createHostedReview('/repo', { @@ -501,6 +532,10 @@ describe('createHostedReview', () => { if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { return { stdout: 'feature\n', stderr: '' } } + if (args[0] === 'for-each-ref') { + // Base-on-remote probe (Change 2) runs on the SSH host; base is pushed. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -588,248 +623,3 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) }) - -describe('getHostedReviewCreationEligibility', () => { - beforeEach(() => { - resetMocks() - - mockGitHubProvider() - getHostedReviewForBranchMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) - isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) - isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) - }) - - it('treats short remote base refs as the default branch name', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'main', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'default_branch', - defaultBaseRef: 'origin/main' - }) - }) - - it('blocks dirty tracked GitHub branches before PR creation', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { - getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: false, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'refs/heads/feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: 'origin/main', - head: 'feature/create-pr' - }) - }) - - it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { - mockGitHubEnterpriseProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null - }) - - // Enterprise auth was already confirmed during detection; the gate must not - // fire a redundant gh probe. - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ stdout: '', stderr: '' })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/remote/repo', - connectionId: 'ssh-1', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - head: 'feature/create-pr' - }) - - expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) - ) - expect(remoteGit.exec).not.toHaveBeenCalled() - }) - - it('offers push as the next action for authenticated branches with local-only commits', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 2, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { - mockGitLabProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitlab', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitlab', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitlab' - }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'gitlab.com'], - { cwd: '/repo' } - ) - }) - - it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { - mockAzureDevOpsProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/azure', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'azure-devops', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/azure' - }) - expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { - mockGiteaProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitea', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitea', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitea' - }) - expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 01caac804d8..1e19f1d4fa8 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -42,6 +42,10 @@ import { type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null + // Why: only the create-time preflight enforces base-on-remote as a hard block; + // the renderer's eligibility probe passes the base as a candidate and relies on + // Change 1 to correct a local-only parent, so it must never set this. + enforceBaseOnRemote?: boolean } & HostedReviewExecutionOptions function stripRefPrefix(ref: string): string { @@ -133,6 +137,52 @@ async function getDefaultBaseRef( ) } +/** + * Whether the candidate base resolves to a remote-tracking branch on the + * executing host. + * + * Why: a stacked worktree's `worktree.baseRef` is typically a bare parent + * branch name with no remote qualifier, so the probe must match the branch + * under *any* configured remote rather than assume `origin` — otherwise fork + * workflows (`upstream/main`) would be missed. Runs through + * `runGitForHostedReview` so it evaluates on the same host that will run the + * provider create (native/WSL/SSH/relay). This reads the local remote-tracking + * snapshot, not the live remote; see the design doc's Open Questions for the + * ls-remote/staleness tradeoff left as a follow-up. + */ +async function baseRefExistsOnRemote( + candidate: string, + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const base = normalizeHostedReviewBaseRef(candidate).trim() + if (!base) { + return false + } + const run = (argv: string[]): Promise<{ stdout: string }> => + runGitForHostedReview(repoPath, argv, connectionId, options) + + const patterns = [`refs/remotes/*/${base}`] + // Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob + // above only matches a branch literally named that because `*` does not cross `/`. + // Include the exact tracking ref directly. + if (base.includes('/')) { + patterns.push(`refs/remotes/${base}`) + } + + try { + // for-each-ref exits 0 whether or not the pattern matches, so a clean empty + // result is an authoritative "absent" while a thrown error is a transport + // failure. Never conflate the two: on an unreachable host, preserve the + // candidate rather than silently demoting a legitimately-pushed parent base. + const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns]) + return stdout.trim().length > 0 + } catch { + return true + } +} + async function getCurrentBranch( repoPath: string, connectionId?: string | null, @@ -255,9 +305,11 @@ async function isProviderAuthenticated( function blockedCreateResultForReason( reason: NonNullable, - provider: HostedReviewProvider + provider: HostedReviewProvider, + submittedBase?: string | null ): CreateHostedReviewResult | null { const copy = reviewCopy(provider) + const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : '' const blockedCreateResultByReason = { auth_required: { ok: false, @@ -303,6 +355,11 @@ function blockedCreateResultForReason( ok: false, code: 'validation', error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` + }, + base_not_on_remote: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.` } } satisfies Partial< Record, CreateHostedReviewResult> @@ -311,7 +368,8 @@ function blockedCreateResultForReason( } function blockedEligibilityToCreateResult( - eligibility: HostedReviewCreationEligibility + eligibility: HostedReviewCreationEligibility, + submittedBase?: string | null ): CreateHostedReviewResult | null { if (eligibility.canCreate) { return null @@ -326,7 +384,11 @@ function blockedEligibilityToCreateResult( } } if (eligibility.blockedReason) { - return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider) + return blockedCreateResultForReason( + eligibility.blockedReason, + eligibility.provider, + submittedBase + ) } const copy = reviewCopy(eligibility.provider) return { @@ -358,20 +420,24 @@ async function validateCurrentBranchCanCreateReview( hasUncommittedChanges(repoPath, connectionId, options), getHostedReviewUpstreamStatus(repoPath, connectionId, options) ]) + const submittedBase = normalizeHostedReviewBaseRef(input.base) const eligibility = await getHostedReviewCreationEligibility({ repoPath, branch: requestedHead || currentBranch, - base: normalizeHostedReviewBaseRef(input.base), + base: submittedBase, hasUncommittedChanges: dirty, hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, connectionId, + // Why: this is the last gate before the provider create, which targets the + // submitted base verbatim — enforce that the base exists on the remote here. + enforceBaseOnRemote: true, ...options }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. - return blockedEligibilityToCreateResult(eligibility) + return blockedEligibilityToCreateResult(eligibility, submittedBase) } catch (error) { console.warn('Hosted review creation preflight failed:', error) return { @@ -391,8 +457,22 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId, ...hostedReviewExecutionContext(args) }) - const defaultBaseRef = - args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args)) + // Why: an incoming base is only a *candidate* for the default merge target. A + // stacked worktree's parent base resolves on the remote only when it was + // actually pushed; a local-only parent must fall back to the repo default so + // the PR targets a ref the remote can resolve. Never regress to "no base" — + // keep the candidate if the repo default itself is unavailable. + const candidateBase = args.base?.trim() || null + const candidateBaseOnRemote = + candidateBase != null && + (await baseRefExistsOnRemote(candidateBase, args.repoPath, args.connectionId, args)) + let defaultBaseRef: string | null + if (candidateBase && candidateBaseOnRemote) { + defaultBaseRef = candidateBase + } else { + const repoDefaultBaseRef = await getDefaultBaseRef(args.repoPath, args.connectionId, args) + defaultBaseRef = repoDefaultBaseRef ?? candidateBase + } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null try { @@ -481,6 +561,20 @@ export async function getHostedReviewCreationEligibility( if ((args.ahead ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } } + // Why: at create-time, `gh pr create` (and the other providers) target the + // submitted base verbatim — Change 1 only corrects the *default*, not a stale + // renderer's submitted value. Block a local-only submitted base here so it + // fails with actionable copy instead of the provider's opaque error. Only the + // create-time preflight enforces this; the renderer's eligibility probe leaves + // enforceBaseOnRemote unset so a local-only parent is silently auto-corrected. + if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'base_not_on_remote', + nextAction: null + } + } return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } } 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 74853dd4147..516c0711369 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 @@ -143,14 +143,18 @@ describe('source-control Create PR intent flow helpers', () => { ).toEqual(['safe.ts', 'new.ts']) }) - it('prefers the current compare base over stale eligibility defaults', () => { + it('prefers the remote-validated eligibility default so it cannot diverge from the composer', () => { + // Why: the intent flow's eligibility is recomputed from the same compare + // base right before creation, so its default already corrects a local-only + // stacked parent to the repo default. The one-click path must target that + // same base as the composer, not the raw (possibly unpushable) compare base. expect( resolveCreatePrIntentReviewBase({ - currentBaseRef: 'refs/remotes/origin/release', + currentBaseRef: 'stacked-parent', eligibilityDefaultBaseRef: 'refs/remotes/origin/main', composerBaseRef: 'main' }) - ).toBe('release') + ).toBe('main') expect( resolveCreatePrIntentReviewBase({ @@ -161,6 +165,19 @@ describe('source-control Create PR intent flow helpers', () => { ).toBe('develop') }) + it('falls back to the compare base when eligibility supplies no default', () => { + // Why: never blank the base. If the main process could not resolve a default + // (no candidate on remote and repo default unavailable), keep the user's + // compare base rather than dropping to an empty target. + expect( + resolveCreatePrIntentReviewBase({ + currentBaseRef: 'refs/remotes/origin/release', + eligibilityDefaultBaseRef: null, + composerBaseRef: 'main' + }) + ).toBe('release') + }) + it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => { expect( resolveCreatePrIntentRemoteStep({ 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 7e183122ec5..566ee54e1d7 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 @@ -103,10 +103,15 @@ export function resolveCreatePrIntentReviewBase({ eligibilityDefaultBaseRef?: string | null composerBaseRef?: string | null }): string { - // Why: the compare-base picker is the user's latest target; eligibility can - // lag behind while Create PR intent is preparing the branch. + // Why: prefer the remote-validated eligibility default over the raw compare + // base. The intent flow auto-submits, and its eligibility is recomputed from + // this same compare base right before creation — so `eligibilityDefaultBaseRef` + // already keeps a pushed base verbatim and corrects a local-only stacked parent + // to the repo default. Using it keeps the one-click path consistent with the + // composer instead of submitting a base the remote cannot resolve. Fall back to + // the compare base only when eligibility supplied no default. return normalizeHostedReviewBaseRef( - currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || composerBaseRef?.trim() || '' + eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || composerBaseRef?.trim() || '' ) } diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts index e59354da5c8..2475a7d293e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts @@ -66,6 +66,9 @@ export function resolveBlockedCreateReviewNoticeMessage( case 'existing_review': case 'fork_head_unsupported': case 'unsupported_provider': + // Why: base_not_on_remote is a create-time hard failure surfaced as an error + // result, not an inline-actionable eligibility state, so it is non-clickable. + case 'base_not_on_remote': case null: return null } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 58bbfe9454e..62801dcfe31 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -513,6 +513,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr return `A ${createReviewCopy.reviewLabel} already exists` case 'fork_head_unsupported': return 'Fork head unsupported' + case 'base_not_on_remote': + return 'Base branch is not on the remote' case null: case undefined: return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready' diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts index ce4849a89f9..6ee7bce037f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts @@ -180,6 +180,7 @@ export function resolveDisabledCreatePrHeaderAction( case 'existing_review': case 'fork_head_unsupported': case 'unsupported_provider': + case 'base_not_on_remote': case null: title = translate( 'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581', @@ -311,11 +312,7 @@ export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): Primar return createPrIntent } - // Why: blocked notices are only for states the preparation intent cannot - // safely resolve, such as auth/default-branch/unsafe sync blockers. - if (canClickBlockedCreateReviewReason(inputs.hostedReviewCreation?.blockedReason)) { - return resolveDisabledCreatePrHeaderAction(inputs) - } - + // Why: any remaining blocked state (including create-time-only base_not_on_remote) + // falls back to the disabled header action with its explanatory title. return resolveDisabledCreatePrHeaderAction(inputs) } diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index e23db872e1b..d09fc41cc32 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -155,14 +155,36 @@ describe('useCreatePullRequestDialogFields', () => { } }) - it('prefers the selected current base ref over stale eligibility defaults', async () => { + it('prefers the remote-validated eligibility default over a stacked local-only base', async () => { + // Why: for a stacked worktree the current base is the local-only parent + // branch, which the main process resolves to the repo default. The seeded + // field must follow the remote-validated eligibility default, not the parent. const harness = renderDialogFields({ eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }), - currentBaseRef: 'refs/remotes/origin/release' + currentBaseRef: 'stacked-parent' }) try { await harness.rerender({ eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }), + currentBaseRef: 'stacked-parent' + }) + + expect(harness.current().base).toBe('main') + } finally { + harness.unmount() + } + }) + + it('falls back to the current base ref when eligibility supplies no default', async () => { + // Why: when the main process cannot resolve a default (e.g. origin/HEAD + // unset and no probes match), keep the current base rather than blanking it. + const harness = renderDialogFields({ + eligibility: createEligibility({ defaultBaseRef: null }), + currentBaseRef: 'refs/remotes/origin/release' + }) + try { + await harness.rerender({ + eligibility: createEligibility({ defaultBaseRef: null }), currentBaseRef: 'refs/remotes/origin/release' }) @@ -212,7 +234,7 @@ describe('useCreatePullRequestDialogFields', () => { const seedRevisions = { ...harness.current().fieldRevisions } await harness.rerender({ - eligibility: createEligibility(), + eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/release' }), currentBaseRef: 'refs/remotes/origin/release' }) expect(harness.current().base).toBe('release') diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index a480689334e..08a3b311de6 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -91,7 +91,13 @@ function resolveCreateReviewDefaultBaseRef({ currentBaseRef?: string | null eligibilityDefaultBaseRef?: string | null }): string { - return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '') + // Why: prefer the remote-validated main-process default over the worktree's + // local parent base. For a stacked worktree whose parent is local-only, + // `currentBaseRef` is that unpushable parent; the eligibility default has + // already fallen back to a ref the remote can resolve. Fall back to + // `currentBaseRef` only when eligibility supplied no default. Manual + // `setUserBase` still wins via the base-resync suppression. + return stripBaseRef(eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || '') } export function normalizeCreateReviewBaseSearchResults( 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 6a4c8b197cf..957e0a9bb1e 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 @@ -1832,14 +1832,29 @@ describe('agent completion coordinator', () => { const turn = { prompt: 'fix the bug', agentType: 'codex' as const } coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'ls' + }) // First pause auto-resolves before the window elapses. - coordinator.observeHookStatus({ state: 'working', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + coordinator.observeHookStatus({ + state: 'working', + ...turn, + toolName: 'exec_command', + toolInput: 'ls' + }) vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) expect(dispatchAttention).not.toHaveBeenCalled() // A later, genuinely-distinct pause must re-arm the debounce and fire. - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'apply_patch', toolInput: 'diff' }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'apply_patch', + toolInput: 'diff' + }) expect(dispatchAttention).not.toHaveBeenCalled() vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) expect(dispatchAttention).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 1801c78bace..228af0058eb 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -112,9 +112,8 @@ describe('agent hook completion notifications', () => { // Why: the Codex permission-pause tests share a working→pause→quiet-window // sequence; centralizing it keeps the debounce advance (issue #8387) in one spot. async function observeCodexPermissionPause(state: 'waiting' | 'blocked'): Promise { - const { observeAgentHookCompletionForNotification } = await import( - './agent-hook-completion-notifications' - ) + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') observeAgentHookCompletionForNotification({ paneKey, worktreeId: 'wt-1', diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 864baceba87..04094cf0774 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -104,6 +104,10 @@ export type HostedReviewCreationBlockedReason = | 'fork_head_unsupported' | 'unsupported_provider' | 'existing_review' + // Why: a stacked worktree's local-only parent base is unresolvable on the + // remote; blocked at create-time so the submit fails with actionable copy + // instead of the provider's opaque error. + | 'base_not_on_remote' | null export type HostedReviewCreationNextAction = From 81cc966ea3a23c41e72f98f8aa9793143f5fef49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:12:08 +0000 Subject: [PATCH 40/52] release: v1.4.139-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1bf2bc74f30..e62fce386e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.139-rc.1", + "version": "1.4.139-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b379610cbedd9cfe7750c5e9d2b0464334dc423e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:48:00 -0700 Subject: [PATCH 41/52] fix(browser): handle Cmd-click popups without adopted WebContents (#8659) Co-authored-by: Orca --- src/main/browser/popup-origin-bar-window.test.ts | 6 ++++++ src/main/browser/popup-origin-bar-window.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts index c0161dd94e8..3e816e74b07 100644 --- a/src/main/browser/popup-origin-bar-window.test.ts +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -53,6 +53,11 @@ const { fakeElectron } = vi.hoisted(() => { webContents: FakeWebContents setBounds = vi.fn() constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) { + // Why: Electron rejects explicit undefined instead of treating it as + // omitted, which the popup fallback path depends on. + if (Object.hasOwn(options, 'webContents') && options.webContents === undefined) { + throw new TypeError('options.webContents must be a WebContents') + } this.options = options this.webContents = options.webContents ?? createFakeWebContents() FakeWebContentsView.instances.push(this) @@ -186,6 +191,7 @@ describe('openPopupWithOriginBar', () => { it('loads the target itself only when no pre-created contents were provided', () => { const popup = openPopupWithOriginBar({}, 'https://example.com/login') + expect(lastViews().content.options).not.toHaveProperty('webContents') expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login') }) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts index 504c862a5b8..c9ac057c177 100644 --- a/src/main/browser/popup-origin-bar-window.ts +++ b/src/main/browser/popup-origin-bar-window.ts @@ -120,7 +120,9 @@ export function openPopupWithOriginBar( webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }) const contentView = new WebContentsView({ - webContents: options.webContents, + // Why: Electron rejects an explicitly undefined webContents; omitting it + // lets WebContentsView create contents for Cmd/Ctrl-click popups. + ...(options.webContents === undefined ? {} : { webContents: options.webContents }), webPreferences: options.webPreferences }) window.contentView.addChildView(contentView) From 5a3e4b44f85b74bee12f328961967551fa2b873b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:10:01 -0700 Subject: [PATCH 42/52] fix(lint): cover active/inactive in the terminal tab activity switch (#8660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WorktreeStatus union feeding activityDotState widened, and the switch-exhaustiveness gate now fails on main itself (verify does not run on pushes to main, so the break landed silently and blocks every PR's CI). Cover the two glyph-less states explicitly — matching the function's documented intent — and drop the now-unreachable default. --- src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 5c3b0c7dc24..72e8ed3f276 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -35,7 +35,8 @@ function activityDotState(status: TerminalTabActivityStatus): AgentDotState | nu return 'permission' case 'done': return 'done' - default: + case 'active': + case 'inactive': return null } } From 9875b5528a3d4164b1556e8b1daf457d8bf21b7a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:24:33 -0700 Subject: [PATCH 43/52] Improve native chat style (#8654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redesign native chat interactive UI to match assistant-turn styling - Restyle approval and question cards as bordered cards docked in the composer region instead of top-border strips, and widen the chat column (max-w-3xl → max-w-4xl) to match the rest of the app - Rework the question card into a numbered pick-list with an always- present free-text row and explicit Next/Skip/Send action, replacing the prior "Other…" toggle flow - Hide the composer while a question card is active since it supplies its own answer input, and hold the card open until the paced answer send settles instead of dismissing on click - Auto-repin the message list to the bottom during in-place streaming growth via a ResizeObserver on the content, not just the viewport - Widen question-answer pacing (800ms→1000ms step, 300ms→500ms buffer) for reliability on slower machines / higher-latency SSH sessions - Restyle tool-run/tool-line disclosure to a right-side hover chevron and add a formatted (pretty-printed) detail view for diff-less calls - Add "Skip" i18n string across all locales * Extend paste bridge and question-card fixes for native chat interactive - Route pane-level Paste to the question card's free-text input when the composer is unmounted, so Cmd/Ctrl+V keeps working while a question prompt is showing. - Fix a stale-timer bug where a new interactive prompt could inherit the previous card's dismiss/submitting state and swallow its first answer. - Only render tool-run detail when there's actually content to show, and mark selected question options with aria-pressed for accessibility. --- .../native-chat/NativeChatApprovalCard.tsx | 58 +++-- .../native-chat/NativeChatComposerField.tsx | 9 +- .../native-chat/NativeChatInteractiveCard.tsx | 53 +++- .../native-chat/NativeChatMessageList.tsx | 37 ++- .../native-chat/NativeChatQuestionCard.tsx | 243 +++++++++++------- .../native-chat/NativeChatToolRun.tsx | 83 ++++-- .../components/native-chat/NativeChatView.tsx | 71 ++--- .../native-chat-runtime-send.test.ts | 8 +- .../native-chat/native-chat-runtime-send.ts | 6 +- .../native-chat/native-chat-tool-summary.ts | 20 ++ .../use-native-chat-context-menu.tsx | 17 ++ .../use-native-chat-interactive-send.ts | 16 +- .../use-native-chat-paste-bridge.ts | 34 ++- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 18 files changed, 446 insertions(+), 224 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index cbf041f62e3..9b394ada907 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -19,35 +19,37 @@ export function NativeChatApprovalCard({ onChoose }: NativeChatApprovalCardProps): React.JSX.Element { return ( -
-
-
- -
-

{approval.title}

- {approval.detail ? ( -

- {approval.detail} -

- ) : null} +
+
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))}
-
-
- {approval.options.map((opt, i) => ( - - ))}
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d3149c8c4ca..5a7b2a90255 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -85,8 +85,9 @@ export function NativeChatComposerField({ }: NativeChatComposerFieldProps): React.JSX.Element { return (
-
-
+ {/* Extra bottom padding keeps the input box off the window rim. */} +
+
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( {imageAttachments.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 39bbba9a75e..947d5f65c3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' @@ -23,11 +23,19 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se export function NativeChatInteractiveCard({ paneKey, send, - canSend + canSend, + onShowingQuestionChange, + answerInputRef }: { paneKey: string send: NativeChatInteractiveSend canSend: boolean + /** Reports whether a question card is on screen so the view can replace the + * composer with it (the card's free-text row is the answer input). */ + onShowingQuestionChange?: (showing: boolean) => void + /** Forwarded to the question card's free-text row so pane-level Paste keeps + * a target while the composer is unmounted. */ + answerInputRef?: React.RefObject }): React.JSX.Element | null { const interactivePrompt = useAppStore( (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null @@ -43,15 +51,40 @@ export function NativeChatInteractiveCard({ ) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) + // A question answer is a paced multi-step write (body→Enter per question); keep + // the card up until it settles instead of dismissing on the click, so it doesn't + // vanish mid-send. `submitting` also gates a second submit racing the first. + const dismissTimerRef = useRef | null>(null) + const submittingRef = useRef(false) + const clearDismissTimer = (): void => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current) + dismissTimerRef.current = null + } + submittingRef.current = false + } + // Keyed on cardKey (and unmount): a new prompt can replace the current one + // before the settle timer fires, and a stale `submitting` gate would swallow + // the new card's first answer. + useEffect(() => clearDismissTimer, [cardKey]) // Forget the dismissal once the prompt clears so a fresh prompt can show. const present = card != null useEffect(() => { if (!present) { setDismissedKey(null) + clearDismissTimer() } }, [present]) + // Tell the view when a question card is up so it can hide the composer (this + // card supplies its own input). Reset on unmount so the composer comes back. + const showingQuestion = card?.kind === 'question' && canSend && cardKey !== dismissedKey + useEffect(() => { + onShowingQuestionChange?.(showingQuestion) + return () => onShowingQuestionChange?.(false) + }, [showingQuestion, onShowingQuestionChange]) + if (!card || !canSend || cardKey === dismissedKey) { return null } @@ -60,11 +93,23 @@ export function NativeChatInteractiveCard({ { - setDismissedKey(cardKey) - sendAnswer(text) + if (submittingRef.current) { + return + } + submittingRef.current = true + const settleMs = sendAnswer(text) + // Hold the card until the paced write finishes, then mark it answered + // (which hides it and restores the composer). + dismissTimerRef.current = setTimeout(() => { + setDismissedKey(cardKey) + submittingRef.current = false + dismissTimerRef.current = null + }, settleMs) }} onCancel={() => { + clearDismissTimer() setDismissedKey(cardKey) cancel() }} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 313845c2030..71aee362083 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -167,7 +167,9 @@ function MessageRow({ // transcript caught up.) return (
-
+ {/* User turns get a distinct muted fill (not the card/canvas color) so + the prompt reads apart from the assistant's body copy. */} +
{markdown ? ( <> @@ -213,7 +215,7 @@ function MessageRow({ ) : null} @@ -251,6 +253,7 @@ export function NativeChatMessageList({ failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) + const contentRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) const [showJump, setShowJump] = useState(false) @@ -312,6 +315,11 @@ export function NativeChatMessageList({ if (!container) { return } + // Detach synchronously (not just via the pending onScroll) so an in-place + // streaming growth can't re-pin to the bottom mid-flight and fight this + // deliberate scroll. The ref is what the resize observer reads. + stuckToBottomRef.current = false + setStuckToBottom(false) const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' }) }, []) @@ -334,17 +342,31 @@ export function NativeChatMessageList({ } }, [messages.length, isWorking, showTypingIndicator, scrollToBottom]) - // Keep the affordances in sync if the container resizes (e.g. composer mounts, - // viewport reflow) without a scroll event. + // Content growing without a message-count change (a streaming assistant turn + // extends its own message in place) never re-fires the layout effect above. + // Observe the container so those in-place growths still re-pin: stay glued to + // the bottom while stuck, otherwise just refresh the jump affordance. This is + // what removes most "Jump to latest" clicks during a live response. useEffect(() => { const el = scrollRef.current if (!el || typeof ResizeObserver === 'undefined') { return } - const observer = new ResizeObserver(handleScroll) + const observer = new ResizeObserver(() => { + if (stuckToBottomRef.current) { + scrollToBottom() + } else { + handleScroll() + } + }) + // Observe the growing content, not just the fixed-height viewport, so an + // in-place streaming growth is seen; also watch the viewport for reflows. observer.observe(el) + if (contentRef.current) { + observer.observe(contentRef.current) + } return () => observer.disconnect() - }, [handleScroll]) + }, [handleScroll, scrollToBottom]) return (
@@ -354,7 +376,8 @@ export function NativeChatMessageList({ className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4" >
void /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void + /** Exposes the free-text row so pane-level Paste can target it while the + * card replaces the composer. */ + answerInputRef?: RefObject } -// Synthetic option value for the "Other…" free-text row, kept out of the -// answer text and replaced by the typed value when selected. -const OTHER = '__other__' - /** - * Native renderer for an agent's AskUserQuestion prompt as a wizard: one - * question per step with tabs across the top (tap to jump, a check once - * answered), single- or multi-select option rows, an "Other…" row that reveals a - * free-text input, and a Next button that advances and becomes "Send answer" on - * the last step. Matches the desktop chat's neutral shadcn styling. + * Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list + * (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per + * option, and an always-present free-text row for a custom answer. Single-select + * commits on click; multi-select toggles and confirms via the trailing action. + * Multi-question prompts step through tabs across the top. Neutral shadcn tokens. */ export function NativeChatQuestionCard({ prompt, onAnswer, - onCancel + onCancel, + answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) - const toggle = (qi: number, label: string, multi: boolean): void => { - setSelections((prev) => { - const next = prev.map((s) => [...s]) - const cur = next[qi] ?? [] - if (multi) { - next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] - } else { - next[qi] = cur.includes(label) ? [] : [label] - } - return next - }) - } + const total = prompt.questions.length + const isLast = index === total - 1 + const q = prompt.questions[index]! const setOther = (qi: number, value: string): void => { setOtherText((prev) => { @@ -53,28 +44,19 @@ export function NativeChatQuestionCard({ }) } - // The resolved answer for a question: picked labels plus the typed "Other" - // value (which replaces the synthetic OTHER marker). - const answerFor = (qi: number): string => { - const picked = (selections[qi] ?? []).filter((l) => l !== OTHER) - const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' - return [...picked, other].filter((p) => p.length > 0).join(', ') + // The resolved answer for a question: picked labels plus any typed free-text. + const answerFor = (qi: number, sel = selections, oth = otherText): string => { + const picked = sel[qi] ?? [] + const other = (oth[qi] ?? '').trim() + return [...picked, ...(other ? [other] : [])].join(', ') } - const total = prompt.questions.length - const isLast = index === total - 1 - const currentAnswered = useMemo( - () => answerFor(index).length > 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selections, otherText, index] - ) + const currentAnswered = answerFor(index).length > 0 - const submit = (): void => { - // Build per-question label lists, substituting the typed Other value, then - // format to one line per answered question. + const submitAll = (sel: string[][], oth: string[]): void => { const resolved = prompt.questions.map((_, i) => { - const picked = (selections[i] ?? []).filter((l) => l !== OTHER) - const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : '' + const picked = sel[i] ?? [] + const other = (oth[i] ?? '').trim() return [...picked, ...(other ? [other] : [])] }) const text = formatAskAnswer(prompt, resolved) @@ -83,22 +65,60 @@ export function NativeChatQuestionCard({ } } - const advance = (): void => { + // Advance to the next question, or submit on the last one — always from an + // explicit snapshot so a just-committed single-select pick isn't lost to the + // async setState. + const advanceOrSubmit = (sel: string[][], oth: string[]): void => { if (isLast) { - submit() + submitAll(sel, oth) } else { setIndex((i) => Math.min(i + 1, total - 1)) } } - const q = prompt.questions[index]! - const otherSelected = (selections[index] ?? []).includes(OTHER) + // Selecting only highlights the row; submitting is an explicit step via the + // trailing Send/Next button. (Auto-submitting on the first click dismissed the + // card before the user saw any feedback, which read as "nothing happened".) + const pickOption = (label: string): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[index] ?? [] + if (q.multiSelect) { + next[index] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] + } else { + next[index] = cur.includes(label) ? [] : [label] + } + return next + }) + } + + // Trailing action (also fired by Enter). On any non-final question this just + // advances — "Next" when answered, "Skip" when not — so skipping one question + // never discards answers already given on the others. Only the final question + // submits; an explicit Skip click there with nothing answered anywhere + // dismisses, but a reflexive Enter in the empty field is a no-op so it can't + // throw away the whole prompt. + const confirm = (fromKeyboard = false): void => { + if (!isLast) { + advanceOrSubmit(selections, otherText) + return + } + const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0) + if (anyAnswered) { + submitAll(selections, otherText) + } else if (!fromKeyboard) { + onCancel() + } + } return ( -
-
+ // Part of the composer: docked in the bottom input region, matching the + // composer's width and padding, rendered as the "ask" dialog card directly + // above the text input. Its free-text row is the answer input. +
+
{total > 1 ? ( -
+
{prompt.questions.map((qq, i) => ( +
+ + {/* Scroll only kicks in on long option lists; the sleek scrollbar rides + the card's right edge instead of crowding the choices. */} +
+ {q.options.map((opt, i) => ( toggle(index, opt.label, q.multiSelect)} + onSelect={() => pickOption(opt.label)} /> ))} - toggle(index, OTHER, q.multiSelect)} - /> - {otherSelected ? ( -