From aa273e44f2eae3e5feeeadca219fc2b11cabd09e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:41:47 -0700 Subject: [PATCH] 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. --- src/main/runtime/orca-runtime.test.ts | 182 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 50 +++-- .../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 | 24 +++ src/renderer/src/hooks/useIpcEvents.test.ts | 127 ++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 7 +- src/shared/runtime-types.ts | 6 + 11 files changed, 394 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 72663c7f908..8f2ac9efc91 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -36064,10 +36064,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 }) @@ -36303,15 +36306,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, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 161314dbb16..43b313a5d80 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1271,6 +1271,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?: boolean tabId?: string leafId?: string sessionId?: string @@ -1702,6 +1706,7 @@ type RuntimeNotifier = { viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: boolean tabId?: string leafId?: string splitFromLeafId?: string @@ -19942,7 +19947,8 @@ export class OrcaRuntimeService { private async createDefaultTabTerminals( worktreeSelector: string, worktreeId: string, - defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, + surfaceOwner?: boolean ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { return [] @@ -19953,7 +19959,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 } : {}), + ...(surfaceOwner === false ? { surfaceOwner: false } : {}) }) handles.push(terminal.handle) if (template.color && terminal.tabId) { @@ -19984,17 +19991,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?: boolean }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { if (!this.ptyController?.spawn) { return { setupSpawned: false, setupTerminalHandle: null } } + const silent = args.surfaceOwner === false ? ({ surfaceOwner: false } as const) : {} let setupSpawned = false let setupTerminalHandle: string | null = null try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, args.worktreeId, - args.defaultTabs + args.defaultTabs, + args.surfaceOwner ) let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null const setupLaunchMode = @@ -20004,7 +20016,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, { ...silent }) primaryTerminalHandle = terminal.handle } if (args.setup) { @@ -20035,7 +20047,8 @@ export class OrcaRuntimeService { : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, - env: setupEnv + env: setupEnv, + ...silent })) setupTerminalHandle = setupTerminal.handle setupSpawned = true @@ -20314,7 +20327,8 @@ export class OrcaRuntimeService { ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + telemetry: effectiveStartup.telemetry, + ...(shouldActivate ? {} : { surfaceOwner: false }) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -20345,7 +20359,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 @@ -21054,7 +21068,8 @@ export class OrcaRuntimeService { ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...(shouldActivate ? {} : { surfaceOwner: false }) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -21155,7 +21170,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. @@ -21171,7 +21187,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 @@ -21355,6 +21371,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 @@ -21403,7 +21420,8 @@ export class OrcaRuntimeService { ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...(shouldActivate ? {} : { surfaceOwner: false }) }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -21424,7 +21442,6 @@ export class OrcaRuntimeService { } } - const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(result.setup || result.defaultTabs) @@ -21506,7 +21523,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. @@ -21522,7 +21540,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 @@ -23948,6 +23966,7 @@ export class OrcaRuntimeService { ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}), tabId, leafId }) @@ -24032,7 +24051,8 @@ export class OrcaRuntimeService { startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', - ...(presentation ? { presentation } : {}) + ...(presentation ? { presentation } : {}), + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}) }) }) 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 a1282932721..51adc620901 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 05790e88a9d..876339525d2 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 d539ba6fc67..a4453f4e192 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3157,6 +3157,7 @@ export type PreloadApi = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: boolean tabId?: string leafId?: string splitFromLeafId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 00c8402211b..6a0ee41954f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3722,6 +3722,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: boolean tabId?: string leafId?: string splitFromLeafId?: string @@ -3747,6 +3748,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: boolean 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 index 0c3557654c6..994001efea7 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -8,12 +8,24 @@ export type CreateTerminalRequest = { 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 = { @@ -108,6 +120,7 @@ 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 } @@ -120,6 +133,7 @@ export async function loadIpcEventsHarness( ): Promise { const replyTerminalCreate = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null + let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null vi.resetModules() vi.unstubAllGlobals() @@ -170,6 +184,10 @@ export async function loadIpcEventsHarness( onCreateTerminal: (listener: (request: CreateTerminalRequest) => void) => { createTerminalListener = listener return () => {} + }, + onRequestTerminalCreate: (listener: (request: RequestTerminalCreateRequest) => void) => { + requestTerminalCreateListener = listener + return () => {} } }), rateLimits: { @@ -219,6 +237,12 @@ export async function loadIpcEventsHarness( } 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 03d3a64b743..33377008caf 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1416,6 +1416,7 @@ export function useIpcEvents(): void { activate, focus, presentation, + surfaceOwner, tabId, leafId, splitFromLeafId, @@ -1430,7 +1431,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) } @@ -1682,7 +1684,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..7632c2b376a 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -622,6 +622,12 @@ 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). + */ + surfaceOwner?: boolean } export type RuntimeTerminalCreateRequestPayload =