mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
Velm14
Claude Opus 5.5
Neil
parent
7cf0c2a5e2
commit
0fac887dbc
@@ -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 } } })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user