mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
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.
This commit is contained in:
@@ -36064,10 +36064,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
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string[]> {
|
||||
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 } : {})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -3157,6 +3157,7 @@ export type PreloadApi = {
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: boolean
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -120,6 +133,7 @@ export async function loadIpcEventsHarness(
|
||||
): Promise<IpcEventsHarness> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user