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.
This commit is contained in:
Neil
2026-07-30 02:21:38 -07:00
parent 624a76d91e
commit a16d4c6ecc
11 changed files with 800 additions and 21 deletions
+348
View File
@@ -35666,10 +35666,13 @@ describe('OrcaRuntimeService', () => {
(spawn.mock.calls[1]?.[0] as { env?: Record<string, string> } | 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<string, string> }).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<string, WorktreeMeta> = {}
const runtimeStore = {
+50 -16
View File
@@ -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<string[]> {
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<string, string>
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<RuntimeTerminalSplit> {
@@ -24908,6 +24938,9 @@ export class OrcaRuntimeService {
env?: Record<string, string>
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<RuntimeTerminalSplit> {
@@ -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,
@@ -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',
@@ -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()
})
@@ -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 } : {}),
+1
View File
@@ -3140,6 +3140,7 @@ export type PreloadApi = {
activate?: boolean
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
tabId?: string
leafId?: string
splitFromLeafId?: string
+2
View File
@@ -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
@@ -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<string, HarnessTab[]>
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<
string,
{ root?: TerminalPaneLayoutNode; ptyIdsByLeafId?: Record<string, string> }
>
[key: string]: unknown
}
/** Store surface useIpcEvents touches at mount plus the terminal-reveal path. */
export function createHarnessStoreState(
overrides: Partial<HarnessStoreState> & Pick<HarnessStoreState, 'tabsByWorktree'>
): 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<string, unknown> = {}): Record<string, unknown> {
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<typeof vi.fn>
}
/**
* 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<IpcEventsHarness> {
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<typeof ReactModule>('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<string, unknown>,
{ 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
}
}
+127
View File
@@ -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<typeof vi.fn> => value as ReturnType<typeof vi.fn>
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()
})
})
+5 -2
View File
@@ -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)
}
+7
View File
@@ -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 =