From a16d4c6ecc06cb0afafb8b5f9f24ccfb36abaa39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:08:01 -0700 Subject: [PATCH] fix(sidebar): stop background workspace creation from scrolling the sidebar (#11530) * fix(sidebar): stop background workspace creation from scrolling the sidebar Creating a workspace in the background still spawns its terminals, and the renderer treated "no presentation stated" as "point the user at this terminal" -- revealing (scrolling to) the owning workspace. Split adoption from surfacing with an explicit surfaceOwner flag: background worktree creates and worker dispatch adopt their tabs silently, while `orca terminal create` keeps its discoverability reveal. * fix(sidebar): keep split-mode setup panes silent, tighten surfaceOwner Review catch: with setupScriptLaunchMode split-vertical/horizontal the Setup terminal goes through splitTerminal, whose reveal payload had no surfaceOwner, so a background create still scrolled the sidebar in that configuration. Also narrow surfaceOwner to `false` so "surface it" can only be expressed by omitting the key, and fold the repeated conditional spreads into ownerSurfacing. --- src/main/runtime/orca-runtime.test.ts | 348 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 66 +++- .../methods/orchestration-worker-topology.ts | 5 +- .../runtime/rpc/methods/orchestration.test.ts | 9 +- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + .../src/hooks/ipc-events-test-harness.ts | 248 +++++++++++++ src/renderer/src/hooks/useIpcEvents.test.ts | 127 +++++++ src/renderer/src/hooks/useIpcEvents.ts | 7 +- src/shared/runtime-types.ts | 7 + 11 files changed, 800 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events-test-harness.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b44dfc5c5ed..18d07b8e422 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -35666,10 +35666,13 @@ describe('OrcaRuntimeService', () => { (spawn.mock.calls[1]?.[0] as { env?: Record } | undefined)?.env ?? {} expectStablePaneKeyEnv(setupSpawnEnv) const setupLeafId = setupSpawnEnv.ORCA_PANE_KEY.slice(`${setupSpawnEnv.ORCA_TAB_ID}:`.length) + // Why: a background CLI create adopts its tabs silently — surfaceOwner:false + // keeps the sidebar from scrolling to a workspace the user never asked for. expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, { ptyId: 'pty-setup', title: 'Setup', activate: false, + surfaceOwner: false, tabId: setupSpawnEnv.ORCA_TAB_ID, leafId: setupLeafId }) @@ -35900,15 +35903,194 @@ describe('OrcaRuntimeService', () => { const initialLeafId = initialSpawnEnv.ORCA_PANE_KEY.slice( `${initialSpawnEnv.ORCA_TAB_ID}:`.length ) + // Why: the renderer treats a missing surfaceOwner as "reveal the owner", which + // scrolled the sidebar to background CLI creates. expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, { ptyId: 'pty-created-worktree', title: null, activate: false, + surfaceOwner: false, tabId: initialSpawnEnv.ORCA_TAB_ID, leafId: initialLeafId }) }) + it('does not surface the new workspace when a background CLI create launches its agent', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-background-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-background-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-background-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-background-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-background-agent', + head: 'def', + branch: 'runtime-background-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-background-agent', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // `orca worktree create --agent claude` without --activate: the agent tab is + // adopted, but the sidebar must stay on whatever the user was reading. + expect(activateWorktree).not.toHaveBeenCalled() + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-background-agent', + activate: false, + surfaceOwner: false + }) + ) + }) + + it('still surfaces the new workspace when the caller explicitly activates it', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-activated-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-activated-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-activated-agent', + head: 'def', + branch: 'runtime-activated-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-activated-agent', + activate: true, + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // No-regression guard: an explicit --activate still reveals the workspace, so + // surfaceOwner must stay absent. + const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ ptyId: 'pty-activated-agent' }) + ) + expect(revealPayload).not.toHaveProperty('surfaceOwner') + }) + + it('still surfaces the new workspace when hooks run in the foreground', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-hooks-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooks-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-hooks-agent', + head: 'def', + branch: 'runtime-hooks-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hooks-agent', + runHooks: true, + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ ptyId: 'pty-hooks-agent' }) + ) + expect(revealPayload).not.toHaveProperty('surfaceOwner') + }) + it('honors split setup placement for CLI-created worktrees without startup agents', async () => { const runtimeStore = { ...store, @@ -35997,6 +36179,172 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not surface the new workspace when a background create splits its setup pane', async () => { + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + setupScriptLaunchMode: 'split-horizontal' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-bg-split-main' }) + .mockResolvedValueOnce({ id: 'pty-bg-split-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-bg-split-setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-bg-split-setup', + head: 'def', + branch: 'runtime-bg-split-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-bg-split-setup', + setupDecision: 'run' + }) + + // Split launch modes reach the renderer through splitTerminal, not + // createTerminal — that path must suppress owner surfacing too. + expect(activateWorktree).not.toHaveBeenCalled() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} + await vi.waitFor(() => + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-bg-split-setup', + tabId: mainEnv.ORCA_TAB_ID, + activate: false, + surfaceOwner: false, + splitDirection: 'horizontal' + }) + ) + ) + }) + + it('still surfaces the new workspace when an activating create splits its setup pane', async () => { + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + setupScriptLaunchMode: 'split-horizontal' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-active-split-main' }) + .mockResolvedValueOnce({ id: 'pty-active-split-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-active-split-setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-active-split-setup', + head: 'def', + branch: 'runtime-active-split-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-active-split-setup', + activate: true, + setupDecision: 'run', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // No-regression guard: an explicit --activate still reveals the workspace. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-active-split-setup', + splitDirection: 'horizontal' + }) + ) + ) + const splitRevealPayload = revealTerminalSession.mock.lastCall?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(splitRevealPayload).not.toHaveProperty('surfaceOwner') + }) + it('does not warn when setup is explicitly skipped for CLI-created worktrees', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c41d88c83a5..6fa3585fe96 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1248,6 +1248,10 @@ type TerminalCreateOptions = { rendererBacked?: boolean activate?: boolean presentation?: RuntimeTerminalPresentation + // Why: `false` adopts the terminal without pointing the user at it — no + // sidebar reveal, no tab focus. Distinct from 'background' presentation, + // which skips renderer adoption entirely. + surfaceOwner?: false tabId?: string leafId?: string sessionId?: string @@ -1603,6 +1607,12 @@ function createTerminalRevealWarning(handle: string, error?: unknown): string { ].join(' ') } +// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit +// the key rather than send `true`. +function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } { + return shouldSurface ? {} : { surfaceOwner: false } +} + function resolveTerminalPresentation(opts: { presentation?: RuntimeTerminalPresentation focus?: boolean @@ -1651,6 +1661,7 @@ type RuntimeNotifier = { viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -19674,7 +19685,8 @@ export class OrcaRuntimeService { private async createDefaultTabTerminals( worktreeSelector: string, worktreeId: string, - defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, + surfacing: { surfaceOwner?: false } = {} ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { return [] @@ -19685,7 +19697,8 @@ export class OrcaRuntimeService { const command = template.command?.trim() const terminal = await this.createTerminal(worktreeSelector, { ...(template.title ? { title: template.title } : {}), - ...(command && defaultTabs.runCommands ? { command } : {}) + ...(command && defaultTabs.runCommands ? { command } : {}), + ...surfacing }) handles.push(terminal.handle) if (template.color && terminal.tabId) { @@ -19716,17 +19729,22 @@ export class OrcaRuntimeService { // the setup command. Pass that wrapped command through so the Setup tab runs // the same script the agent is waiting on instead of a bare runner. wrappedSetupCommand?: string + // Why: a workspace provisioned in the background must not pull the sidebar + // to itself; the user never asked to look at these tabs. + surfaceOwner?: false }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { if (!this.ptyController?.spawn) { return { setupSpawned: false, setupTerminalHandle: null } } + const surfacing = ownerSurfacing(args.surfaceOwner !== false) let setupSpawned = false let setupTerminalHandle: string | null = null try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, args.worktreeId, - args.defaultTabs + args.defaultTabs, + surfacing ) let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null const setupLaunchMode = @@ -19736,7 +19754,7 @@ export class OrcaRuntimeService { > ).setupScriptLaunchMode ?? 'new-tab' if (!args.hasStartupTerminal && !primaryTerminalHandle) { - const terminal = await this.createTerminal(args.worktreeSelector) + const terminal = await this.createTerminal(args.worktreeSelector, surfacing) primaryTerminalHandle = terminal.handle } if (args.setup) { @@ -19762,12 +19780,14 @@ export class OrcaRuntimeService { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, env: setupEnv, - activate: false + activate: false, + ...surfacing }) : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, - env: setupEnv + env: setupEnv, + ...surfacing })) setupTerminalHandle = setupTerminal.handle setupSpawned = true @@ -20039,7 +20059,8 @@ export class OrcaRuntimeService { : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + telemetry: effectiveStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -20070,7 +20091,7 @@ export class OrcaRuntimeService { } } else if (this.ptyController?.spawn && !didSpawnStartup) { try { - await this.createTerminal(`id:${worktree.id}`) + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -20774,7 +20795,8 @@ export class OrcaRuntimeService { ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -20875,7 +20897,8 @@ export class OrcaRuntimeService { : 'posix' : 'posix', observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false }) // Why: runtime owns setup spawning here, so the RPC result must omit setup // to keep the headless/mobile caller from launching it a second time. @@ -20891,7 +20914,7 @@ export class OrcaRuntimeService { } } else if (this.ptyController?.spawn) { try { - await this.createTerminal(`id:${worktree.id}`) + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -21069,6 +21092,7 @@ export class OrcaRuntimeService { this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyWorktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true let warning = result.warning let didSpawnStartup = false // Why: same no-double-spawn contract as the local path — once runtime @@ -21116,7 +21140,8 @@ export class OrcaRuntimeService { ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -21137,7 +21162,6 @@ export class OrcaRuntimeService { } } - const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(result.setup || result.defaultTabs) @@ -21219,7 +21243,8 @@ export class OrcaRuntimeService { : 'posix' : 'posix', observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false }) // Why: runtime owns setup spawning here, so omit setup from the RPC result // to keep the headless/mobile caller from launching it a second time. @@ -21235,7 +21260,7 @@ export class OrcaRuntimeService { } } else if (!shouldActivate && this.ptyController?.spawn) { try { - await this.createTerminal(`path:${result.worktree.path}`) + await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -23647,6 +23672,7 @@ export class OrcaRuntimeService { ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false), tabId, leafId }) @@ -23731,7 +23757,8 @@ export class OrcaRuntimeService { startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', - ...(presentation ? { presentation } : {}) + ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false) }) }) @@ -24871,6 +24898,9 @@ export class OrcaRuntimeService { env?: Record envToDelete?: string[] activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { @@ -24908,6 +24938,9 @@ export class OrcaRuntimeService { env?: Record envToDelete?: string[] activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { @@ -24958,6 +24991,7 @@ export class OrcaRuntimeService { ptyId: result.id, title: null, activate: opts.activate !== false, + ...ownerSurfacing(opts.surfaceOwner !== false), tabId: parentTabId, leafId, splitFromLeafId: parsedPaneKey.leafId, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 91e92845963..474d3cb0316 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -60,7 +60,10 @@ export async function createExistingWorktreeWorkerTerminal(args: { }): Promise<{ handle: string; warning?: string }> { const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, { command: args.agent, - title: `worker-${args.taskId}` + title: `worker-${args.taskId}`, + // Why: dispatching a worker is background work; it must not pull the sidebar + // to the worker's workspace while the user is reading somewhere else. + surfaceOwner: false }) args.effects.push({ kind: 'terminal', diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index b5a850a7781..c29ac7ddadb 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -2176,9 +2176,12 @@ describe('orchestration RPC methods', () => { ) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready') + // Why: dispatching a worker is background work — surfaceOwner:false adopts + // the tab without scrolling the sidebar to the worker's workspace. expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { command: 'codex', - title: `worker-${task.id}` + title: `worker-${task.id}`, + surfaceOwner: false }) expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( 'term_worker', @@ -2277,7 +2280,9 @@ describe('orchestration RPC methods', () => { ) expect(runtime.createTerminal).toHaveBeenCalledWith( 'id:repo::other', - expect.objectContaining({ command: 'codex' }) + // Why: starting a worker in an existing worktree must not pull the sidebar + // away from whatever the user is looking at. + expect.objectContaining({ command: 'codex', surfaceOwner: false }) ) expect(createWorktree).not.toHaveBeenCalled() }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4d4448c04bf..01445216b69 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -402,6 +402,7 @@ function registerRuntimeWindowLifecycle( ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), activate: opts.activate !== false, ...(opts.presentation ? { presentation: opts.presentation } : {}), + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}), // Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right. ...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}), ...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b601ce39fac..7150731ff47 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3140,6 +3140,7 @@ export type PreloadApi = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 3388c8af33e..6de9ffafce2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3701,6 +3701,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -3726,6 +3727,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts new file mode 100644 index 00000000000..994001efea7 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -0,0 +1,248 @@ +import { vi } from 'vitest' +import type * as ReactModule from 'react' +import type { TerminalPaneLayoutNode } from '../../../shared/types' + +export type CreateTerminalRequest = { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + presentation?: 'background' | 'focused' + surfaceOwner?: boolean + tabId?: string + leafId?: string + splitFromLeafId?: string +} + +export type RequestTerminalCreateRequest = { + requestId: string + worktreeId?: string + command?: string + title?: string + activate?: boolean + presentation?: 'background' | 'focused' + surfaceOwner?: boolean +} + +export type HarnessTab = { id: string; ptyId?: string | null; title?: string } + +export type HarnessStoreState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + terminalLayoutsByTabId: Record< + string, + { root?: TerminalPaneLayoutNode; ptyIdsByLeafId?: Record } + > + [key: string]: unknown +} + +/** Store surface useIpcEvents touches at mount plus the terminal-reveal path. */ +export function createHarnessStoreState( + overrides: Partial & Pick +): HarnessStoreState { + const state: HarnessStoreState = { + createTab: vi.fn(() => ({ id: 'tab-minted' })), + setActiveView: vi.fn(), + setActiveWorktree: vi.fn(), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + isNavigatingHistory: false, + setActiveTabType: vi.fn(), + setActiveTab: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setTabCustomTitle: vi.fn(), + queueTabStartupCommand: vi.fn(), + registerAgentLaunchConfig: vi.fn(), + clearAgentLaunchConfig: vi.fn(), + updateTabPtyId: vi.fn(), + setTabLayout: vi.fn(), + setTabBarOrder: vi.fn(), + clearTabPtyId: vi.fn(), + setUpdateStatus: vi.fn(), + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + closeModal: vi.fn(), + openModal: vi.fn(), + setActiveRepo: vi.fn(), + setIsFullScreen: vi.fn(), + updateBrowserPageState: vi.fn(), + setEditorFontZoomLevel: vi.fn(), + setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), + setPortForwards: vi.fn(), + clearPortForwards: vi.fn(), + setDetectedPorts: vi.fn(), + enqueueSshCredentialRequest: vi.fn(), + removeSshCredentialRequest: vi.fn(), + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + folderWorkspaces: [], + projectGroups: [], + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + openFiles: [], + browserTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeModal: null, + activeWorktreeId: 'wt-1', + activeView: 'terminal', + activeTabType: 'terminal', + editorFontZoomLevel: 0, + settings: { + terminalFontSize: 13, + experimentalNativeChat: false, + openAgentTabsInChatByDefault: false, + activeRuntimeEnvironmentId: undefined + }, + ...overrides + } + if (overrides.setTabLayout === undefined) { + state.setTabLayout = vi.fn( + (tabId: string, layout: HarnessStoreState['terminalLayoutsByTabId'][string]) => { + state.terminalLayoutsByTabId[tabId] = layout + } + ) + } + return state +} + +/** Subscription no-ops for every listener useIpcEvents attaches beyond the ones under test. */ +function createApiNamespaceStub(overrides: Record = {}): Record { + return new Proxy(overrides, { + get: (target, prop: string) => (prop in target ? target[prop] : () => () => {}) + }) +} + +export type IpcEventsHarness = { + /** Call inside the test body: useIpcEvents runs its effects eagerly here. */ + useIpcEvents: () => void + createTerminal: (request: CreateTerminalRequest) => void + requestTerminalCreate: (request: RequestTerminalCreateRequest) => void + replyTerminalCreate: ReturnType +} + +/** + * Loads useIpcEvents against a stubbed preload API and returns a driver for the + * create-terminal IPC, so reveal/adoption behavior is asserted through the hook. + */ +export async function loadIpcEventsHarness( + storeState: HarnessStoreState +): Promise { + const replyTerminalCreate = vi.fn() + let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null + let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null + + vi.resetModules() + vi.unstubAllGlobals() + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => storeState } + })) + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ getVisibleWorktreeIds: () => [] })) + vi.doMock('@/lib/floating-workspace-terminal-actions', () => ({ + createFloatingWorkspaceTerminalTab: vi.fn(), + isEmptyFloatingWorkspacePanelVisible: () => false, + isFloatingWorkspacePanelFocused: () => false + })) + vi.doMock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionBrowserTab: vi.fn().mockResolvedValue(false), + createWebRuntimeSessionTerminal: vi.fn().mockResolvedValue({ status: 'failed', message: '' }), + isWebRuntimeSessionActive: vi.fn(() => false) + })) + vi.doMock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) + vi.doMock('@/runtime/sync-runtime-graph', () => ({ + focusRuntimeTerminalSurface: vi.fn(() => false) + })) + vi.doMock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: vi.fn() })) + + vi.stubGlobal('window', { + dispatchEvent: vi.fn(), + api: new Proxy( + { + ui: createApiNamespaceStub({ + getZoomLevel: () => 0, + consumePendingOpenSettings: () => Promise.resolve(false), + set: vi.fn(), + replyTabCreate: vi.fn(), + replyTabClose: vi.fn(), + replyTabSetProfile: vi.fn(), + replyTerminalCreate, + onCreateTerminal: (listener: (request: CreateTerminalRequest) => void) => { + createTerminalListener = listener + return () => {} + }, + onRequestTerminalCreate: (listener: (request: RequestTerminalCreateRequest) => void) => { + requestTerminalCreateListener = listener + return () => {} + } + }), + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: 0 }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {}, + onBrowserDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + listRemovedTargetLabels: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + mobile: createApiNamespaceStub({ + consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false) + }), + remoteWorkspace: createApiNamespaceStub({ clientId: () => Promise.resolve(null) }) + } as Record, + { get: (target, prop: string) => target[prop] ?? createApiNamespaceStub() } + ) + }) + + const { useIpcEvents } = await import('./useIpcEvents') + return { + useIpcEvents, + createTerminal: (request) => { + if (typeof createTerminalListener !== 'function') { + throw new Error('Expected the create-terminal listener to be registered') + } + createTerminalListener(request) + }, + requestTerminalCreate: (request) => { + if (typeof requestTerminalCreateListener !== 'function') { + throw new Error('Expected the request-terminal-create listener to be registered') + } + requestTerminalCreateListener(request) + }, + replyTerminalCreate + } +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0765019ca83..1f9b1763851 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -11,6 +11,11 @@ import { resolveBrowserSessionTabTarget, resolveZoomTarget } from './useIpcEvents' +import { + createHarnessStoreState, + loadIpcEventsHarness, + type HarnessStoreState +} from './ipc-events-test-harness' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' import type { TuiAgent } from '../../../shared/types' @@ -8555,3 +8560,125 @@ describe('parked terminal recovery on repos:changed', () => { clearTerminalTabsParkedOnUnresolvedHost() }) }) + +describe('useIpcEvents silent terminal adoption (surfaceOwner: false)', () => { + const asMock = (value: unknown): ReturnType => value as ReturnType + + function createBackgroundWorkspaceState(): HarnessStoreState { + return createHarnessStoreState({ + tabsByWorktree: {}, + // Honour the pre-minted tab id so adoption resolves the same tab main asked for. + createTab: vi.fn( + (_worktreeId: string, _groupId?: string, _tabType?: string, options?: { id?: string }) => ({ + id: options?.id ?? 'tab-minted' + }) + ), + worktreesByRepo: { + 'repo-1': [ + { id: 'wt-1', repoId: 'repo-1' }, + { id: 'wt-2', repoId: 'repo-1' } + ] + } + }) + } + + it('adopts a background workspace terminal without scrolling the sidebar to it', async () => { + const storeState = createBackgroundWorkspaceState() + const harness = await loadIpcEventsHarness(storeState) + harness.useIpcEvents() + + // Control: `orca terminal create` (no surfaceOwner) keeps its reveal so the + // user can find the terminal they just asked for. + harness.createTerminal({ + worktreeId: 'wt-2', + ptyId: 'pty-cli', + activate: false, + tabId: 'tab-cli' + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-cli', + activate: false, + id: 'tab-cli' + }) + expect(storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') + + asMock(storeState.createTab).mockClear() + asMock(storeState.revealWorktreeInSidebar).mockClear() + asMock(storeState.setActiveView).mockClear() + asMock(storeState.setActiveWorktree).mockClear() + asMock(storeState.setActiveTabType).mockClear() + asMock(storeState.setActiveTab).mockClear() + + // Regression: a workspace created in the background spawns its agent terminal + // here; the tab must still be adopted, but the sidebar must not scroll to it. + harness.createTerminal({ + worktreeId: 'wt-2', + ptyId: 'pty-background-create', + activate: false, + surfaceOwner: false, + tabId: 'tab-background-create' + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-background-create', + activate: false, + id: 'tab-background-create' + }) + expect(storeState.revealWorktreeInSidebar).not.toHaveBeenCalled() + expect(storeState.setActiveView).not.toHaveBeenCalled() + expect(storeState.setActiveWorktree).not.toHaveBeenCalled() + expect(storeState.setActiveTabType).not.toHaveBeenCalled() + expect(storeState.setActiveTab).not.toHaveBeenCalled() + }) + + it('replies to a requested terminal create without scrolling the sidebar when surfaceOwner is false', async () => { + const storeState = createBackgroundWorkspaceState() + const harness = await loadIpcEventsHarness(storeState) + harness.useIpcEvents() + + // Control: the same request without surfaceOwner still reveals its workspace. + harness.requestTerminalCreate({ + requestId: 'req-cli', + worktreeId: 'wt-2', + title: 'Claude' + }) + + expect(harness.replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-cli', + tabId: 'tab-minted', + title: 'Claude' + }) + expect(storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') + + asMock(storeState.createTab).mockClear() + asMock(storeState.revealWorktreeInSidebar).mockClear() + asMock(storeState.setActiveView).mockClear() + asMock(storeState.setActiveWorktree).mockClear() + asMock(storeState.setActiveTabType).mockClear() + asMock(storeState.setActiveTab).mockClear() + harness.replyTerminalCreate.mockClear() + + harness.requestTerminalCreate({ + requestId: 'req-background-create', + worktreeId: 'wt-2', + title: 'Claude', + surfaceOwner: false + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + activate: false, + recordInteraction: false + }) + expect(harness.replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-background-create', + tabId: 'tab-minted', + title: 'Claude' + }) + expect(storeState.revealWorktreeInSidebar).not.toHaveBeenCalled() + expect(storeState.setActiveView).not.toHaveBeenCalled() + expect(storeState.setActiveWorktree).not.toHaveBeenCalled() + expect(storeState.setActiveTabType).not.toHaveBeenCalled() + expect(storeState.setActiveTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 320241061fb..d262f72452b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1406,6 +1406,7 @@ export function useIpcEvents(): void { activate, focus, presentation, + surfaceOwner, tabId, leafId, splitFromLeafId, @@ -1420,7 +1421,8 @@ export function useIpcEvents(): void { focus }) const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = terminalPresentation !== 'background' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && surfaceOwner !== false if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } @@ -1676,7 +1678,8 @@ export function useIpcEvents(): void { } const terminalPresentation = resolveTerminalPresentation(data) const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = terminalPresentation !== 'background' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && data.surfaceOwner !== false if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 62d8293bcc8..fc55a6985fc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -622,6 +622,13 @@ type RuntimeTerminalCreateBaseRequestPayload = { title?: string activate?: boolean presentation?: RuntimeTerminalPresentation + /** + * Why: adopting a terminal is separate from pointing the user at it. `false` + * keeps the tab silent — no sidebar reveal, no tab focus — for terminals the + * user never asked to see (e.g. a workspace created in the background). + * Absent means "surface it", so this is a suppression switch, never `true`. + */ + surfaceOwner?: false } export type RuntimeTerminalCreateRequestPayload =