feat(terminal): show a paired server's terminal in this desktop on terminal switch (#28023)

* fix(terminal): log a failed pane reveal instead of rethrowing it

The bridge already replies false when the reveal throws, so the rethrow only
surfaced as an uncaught renderer error after the caller had its answer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): refuse terminal.reveal from paired clients

It moves this host's window. terminal.focus lets a paired client do that only
by asking for host navigation, and the callers this method serves (the CLI and
companion apps on the same machine) never arrive as paired clients.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(terminal): show a paired server's terminal in this desktop on terminal switch

A paired `orca serve` host has no window, so `orca terminal switch --environment <name>`
resolved the handle there, answered `navigated: false`, and the desktop stayed put (#24688).

Add `terminal.reveal`, a desktop runtime method that names a pane by worktree, tab and leaf, as
the owning host knows them, and runs the Agent Dashboard's click-to-focus sequence before raising
the window. When a paired server cannot navigate, `terminal switch` asks the desktop on this
machine to reveal the terminal instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(e2e): terminal switch on a paired server moves the desktop on this machine

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: update terminal manifest count after main and assert the revealed pane renders

Main added a terminal method since this branch was cut, so the characterization
count is 39 with terminal.reveal. The e2e now proves the target pane is visible
in the DOM, not only selected in the store.

Co-authored-by: Velm14 <Velm14@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Velm14 <Velm14@users.noreply.github.com>
This commit is contained in:
Hennadii Mishyn
2026-10-15 23:26:59 -07:00
committed by GitHub
co-authored by Velm14 Claude Opus 5.5 Neil
parent 7cf0c2a5e2
commit 0fac887dbc
37 changed files with 1016 additions and 28 deletions
+1
View File
@@ -214,6 +214,7 @@ Terminal rules:
- A bulk close fails when the execution host cannot confirm every PTY stopped. Treat that as `unverifiable`; do not report the processes as exited or retry against another host.
- Use workspace Sleep, not close, when the terminals and agent sessions should resume later. `terminal stop` is legacy compatibility plumbing and should not be used in new agent workflows.
- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.
- A paired server has no window. `terminal switch --environment <name>` moves the desktop app on this machine to that server's terminal, and reports `navigated: false` when no desktop here shows that server.
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
- `accepted: true` proves input acceptance, not a started turn. Use the receipt's `turn_started` stage when submission proof is needed; never resend on silence.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeClient } from '../runtime-client'
const mocks = vi.hoisted(() => ({ revealPairedTerminalOnLocalDesktop: vi.fn() }))
vi.mock('../paired-terminal-desktop-reveal', () => ({
revealPairedTerminalOnLocalDesktop: mocks.revealPairedTerminalOnLocalDesktop
}))
import { TERMINAL_HANDLERS } from './terminal'
function focusResponse(navigated: boolean) {
return {
id: 'req-focus',
ok: true,
result: { focus: { handle: 'term-1', tabId: 'tab-1', worktreeId: 'wt-1', navigated } },
_meta: { runtimeId: 'runtime-1' }
}
}
async function runSwitch(client: { call: ReturnType<typeof vi.fn>; isRemote: boolean }) {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal switch']({
flags: new Map([['terminal', 'term-1']]),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch handler reads only call and isRemote.
client: client as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
return JSON.parse(String(log.mock.calls[0]?.[0]))
}
describe('terminal switch on a paired environment', () => {
afterEach(() => {
vi.restoreAllMocks()
mocks.revealPairedTerminalOnLocalDesktop.mockReset()
})
it('shows the terminal in the local desktop when the windowless server cannot (#24688)', async () => {
const call = vi.fn().mockResolvedValue(focusResponse(false))
mocks.revealPairedTerminalOnLocalDesktop.mockResolvedValue(true)
const printed = await runSwitch({ call, isRemote: true })
expect(call).toHaveBeenCalledWith('terminal.focus', { terminal: 'term-1', navigation: 'host' })
expect(mocks.revealPairedTerminalOnLocalDesktop).toHaveBeenCalledWith(
expect.objectContaining({ isRemote: true }),
'term-1'
)
expect(printed).toMatchObject({ ok: true, result: { focus: { navigated: true } } })
})
it('keeps navigated false when no local desktop shows that environment', async () => {
const call = vi.fn().mockResolvedValue(focusResponse(false))
mocks.revealPairedTerminalOnLocalDesktop.mockResolvedValue(false)
const printed = await runSwitch({ call, isRemote: true })
expect(printed).toMatchObject({ result: { focus: { navigated: false } } })
})
it.each([
['a local terminal the host could not navigate to', false, false],
['a remote host that navigated itself', true, true]
])('leaves %s alone', async (_case, isRemote, navigated) => {
const call = vi.fn().mockResolvedValue(focusResponse(navigated))
const printed = await runSwitch({ call, isRemote })
expect(mocks.revealPairedTerminalOnLocalDesktop).not.toHaveBeenCalled()
expect(printed).toMatchObject({ result: { focus: { navigated } } })
})
})
+6 -1
View File
@@ -42,6 +42,7 @@ import {
getRequiredWorktreeSelector,
getTerminalHandle
} from '../selectors'
import { revealPairedTerminalOnLocalDesktop } from '../paired-terminal-desktop-reveal'
import { terminalCloseHandler } from './terminal-close'
import { terminalSendHandler } from './terminal-send'
@@ -51,10 +52,14 @@ import { terminalSendHandler } from './terminal-send'
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
const terminal = await getTerminalHandle(flags, cwd, client)
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
terminal: await getTerminalHandle(flags, cwd, client),
terminal,
navigation: 'host'
})
if (client.isRemote && result.result.focus.navigated === false) {
result.result.focus.navigated = await revealPairedTerminalOnLocalDesktop(client, terminal)
}
printResult(result, json, formatTerminalFocus)
}
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
desktopCall: vi.fn(),
desktopClient: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('./runtime-client', () => ({
RuntimeClient: class {
call = mocks.desktopCall
constructor(...args: unknown[]) {
mocks.desktopClient(...args)
}
}
}))
vi.mock('./runtime/environments', () => ({ resolveEnvironment: mocks.resolveEnvironment }))
import type { TerminalPaneRevealTarget } from '../shared/terminal-pane-reveal'
import type { RuntimeClient } from './runtime-client'
import { revealPairedTerminalOnLocalDesktop } from './paired-terminal-desktop-reveal'
const HANDLE = 'term_remote'
const shownTerminal = {
handle: HANDLE,
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
}
function pairedClient(environmentSelector: string | null) {
const serverCall = vi.fn(async () => ({ ok: true, result: { terminal: shownTerminal } }))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reveal reads only these three members.
const client = {
environmentSelector,
userDataPath: '/profile',
call: serverCall
} as unknown as RuntimeClient
return { client, serverCall }
}
describe('revealPairedTerminalOnLocalDesktop', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveEnvironment.mockReturnValue({ id: 'env/1' })
mocks.desktopCall.mockImplementation(
async (_method: string, params: TerminalPaneRevealTarget) => ({
ok: true,
result: { reveal: { ...params, navigated: true } }
})
)
})
it("shows a paired server's terminal in this machine's desktop window", async () => {
const { client, serverCall } = pairedClient('winpc')
await expect(revealPairedTerminalOnLocalDesktop(client, HANDLE)).resolves.toBe(true)
expect(mocks.resolveEnvironment).toHaveBeenCalledWith('/profile', 'winpc')
expect(serverCall).toHaveBeenCalledWith('terminal.show', { terminal: HANDLE })
// Why null selectors: the reveal must reach this machine's runtime, not the environment again.
expect(mocks.desktopClient).toHaveBeenCalledWith('/profile', undefined, null, null)
expect(mocks.desktopCall).toHaveBeenCalledWith('terminal.reveal', {
worktreeId: shownTerminal.worktreeId,
tabId: shownTerminal.tabId,
leafId: shownTerminal.leafId,
executionHostId: 'runtime:env%2F1'
})
})
it('asks no desktop when the client is not talking to a paired environment', async () => {
const { client, serverCall } = pairedClient(null)
await expect(revealPairedTerminalOnLocalDesktop(client, HANDLE)).resolves.toBe(false)
expect(serverCall).not.toHaveBeenCalled()
expect(mocks.desktopClient).not.toHaveBeenCalled()
})
it('asks no desktop when the server no longer knows the terminal', async () => {
const { client, serverCall } = pairedClient('winpc')
serverCall.mockRejectedValue(new Error('terminal_handle_stale'))
await expect(revealPairedTerminalOnLocalDesktop(client, HANDLE)).resolves.toBe(false)
expect(mocks.desktopClient).not.toHaveBeenCalled()
})
it.each([
['no desktop is running here', new Error('runtime_unavailable')],
['the desktop predates terminal.reveal', new Error('method_not_found')]
])('keeps the server answer when %s', async (_case, error) => {
const { client } = pairedClient('winpc')
mocks.desktopCall.mockRejectedValue(error)
await expect(revealPairedTerminalOnLocalDesktop(client, HANDLE)).resolves.toBe(false)
})
it('keeps the server answer when the desktop does not know the workspace', async () => {
const { client } = pairedClient('winpc')
mocks.desktopCall.mockResolvedValue({ ok: true, result: { reveal: { navigated: false } } })
await expect(revealPairedTerminalOnLocalDesktop(client, HANDLE)).resolves.toBe(false)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { toRuntimeExecutionHostId } from '../shared/execution-host'
import type { RuntimeTerminalShow } from '../shared/runtime-types'
import type { RuntimeTerminalReveal } from '../shared/terminal-pane-reveal'
import { RuntimeClient } from './runtime-client'
import { resolveEnvironment } from './runtime/environments'
/**
* A paired server has no window, so `terminal.focus` there never navigates (#24688). The desktop
* app on this machine shows that server's panes under the same ids and can reveal one.
* Resolves false when no local desktop shows the environment; the server's answer then stands.
*/
export async function revealPairedTerminalOnLocalDesktop(
client: RuntimeClient,
handle: string
): Promise<boolean> {
if (!client.environmentSelector) {
return false
}
try {
const environment = resolveEnvironment(client.userDataPath, client.environmentSelector)
const { terminal } = (
await client.call<{ terminal: RuntimeTerminalShow }>('terminal.show', { terminal: handle })
).result
const desktop = new RuntimeClient(client.userDataPath, undefined, null, null)
const revealed = await desktop.call<{ reveal: RuntimeTerminalReveal }>('terminal.reveal', {
worktreeId: terminal.worktreeId,
tabId: terminal.tabId,
leafId: terminal.leafId,
executionHostId: toRuntimeExecutionHostId(environment.id)
})
return revealed.result.reveal.navigated
} catch {
// Why: no desktop here, one without terminal.reveal, a headless one, or a handle gone since.
return false
}
}
+2 -2
View File
@@ -46,10 +46,10 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
const loadWebSocketTransport = async () => await import('./websocket-transport.js')
export class RuntimeClient {
private readonly userDataPath: string
readonly userDataPath: string
private readonly requestTimeoutMs: number
private readonly remote: PairedRuntimeConnection | null
private readonly environmentSelector: string | null
readonly environmentSelector: string | null
private readonly cliExecutable: string
private readonly originalArgs: readonly string[] | undefined
private readonly remoteCompat: RemoteRuntimeCompatGate
+3
View File
@@ -239,6 +239,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
summary: 'Switch to a terminal tab in the UI',
usage: 'orca terminal switch [--terminal <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
notes: [
"With --environment, the desktop app on this machine switches to that server's terminal; a server has no window of its own."
],
examples: ['orca terminal switch --terminal term_abc123']
},
TERMINAL_CLOSE_COMMAND_SPEC,
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { RuntimeNotifier } from './runtime-notifier-contract'
import type { TerminalPaneRevealTarget } from '../../shared/terminal-pane-reveal'
const target: TerminalPaneRevealTarget = {
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: null,
executionHostId: 'runtime:env-1'
}
function notifierWith(revealTerminalPane: RuntimeNotifier['revealTerminalPane']): RuntimeNotifier {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: revealTerminalPane reads only this member of the notifier.
return { revealTerminalPane } as RuntimeNotifier
}
describe('OrcaRuntimeService.revealTerminalPane', () => {
it('refuses on a runtime with no window, such as orca serve', async () => {
const runtime = new OrcaRuntimeService()
await expect(runtime.revealTerminalPane(target)).rejects.toThrow('renderer_unavailable')
})
it("reports the window's answer for the pane it was asked to show", async () => {
const runtime = new OrcaRuntimeService()
const revealTerminalPane = vi.fn(async () => false)
runtime.setNotifier(notifierWith(revealTerminalPane))
await expect(runtime.revealTerminalPane(target)).resolves.toEqual({
...target,
navigated: false
})
expect(revealTerminalPane).toHaveBeenCalledWith(target)
revealTerminalPane.mockResolvedValueOnce(true)
await expect(runtime.revealTerminalPane(target)).resolves.toMatchObject({ navigated: true })
})
})
@@ -32,6 +32,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [
['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false],
['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false],
['terminal.focus', { terminal: 'term' }, false],
['terminal.reveal', { worktreeId: 'worktree', tabId: 'tab' }, false],
['terminal.close', { terminal: 'term' }, false],
['terminal.closeTab', { terminal: 'term' }, false],
['agentTeams.tmuxCompat', { teamId: 'team', token: 'token', envPane: 'pane', argv: [] }, false],
@@ -69,11 +70,11 @@ async function invoke(name: string, params: unknown, runtime: Partial<OrcaRuntim
describe('terminal RPC manifest characterization', () => {
it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => {
expect(TERMINAL_METHODS).toHaveLength(38)
expect(TERMINAL_METHODS).toHaveLength(39)
expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual(
METHOD_CASES.map(([name, _params, stream]) => [name, stream])
)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(38)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(39)
for (const [name, params] of METHOD_CASES) {
expect(() => schemaFor(name).parse(params), name).not.toThrow()
}
@@ -0,0 +1,91 @@
import '../unused-default-rpc-methods.test-fixture'
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { TerminalPaneRevealTarget } from '../../../../shared/terminal-pane-reveal'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { TERMINAL_METHODS } from './terminal'
function request(params: unknown): RpcRequest {
return { id: 'reveal-1', authToken: 'token', method: 'terminal.reveal', params }
}
function createDispatcher() {
const revealTerminalPane = vi.fn(async (target: TerminalPaneRevealTarget) => ({
...target,
navigated: true
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.reveal reads only these two runtime members.
const runtime = {
getRuntimeId: () => 'runtime-1',
revealTerminalPane
} as unknown as OrcaRuntimeService
return {
revealTerminalPane,
dispatcher: new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
}
}
describe('terminal.reveal', () => {
it('reveals a paired server pane by worktree, tab and leaf', async () => {
const { revealTerminalPane, dispatcher } = createDispatcher()
const response = await dispatcher.dispatch(
request({
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
executionHostId: 'runtime:env-1'
})
)
expect(revealTerminalPane).toHaveBeenCalledWith({
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
executionHostId: 'runtime:env-1'
})
expect(response).toMatchObject({ ok: true, result: { reveal: { navigated: true } } })
})
it('leaves the leaf and host to the window when the caller omits them', async () => {
const { revealTerminalPane, dispatcher } = createDispatcher()
await dispatcher.dispatch(request({ worktreeId: 'repo-1::/srv/app', tabId: 'tab-1' }))
expect(revealTerminalPane).toHaveBeenCalledWith({
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: null,
executionHostId: null
})
})
it.each(['runtime', 'mobile'] as const)(
'refuses a paired %s client without touching the window',
async (clientKind) => {
const { revealTerminalPane, dispatcher } = createDispatcher()
const response = await dispatcher.dispatch(
request({ worktreeId: 'repo-1::/srv/app', tabId: 'tab-1' }),
{ clientKind }
)
expect(response).toMatchObject({ ok: false, error: { code: 'forbidden' } })
expect(revealTerminalPane).not.toHaveBeenCalled()
}
)
it.each([
[{ tabId: 'tab-1' }],
[{ worktreeId: 'repo-1::/srv/app' }],
[{ worktreeId: 'repo-1::/srv/app', tabId: 'tab-1', executionHostId: 'nowhere' }]
])('refuses %o without touching the window', async (params) => {
const { revealTerminalPane, dispatcher } = createDispatcher()
const response = await dispatcher.dispatch(request(params))
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(revealTerminalPane).not.toHaveBeenCalled()
})
})
@@ -12,6 +12,7 @@ import {
TerminalCreateParams,
TerminalFocus,
TerminalHandle,
TerminalReveal,
TerminalSleep,
TerminalSplit,
TerminalStop,
@@ -165,6 +166,31 @@ export const TERMINAL_LIFECYCLE_METHODS = [
})
})
}),
defineMethod({
// Why a method of its own: terminal.focus resolves a handle on the host that runs the PTY, and a
// paired server has no window, so only the desktop showing its pane can reveal it (#24688).
name: 'terminal.reveal',
permission: 'workspace',
params: TerminalReveal,
handler: async (params, { runtime, clientKind }) => {
// Why local callers only: it moves this host's window, which terminal.focus lets a paired
// client do only by asking for host navigation, and a CLI or companion app here needs no more.
if (clientKind !== undefined) {
throw Object.assign(
new Error('terminal.reveal is available only to callers on this machine.'),
{ code: 'forbidden' }
)
}
return {
reveal: await runtime.revealTerminalPane({
worktreeId: params.worktreeId,
tabId: params.tabId,
leafId: params.leafId ?? null,
executionHostId: params.executionHostId ?? null
})
}
}
}),
defineMethod({
name: 'terminal.close',
permission: 'workspace',
@@ -8,6 +8,7 @@ import type {
} from '../../shared/agent-launch-tab-publication'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
import type { TerminalPaneRevealTarget } from '../../shared/terminal-pane-reveal'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
@@ -116,6 +117,8 @@ export type RuntimeNotifier = {
/** `recordInteraction: false` marks a title the host applied, not one the user typed. */
renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void
focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void
/** Raises the window onto a pane it shows; resolves false when the window does not know the worktree. */
revealTerminalPane?(target: TerminalPaneRevealTarget): Promise<boolean>
focusEditorTab?(tabId: string, worktreeId: string): void
closeSessionTab?(tabId: string, worktreeId: string): void | Promise<void>
moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void
@@ -1,5 +1,9 @@
import { OrcaRuntimeWithWaitForLeafPtyId } from '../view/renderer-terminal-snapshot'
import type { RuntimeTerminalFocus } from '../../../../shared/runtime-types'
import type {
RuntimeTerminalReveal,
TerminalPaneRevealTarget
} from '../../../../shared/terminal-pane-reveal'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { getLatestPtyTitle } from '../../runtime-worktree-status-projection'
import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution'
@@ -163,6 +167,14 @@ export abstract class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitFo
})
}
async revealTerminalPane(target: TerminalPaneRevealTarget): Promise<RuntimeTerminalReveal> {
const notifier = this.notifier
if (!notifier?.revealTerminalPane) {
throw new Error('renderer_unavailable')
}
return { ...target, navigated: await notifier.revealTerminalPane(target) }
}
protected getPtyIdsForExplicitTabClose(worktreeId: string, tabId: string): string[] {
const ptyIds = new Set<string>()
for (const pty of this.ptysById.values()) {
@@ -17,6 +17,7 @@ import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-rel
import { registerRendererDocumentNavigation } from './renderer-document-navigation'
import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender'
import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay'
import { revealTerminalPaneInWindow } from './terminal-pane-reveal-relay'
import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay'
let runtimeNotifierTokenCounter = 0
@@ -174,6 +175,7 @@ export function registerRuntimeWindowLifecycle(
send('ui:renameTerminal', { tabId, title, ...options }),
focusTerminal: (tabId, worktreeId, leafId) =>
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target),
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
closeSessionTab: (tabId, worktreeId) =>
requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId),
@@ -0,0 +1,123 @@
import { EventEmitter } from 'node:events'
import type { BrowserWindow } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
TerminalPaneRevealRequest,
TerminalPaneRevealTarget
} from '../../shared/terminal-pane-reveal'
import type { FocusExistingMainWindowOptions } from './focus-existing-window'
const ipcEmitter = new EventEmitter()
const mocks = vi.hoisted(() => ({
focusExistingMainWindow: vi.fn<(options: FocusExistingMainWindowOptions) => string>()
}))
const ipcMainMock = {
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
ipcEmitter.on(channel, listener)
}),
removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
ipcEmitter.removeListener(channel, listener)
})
}
vi.mock('electron', () => ({ app: {}, ipcMain: ipcMainMock }))
vi.mock('./focus-existing-window', () => ({
focusExistingMainWindow: mocks.focusExistingMainWindow
}))
const target: TerminalPaneRevealTarget = {
worktreeId: 'repo-1::/srv/app',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
executionHostId: 'runtime:env-1'
}
function createWindow(options: { rendererDestroyed?: boolean } = {}) {
const webContents = {
isDestroyed: () => options.rendererDestroyed === true,
send: vi.fn<(channel: string, request: TerminalPaneRevealRequest) => void>()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay reads only isDestroyed and these two webContents members.
const mainWindow = { isDestroyed: () => false, webContents } as unknown as BrowserWindow
const sentRequest = (): TerminalPaneRevealRequest => {
const [channel, request] = webContents.send.mock.calls[0] ?? []
expect(channel).toBe('ui:revealTerminalPane')
return request ?? { ...target, requestId: '' }
}
return { webContents, mainWindow, sentRequest }
}
async function loadRelay() {
return (await import('./terminal-pane-reveal-relay')).revealTerminalPaneInWindow
}
function reply(sender: unknown, requestId: string, revealed: boolean): void {
ipcEmitter.emit('ui:terminalPaneRevealReply', { sender }, { requestId, revealed })
}
describe('revealTerminalPaneInWindow', () => {
beforeEach(() => {
ipcEmitter.removeAllListeners()
mocks.focusExistingMainWindow.mockReset()
})
it('raises the window once the targeted renderer has revealed the pane', async () => {
const revealTerminalPaneInWindow = await loadRelay()
const { webContents, mainWindow, sentRequest } = createWindow()
const pending = revealTerminalPaneInWindow(mainWindow, target)
const { requestId } = sentRequest()
expect(sentRequest()).toEqual({ ...target, requestId })
// Why: request ids are readable by any renderer, so only the targeted window may answer.
reply({}, requestId, false)
reply(webContents, 'other-request', false)
reply(webContents, requestId, true)
await expect(pending).resolves.toBe(true)
expect(mocks.focusExistingMainWindow).toHaveBeenCalledTimes(1)
const focus = mocks.focusExistingMainWindow.mock.calls[0]?.[0]
expect(focus?.getWindow()).toBe(mainWindow)
expect(focus?.canOpenWindow?.()).toBe(false)
expect(ipcEmitter.listenerCount('ui:terminalPaneRevealReply')).toBe(0)
})
it('leaves the window where it is when the renderer does not know the workspace', async () => {
const revealTerminalPaneInWindow = await loadRelay()
const { webContents, mainWindow, sentRequest } = createWindow()
const pending = revealTerminalPaneInWindow(mainWindow, target)
reply(webContents, sentRequest().requestId, false)
await expect(pending).resolves.toBe(false)
expect(mocks.focusExistingMainWindow).not.toHaveBeenCalled()
})
it('fails instead of waiting when the window is gone', async () => {
const revealTerminalPaneInWindow = await loadRelay()
const { webContents, mainWindow } = createWindow({ rendererDestroyed: true })
await expect(revealTerminalPaneInWindow(mainWindow, target)).rejects.toThrow(
'renderer_unavailable'
)
expect(webContents.send).not.toHaveBeenCalled()
})
it('times out when the renderer never answers', async () => {
vi.useFakeTimers()
try {
const revealTerminalPaneInWindow = await loadRelay()
const { mainWindow } = createWindow()
const assertion = expect(revealTerminalPaneInWindow(mainWindow, target)).rejects.toThrow(
'terminal_reveal_timeout'
)
await vi.advanceTimersByTimeAsync(10_000)
await assertion
expect(ipcEmitter.listenerCount('ui:terminalPaneRevealReply')).toBe(0)
expect(mocks.focusExistingMainWindow).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto'
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealTarget
} from '../../shared/terminal-pane-reveal'
import { focusExistingMainWindow } from './focus-existing-window'
const TERMINAL_PANE_REVEAL_TIMEOUT_MS = 10_000
/** Shows a pane in the window, then raises the window; resolves false when it does not know the worktree. */
export async function revealTerminalPaneInWindow(
mainWindow: BrowserWindow,
target: TerminalPaneRevealTarget
): Promise<boolean> {
const revealed = await requestTerminalPaneRevealFromRenderer(mainWindow, target)
if (revealed) {
focusExistingMainWindow({
app,
getWindow: () => mainWindow,
// Why: a reveal moves the window that answered; it never opens a replacement.
canOpenWindow: () => false,
openWindow: () => mainWindow
})
}
return revealed
}
function requestTerminalPaneRevealFromRenderer(
mainWindow: BrowserWindow,
target: TerminalPaneRevealTarget
): Promise<boolean> {
if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
return Promise.reject(new Error('renderer_unavailable'))
}
const webContents = mainWindow.webContents
const requestId = randomUUID()
return new Promise<boolean>((resolve, reject) => {
const finish = (error: Error | null, revealed = false): void => {
clearTimeout(timeout)
ipcMain.removeListener('ui:terminalPaneRevealReply', onReply)
if (error) {
reject(error)
} else {
resolve(revealed)
}
}
const timeout = setTimeout(
() => finish(new Error('terminal_reveal_timeout')),
TERMINAL_PANE_REVEAL_TIMEOUT_MS
)
const onReply = (event: Electron.IpcMainEvent, reply: TerminalPaneRevealReply): void => {
// Why: request ids are visible to renderer code; only the targeted window may answer.
if (event.sender !== webContents || reply.requestId !== requestId) {
return
}
finish(null, reply.revealed === true)
}
ipcMain.on('ui:terminalPaneRevealReply', onReply)
webContents.send('ui:revealTerminalPane', { ...target, requestId })
})
}
@@ -8,6 +8,10 @@ import type {
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealRequest
} from '../../shared/terminal-pane-reveal'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
@@ -112,6 +116,15 @@ export const uiTerminalAndSessionTabsApi = {
replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply): void => {
ipcRenderer.send('agentLaunch:tabPublishReply', reply)
},
onRevealTerminalPane: (callback: (data: TerminalPaneRevealRequest) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: TerminalPaneRevealRequest) =>
callback(data)
ipcRenderer.on('ui:revealTerminalPane', listener)
return () => ipcRenderer.removeListener('ui:revealTerminalPane', listener)
},
replyTerminalPaneReveal: (reply: TerminalPaneRevealReply): void => {
ipcRenderer.send('ui:terminalPaneRevealReply', reply)
},
onAgentLaunchPaneVerdict: (
callback: (data: AgentLaunchPaneVerdictEvent) => void
): (() => void) => {
+6
View File
@@ -10,6 +10,10 @@ import type {
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealRequest
} from '../../shared/terminal-pane-reveal'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
import type {
@@ -202,6 +206,8 @@ export type UiCommandEventApi = {
onPublishAgentLaunchTab: (callback: (data: AgentLaunchTabPublishRequest) => void) => () => void
replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply) => void
onAgentLaunchPaneVerdict: (callback: (data: AgentLaunchPaneVerdictEvent) => void) => () => void
onRevealTerminalPane: (callback: (data: TerminalPaneRevealRequest) => void) => () => void
replyTerminalPaneReveal: (reply: TerminalPaneRevealReply) => void
onSplitTerminal: (
callback: (data: {
tabId: string
@@ -1,25 +1,12 @@
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation'
import { revealTerminalPane } from '@/lib/reveal-terminal-pane'
import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot'
/**
* Click-to-focus from either Agent Dashboard surface (pop-out relay or in-window drawer).
*
* Why the workspace dispatcher rather than a bare `setActiveWorktree`: only the shared
* sequence switches the view back to terminal, resumes sleeping agent sessions, and seeds a
* terminal surface. A parked SSH workspace has no resident tab until those run, so the bare
* call revealed a workspace with nothing in it (#16731).
*/
/** Click-to-focus from either Agent Dashboard surface (pop-out relay or in-window drawer). */
export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean {
const activated = activateAndRevealWorkspace(
args.worktreeId,
args.executionHostId ? { executionHostId: args.executionHostId } : undefined
)
if (activated === false) {
return false
}
activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true })
activateTerminalTabOnOwner(args.worktreeId, args.tabId, args.leafId)
return true
return revealTerminalPane({
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId,
executionHostId: args.executionHostId ?? null
})
}
@@ -51,6 +51,7 @@ export function buildWindowApi(args: {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -179,6 +179,7 @@ export async function useIpcEventsForCloseRouting({
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -99,6 +99,7 @@ export function buildTerminalCreateWindow(args: {
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate,
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onSplitTerminal: () => () => {},
onRenameTerminal: () => () => {},
@@ -27,6 +27,7 @@ import { registerTerminalPresentationIpcBridge } from './terminal-presentation-i
import { registerPtySourceDisownedIpcBridge } from './pty-source-disowned-ipc-bridge'
import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge'
import { registerAgentLaunchTabIpcBridge } from './agent-launch-tab-ipc-bridge'
import { registerTerminalPaneRevealIpcBridge } from './terminal-pane-reveal-ipc-bridge'
import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge'
import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge'
import { createWorktreeEventRuntime } from './worktree-event-runtime'
@@ -116,6 +117,7 @@ export function installAppLifetimeIpcEvents(): () => void {
registerAgentLaunchTabIpcBridge(unsubs)
registerPtySourceDisownedIpcBridge(unsubs)
registerTerminalUiRoutingIpcBridge(unsubs)
registerTerminalPaneRevealIpcBridge(unsubs)
registerSessionTabIpcBridge(unsubs)
registerMobileAndTerminalCloseIpcBridge(unsubs, backgroundWakeDispatcher.request)
registerUpdaterStatusIpcBridge(unsubs)
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
TerminalPaneRevealReply,
TerminalPaneRevealRequest
} from '../../../../shared/terminal-pane-reveal'
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
const mocks = vi.hoisted(() => ({
revealTerminalPane: vi.fn(),
getExplicitRuntimeEnvironmentIdForWorktree: vi.fn()
}))
vi.mock('@/lib/reveal-terminal-pane', () => ({
revealTerminalPane: mocks.revealTerminalPane
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExplicitRuntimeEnvironmentIdForWorktree: mocks.getExplicitRuntimeEnvironmentIdForWorktree
}))
vi.mock('../../store', () => ({ useAppStore: { getState: () => ({}) } }))
import { registerTerminalPaneRevealIpcBridge } from './terminal-pane-reveal-ipc-bridge'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const request: TerminalPaneRevealRequest = {
requestId: 'request-1',
worktreeId: 'repo-1::/srv/app',
tabId: 'host-tab-1',
leafId: LEAF_ID,
executionHostId: 'runtime:env-1'
}
function installBridge() {
let listener: ((data: TerminalPaneRevealRequest) => void) | undefined
const reply = vi.fn<(data: TerminalPaneRevealReply) => void>()
const cleanup = vi.fn()
vi.stubGlobal('window', {
api: {
ui: {
onRevealTerminalPane: (callback: (data: TerminalPaneRevealRequest) => void) => {
listener = callback
return cleanup
},
replyTerminalPaneReveal: reply
}
}
})
const unsubs: (() => void)[] = []
registerTerminalPaneRevealIpcBridge(unsubs)
return { deliver: (data: TerminalPaneRevealRequest) => listener?.(data), reply, cleanup, unsubs }
}
describe('registerTerminalPaneRevealIpcBridge', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.unstubAllGlobals()
mocks.revealTerminalPane.mockReturnValue(true)
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(null)
})
it("reveals a paired server's pane under the id this window mirrors its tab with", () => {
const { deliver, reply, cleanup, unsubs } = installBridge()
deliver(request)
expect(mocks.revealTerminalPane).toHaveBeenCalledWith({
worktreeId: request.worktreeId,
tabId: toWebTerminalSurfaceTabId('host-tab-1'),
leafId: LEAF_ID,
executionHostId: 'runtime:env-1'
})
expect(reply).toHaveBeenCalledWith({ requestId: 'request-1', revealed: true })
unsubs.forEach((unsubscribe) => unsubscribe())
expect(cleanup).toHaveBeenCalledOnce()
})
it('finds the owning server from the workspace when the caller names no host', () => {
const { deliver } = installBridge()
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue('env-1')
deliver({ ...request, executionHostId: null })
expect(mocks.revealTerminalPane).toHaveBeenCalledWith(
expect.objectContaining({ tabId: toWebTerminalSurfaceTabId('host-tab-1') })
)
})
it.each<[string, Partial<TerminalPaneRevealRequest>]>([
['a local workspace', { executionHostId: null }],
['an SSH workspace this app runs', { executionHostId: 'ssh:box' }],
['a tab already named as this window mirrors it', { tabId: 'web-terminal-host-tab-1' }]
])('keeps the tab id of %s', (_case, overrides) => {
const { deliver } = installBridge()
deliver({ ...request, ...overrides })
expect(mocks.revealTerminalPane).toHaveBeenCalledWith(
expect.objectContaining({ tabId: overrides.tabId ?? 'host-tab-1' })
)
})
it('answers false when this window does not know the workspace', () => {
const { deliver, reply } = installBridge()
mocks.revealTerminalPane.mockReturnValue(false)
deliver(request)
expect(reply).toHaveBeenCalledWith({ requestId: 'request-1', revealed: false })
})
it('still answers when the reveal throws, so the caller is not left waiting', () => {
const { deliver, reply } = installBridge()
mocks.revealTerminalPane.mockImplementation(() => {
throw new Error('store unavailable')
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => deliver(request)).not.toThrow()
expect(reply).toHaveBeenCalledWith({ requestId: 'request-1', revealed: false })
expect(consoleError).toHaveBeenCalledWith('Failed to reveal terminal pane:', expect.any(Error))
consoleError.mockRestore()
})
})
@@ -0,0 +1,41 @@
import { revealTerminalPane } from '@/lib/reveal-terminal-pane'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { useAppStore } from '../../store'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { TerminalPaneRevealTarget } from '../../../../shared/terminal-pane-reveal'
import {
isWebTerminalSurfaceTabId,
toWebTerminalSurfaceTabId
} from '../../../../shared/terminal-surface-id'
/**
* External callers name a tab as its owning host does. This window mirrors a paired server's
* terminal tab under a prefixed id, with the same leaf ids, so map the tab before revealing it.
*/
function toWindowTabId(target: TerminalPaneRevealTarget): string {
const host = parseExecutionHostId(target.executionHostId)
const environmentId = host
? host.kind === 'runtime'
? host.environmentId
: null
: getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), target.worktreeId)
return environmentId && !isWebTerminalSurfaceTabId(target.tabId)
? toWebTerminalSurfaceTabId(target.tabId)
: target.tabId
}
export function registerTerminalPaneRevealIpcBridge(unsubs: (() => void)[]): void {
unsubs.push(
window.api.ui.onRevealTerminalPane(({ requestId, ...target }) => {
let revealed = false
try {
revealed = revealTerminalPane({ ...target, tabId: toWindowTabId(target) })
} catch (error) {
console.error('Failed to reveal terminal pane:', error)
} finally {
// Why: main holds the caller's RPC open until this reply, so a throw must still answer.
window.api.ui.replyTerminalPaneReveal({ requestId, revealed })
}
})
)
}
@@ -164,6 +164,7 @@ describe('useIpcEvents browser tab create routing', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -143,6 +143,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -411,6 +412,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -140,6 +140,7 @@ describe('useIpcEvents browser tab close routing', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -368,6 +369,7 @@ describe('useIpcEvents browser tab close routing', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -591,6 +593,7 @@ describe('useIpcEvents browser tab close routing', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -148,6 +148,7 @@ describe('useIpcEvents updater integration', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -115,6 +115,7 @@ describe('useIpcEvents updater integration', () => {
onCreateTerminal: () => () => {},
onRequestTerminalCreate: () => () => {},
onPublishAgentLaunchTab: () => () => {},
onRevealTerminalPane: () => () => {},
onAgentLaunchPaneVerdict: () => () => {},
onRequestTerminalTabMount: () => () => {},
replyTerminalCreate: () => {},
@@ -0,0 +1,26 @@
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation'
import type { TerminalPaneRevealTarget } from '../../../shared/terminal-pane-reveal'
/**
* Shows a terminal pane on whichever host owns it: a local, SSH or paired-server workspace, or the
* floating panel. Returns false when this window does not know the workspace.
*
* Why the workspace dispatcher rather than a bare `setActiveWorktree`: only the shared
* sequence switches the view back to terminal, resumes sleeping agent sessions, and seeds a
* terminal surface. A parked SSH workspace has no resident tab until those run, so the bare
* call revealed a workspace with nothing in it (#16731).
*/
export function revealTerminalPane(target: TerminalPaneRevealTarget): boolean {
const activated = activateAndRevealWorkspace(
target.worktreeId,
target.executionHostId ? { executionHostId: target.executionHostId } : undefined
)
if (activated === false) {
return false
}
activateTabAndFocusPane(target.tabId, target.leafId, { flashFocusedPane: true })
activateTerminalTabOnOwner(target.worktreeId, target.tabId, target.leafId)
return true
}
@@ -263,6 +263,8 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onPublishAgentLaunchTab: () => noopUnsubscribe,
onAgentLaunchPaneVerdict: () => noopUnsubscribe,
replyAgentLaunchTabPublish: () => {},
onRevealTerminalPane: () => noopUnsubscribe,
replyTerminalPaneReveal: () => {},
onSplitTerminal: () => noopUnsubscribe,
onRenameTerminal: () => noopUnsubscribe,
onFocusTerminal: () => noopUnsubscribe,
+2
View File
@@ -569,6 +569,7 @@ import {
TerminalRename,
TerminalResolveActive,
TerminalResolvePane,
TerminalReveal,
TerminalSend,
TerminalSetViewerColors,
TerminalSplit,
@@ -1288,6 +1289,7 @@ export const RPC_PARAMS_BY_METHOD = {
'terminal.resolveIdentity': TerminalHandle,
'terminal.resolvePane': TerminalResolvePane,
'terminal.restoreFit': TerminalHandle,
'terminal.reveal': TerminalReveal,
'terminal.send': TerminalSend,
'terminal.setAutoRestoreFit': TerminalSetAutoRestoreFit,
'terminal.setDisplayMode': TerminalSetDisplayMode,
@@ -9,6 +9,7 @@ import {
listSupportedWindowsShellOverrides
} from '../windows-terminal-shell'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry'
import { OptionalExecutionHostId } from './worktree-params'
export const TerminalHandle = z.object({
terminal: requiredString('Missing terminal handle'),
@@ -30,6 +31,14 @@ export const TerminalFocus = TerminalHandle.extend({
navigation: z.enum(['caller', 'host']).optional()
})
/** `terminal.reveal` takes no handle: a pane a paired server owns has none on this host. */
export const TerminalReveal = z.object({
worktreeId: requiredString('Missing worktree id').pipe(z.string().max(1024)),
tabId: requiredString('Missing tab id').pipe(z.string().max(128)),
leafId: requiredString('Missing leaf id').pipe(z.string().max(128)).optional(),
executionHostId: OptionalExecutionHostId
})
/**
* `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the
* caller's answer decides something once, which is what licenses the host to pay for a process-table
+20
View File
@@ -0,0 +1,20 @@
import type { ExecutionHostId } from './execution-host'
/**
* A terminal pane named by worktree, tab and leaf rather than by handle. A pane on a paired server
* has the same ids in this window, but its handle exists only on that server.
*/
export type TerminalPaneRevealTarget = {
worktreeId: string
tabId: string
leafId: string | null
/** The host that owns the worktree, for when the same id is known on more than one host. */
executionHostId: ExecutionHostId | null
}
export type TerminalPaneRevealRequest = TerminalPaneRevealTarget & { requestId: string }
export type TerminalPaneRevealReply = { requestId: string; revealed: boolean }
/** `navigated` is false when this window does not know the worktree. */
export type RuntimeTerminalReveal = TerminalPaneRevealTarget & { navigated: boolean }
@@ -0,0 +1,162 @@
/**
* `orca terminal switch --environment <server>` run on a desktop's machine must move that desktop
* to the server's terminal (#24688). The server is a windowless `orca serve`, so before this its
* answer was `navigated: false` and no window moved.
*
* Run:
* npx playwright test tests/e2e/paired-terminal-switch-desktop-reveal.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=1
*/
import type { Page } from '@stablyai/playwright-test'
import type { RuntimeTerminalFocus } from '../../src/shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { orcaCliResult } from './helpers/compiled-orca-cli'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedElectronClient } from './helpers/paired-electron-client'
import { expect, test } from './helpers/orca-app'
type HostTerminal = { handle: string; tabId: string; leafId: string }
async function readClientSelection(page: Page, worktreeId: string) {
return page.evaluate((id) => {
const state = window.__store?.getState()
const tabId = state?.activeTabIdByWorktree?.[id] ?? null
return {
activeView: state?.activeView ?? null,
activeWorktreeId: state?.activeWorktreeId ?? null,
tabId,
leafId: tabId ? (state?.terminalLayoutsByTabId?.[tabId]?.activeLeafId ?? null) : null
}
}, worktreeId)
}
test('terminal switch on a paired server reveals the terminal in the desktop on this machine', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
const host = await launchHeadlessPairedRuntimeHost()
const client = await launchPairedElectronClient(
host.offer,
testInfo,
'terminal-switch-desktop-reveal'
).catch(async (error) => {
await host.dispose()
throw error
})
try {
const added = await host.client.call<{ repo: { id: string } }>('repo.add', {
path: testRepoPath,
kind: 'git'
})
let worktreeId = ''
await expect
.poll(
async () => {
const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', {
repo: `id:${added.result.repo.id}`
})
worktreeId = listed.result.worktrees[0]?.id ?? ''
return worktreeId
},
{ timeout: 30_000, message: 'Serve host never listed a worktree for the seeded repo' }
)
.not.toBe('')
const createHostTerminal = async (title: string): Promise<HostTerminal> => {
const created = await host.client.call<{ terminal: { handle: string; tabId?: string } }>(
'terminal.create',
{
worktree: `id:${worktreeId}`,
title,
focus: false,
activate: false,
presentation: 'background'
}
)
const shown = await host.client.call<{ terminal: { leafId: string } }>('terminal.show', {
terminal: created.result.terminal.handle
})
const { handle, tabId } = created.result.terminal
if (!tabId) {
throw new Error('Serve host did not report a tab id for the created terminal')
}
return { handle, tabId, leafId: shown.result.terminal.leafId }
}
const first = await createHostTerminal('build')
const target = await createHostTerminal('agent waiting')
// The desktop mirrors a server's tab under a prefixed id; the leaf keeps the server's id.
const mirroredTargetTabId = toWebTerminalSurfaceTabId(target.tabId)
await expect
.poll(
() =>
client.page.evaluate(
({ id, tabIds }) => {
const tabs = window.__store?.getState().tabsByWorktree[id] ?? []
return tabIds.every((tabId) => tabs.some((tab) => tab.id === tabId))
},
{
id: worktreeId,
tabIds: [toWebTerminalSurfaceTabId(first.tabId), mirroredTargetTabId]
}
),
{ timeout: 60_000, message: 'Paired desktop never mirrored the serve-host terminals' }
)
.toBe(true)
// The user is looking at the other terminal when the switch arrives.
const firstMirroredTabId = toWebTerminalSurfaceTabId(first.tabId)
await client.page.evaluate(
({ id, tabId }) => {
const state = window.__store?.getState()
state?.setActiveWorktree(id)
state?.setActiveTab(tabId)
},
{ id: worktreeId, tabId: firstMirroredTabId }
)
await expect
.poll(async () => (await readClientSelection(client.page, worktreeId)).tabId)
.toBe(firstMirroredTabId)
await client.page.screenshot({ path: testInfo.outputPath('before-terminal-switch.png') })
const switched = await orcaCliResult<{ focus: RuntimeTerminalFocus }>(client.userDataDir, [
'terminal',
'switch',
'--environment',
client.environmentId,
'--terminal',
target.handle
])
try {
expect(switched.focus).toMatchObject({ handle: target.handle, navigated: true })
await expect
.poll(() => readClientSelection(client.page, worktreeId), {
timeout: 30_000,
message: 'The desktop never moved to the serve-host terminal'
})
.toEqual({
activeView: 'terminal',
activeWorktreeId: worktreeId,
tabId: mirroredTargetTabId,
leafId: target.leafId
})
await expect(
client.page.locator(
`[data-terminal-tab-id=${JSON.stringify(mirroredTargetTabId)}] .pane[data-leaf-id=${JSON.stringify(target.leafId)}] .xterm`
)
).toBeVisible({ timeout: 30_000 })
await expect(
client.page.locator(
`[data-terminal-tab-id=${JSON.stringify(firstMirroredTabId)}] .xterm:visible`
)
).toHaveCount(0)
} finally {
// Why: taken either way, so a run without the fix records the window that did not move.
await client.page.screenshot({ path: testInfo.outputPath('after-terminal-switch.png') })
}
} finally {
await client.dispose()
await host.dispose()
}
})