diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts
index ca46e0b8f4a..ea677cebe63 100644
--- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts
+++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts
@@ -15,4 +15,11 @@ describe('terminalPreviewUnavailableMessage', () => {
expect(fromPtyId).not.toMatch(/pane has closed/)
expect(terminalPreviewUnavailableMessage({ hostKind: 'ssh' })).toBe(fromPtyId)
})
+
+ it('reports a paired-server card as remote instead of claiming its pane closed (#25688)', () => {
+ const remote = terminalPreviewUnavailableMessage({ hostKind: 'ssh' })
+ expect(terminalPreviewUnavailableMessage({ hostKind: 'remote' })).toBe(remote)
+ expect(terminalPreviewUnavailableMessage({ ptyId: 'remote:env-1@@term_1' })).toBe(remote)
+ expect(terminalPreviewUnavailableMessage({ ptyId: 'remote:term_1' })).toBe(remote)
+ })
})
diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts
index 07080084bfc..08718f9ed06 100644
--- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts
+++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts
@@ -1,12 +1,14 @@
import { translate } from '@/i18n/i18n'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
+import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
/**
* A missing buffer snapshot only proves the pane exited when the client could have
* observed it. `SshPtyProvider` reports no authoritative buffer snapshot and the relay
* exposes no snapshot RPC, so for a remote pty the absence is loss of contact —
- * `unverifiable`, never `exited`. See docs/reference/ssh-execution-boundary.md.
+ * `unverifiable`, never `exited`. A paired server's pane is likewise held by that server, not by
+ * this client. See docs/reference/ssh-execution-boundary.md.
*/
export function terminalPreviewUnavailableMessage(source: {
ptyId?: string | null
@@ -14,6 +16,8 @@ export function terminalPreviewUnavailableMessage(source: {
}): string {
const isRemote =
source.hostKind === 'ssh' ||
+ source.hostKind === 'remote' ||
+ isRemoteRuntimePtyId(source.ptyId) ||
(typeof source.ptyId === 'string' && parseAppSshPtyId(source.ptyId) !== null)
return isRemote
? translate(
diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts
new file mode 100644
index 00000000000..f1a8c8c8378
--- /dev/null
+++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ activateAndRevealWorkspace: vi.fn(),
+ activateTabAndFocusPane: vi.fn(),
+ activateTerminalTabOnOwner: vi.fn()
+}))
+
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
+}))
+vi.mock('@/lib/activate-tab-and-focus-pane', () => ({
+ activateTabAndFocusPane: mocks.activateTabAndFocusPane
+}))
+vi.mock('@/lib/terminal-tab-owner-activation', () => ({
+ activateTerminalTabOnOwner: mocks.activateTerminalTabOnOwner
+}))
+
+import { revealDashboardAgent } from './reveal-dashboard-agent'
+
+const args = {
+ repoId: 'repo-1',
+ worktreeId: 'wt-1',
+ executionHostId: 'runtime:env-1' as const,
+ tabId: 'web-terminal-host-tab',
+ leafId: '11111111-1111-4111-8111-111111111111'
+}
+
+describe('revealDashboardAgent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("focuses the card's own tab on the workspace's owner", () => {
+ mocks.activateAndRevealWorkspace.mockReturnValue(true)
+
+ expect(revealDashboardAgent(args)).toBe(true)
+
+ expect(mocks.activateTerminalTabOnOwner).toHaveBeenCalledWith('wt-1', args.tabId, args.leafId)
+ expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith(args.tabId, args.leafId, {
+ flashFocusedPane: true
+ })
+ // Why: the owner call captures the visible tab, so it must follow the local activation.
+ expect(mocks.activateTabAndFocusPane.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.activateTerminalTabOnOwner.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('touches no tab when the workspace cannot be revealed', () => {
+ mocks.activateAndRevealWorkspace.mockReturnValue(false)
+
+ expect(revealDashboardAgent(args)).toBe(false)
+
+ expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled()
+ expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts
index 9da364c72ef..9113c9c97f5 100644
--- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts
+++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts
@@ -1,5 +1,6 @@
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 { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot'
/**
@@ -19,5 +20,6 @@ export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean {
return false
}
activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true })
+ activateTerminalTabOnOwner(args.worktreeId, args.tabId, args.leafId)
return true
}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx
index 3631f315220..37e003efab6 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx
@@ -98,6 +98,10 @@ const activationMocks = vi.hoisted(() => ({
activateTabAndFocusPane: vi.fn()
}))
+const ownerActivationMocks = vi.hoisted(() => ({
+ activateTerminalTabOnOwner: vi.fn()
+}))
+
const staleAgentRowMocks = vi.hoisted(() => ({
dismissStaleAgentRowByKey: vi.fn()
}))
@@ -123,6 +127,10 @@ vi.mock('@/lib/activate-tab-and-focus-pane', () => ({
activateTabAndFocusPane: activationMocks.activateTabAndFocusPane
}))
+vi.mock('@/lib/terminal-tab-owner-activation', () => ({
+ activateTerminalTabOnOwner: ownerActivationMocks.activateTerminalTabOnOwner
+}))
+
vi.mock('../terminal-pane/stale-agent-row', () => ({
dismissStaleAgentRowByKey: staleAgentRowMocks.dismissStaleAgentRowByKey
}))
@@ -203,6 +211,54 @@ describe('WorktreeCardAgents activation', () => {
expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled()
})
+ it('tells the owning server which tab a row opens (#12739, #22196)', async () => {
+ mockAgentActivityDisplayMode = 'full'
+ const tabId = 'web-terminal-host-tab'
+ const paneKey = makePaneKey(tabId, LEAF_A)
+ mockAgents = [
+ mockAgent({ paneKey, tabId, agentType: 'codex', prompt: 'Remote', worktreeId: 'wt-1' })
+ ]
+ mockTabsByWorktree = { 'wt-1': [{ id: tabId }] }
+ mockAgentStatusByPaneKey = { [paneKey]: { worktreeId: 'wt-1' } }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ renderToStaticMarkup()
+ capturedRowActivations[0].onActivate(tabId, paneKey)
+
+ expect(ownerActivationMocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(
+ 'wt-1',
+ tabId,
+ LEAF_A
+ )
+ expect(activationMocks.activateTabAndFocusPane).toHaveBeenCalledWith(
+ tabId,
+ LEAF_A,
+ expect.anything()
+ )
+ })
+
+ it('asks the owner to focus a live row whose tab has not reached this window yet', async () => {
+ mockAgentActivityDisplayMode = 'full'
+ const tabId = 'web-terminal-unhydrated'
+ const paneKey = makePaneKey(tabId, LEAF_A)
+ mockAgents = [
+ mockAgent({ paneKey, tabId, agentType: 'codex', prompt: 'Remote', worktreeId: 'wt-1' })
+ ]
+ mockAgentStatusByPaneKey = { [paneKey]: { worktreeId: 'wt-1' } }
+ const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
+
+ renderToStaticMarkup()
+ capturedRowActivations[0].onActivate(tabId, paneKey)
+
+ expect(ownerActivationMocks.activateTerminalTabOnOwner).toHaveBeenCalledWith(
+ 'wt-1',
+ tabId,
+ LEAF_A
+ )
+ expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled()
+ expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled()
+ })
+
it('reveals the worktree and focuses an automation worker row hydrated during reveal', async () => {
mockAgentActivityDisplayMode = 'full'
const tabId = 'worker-tab'
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 391c45127cd..5191572a75b 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -3,6 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
+import { activateTerminalTabOnOwner } from '@/lib/terminal-tab-owner-activation'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
import { useNow } from '@/hooks/use-now'
import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets'
@@ -178,10 +179,12 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
+ activateTerminalTabOnOwner(worktreeId, tabId, parsed.leafId)
} else if (!activateStructuredAgentSessionTab({ worktreeId, tabId })) {
const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey]
if (liveEntry?.worktreeId === worktreeId) {
// Why: orchestration worker status can be worktree-attributed before the renderer knows its tab; keep the live row instead of dismissing as stale.
+ activateTerminalTabOnOwner(worktreeId, tabId, parsed.leafId)
return
}
dismissStaleAgentRowByKey(paneKey)
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts
index bdba5ca6c28..f533f91e053 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
+import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -58,4 +59,45 @@ describe('copyTerminalHandleForPane', () => {
expect(writeClipboardText).not.toHaveBeenCalled()
})
+
+ it('copies the owning server handle for a paired-server pane without asking this runtime', async () => {
+ // Why: this desktop's runtime has no record of a server-owned pane (#11664, #15553).
+ const callRuntime = vi.fn().mockResolvedValue({
+ id: 'req-1',
+ ok: false,
+ error: { code: 'terminal_not_found', message: 'terminal not found' }
+ })
+ const writeClipboardText = vi.fn().mockResolvedValue(undefined)
+
+ await expect(
+ copyTerminalHandleForPane({
+ tabId: 'tab-1',
+ leafId: LEAF_ID,
+ ptyId: toRemoteRuntimePtyId('term_on_server', 'env-1'),
+ callRuntime,
+ writeClipboardText
+ })
+ ).resolves.toBe('term_on_server')
+
+ expect(callRuntime).not.toHaveBeenCalled()
+ expect(writeClipboardText).toHaveBeenCalledWith('term_on_server')
+ })
+
+ it('refuses an unreadable paired-server pty instead of asking this runtime', async () => {
+ const callRuntime = vi.fn()
+ const writeClipboardText = vi.fn()
+
+ await expect(
+ copyTerminalHandleForPane({
+ tabId: 'tab-1',
+ leafId: LEAF_ID,
+ ptyId: 'remote:',
+ callRuntime,
+ writeClipboardText
+ })
+ ).rejects.toThrow('Terminal ID unavailable')
+
+ expect(callRuntime).not.toHaveBeenCalled()
+ expect(writeClipboardText).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts
index aaf79719bd2..76513397fe1 100644
--- a/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-handle-copy.ts
@@ -1,9 +1,15 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import { makePaneKey } from '../../../../shared/stable-pane-id'
+import {
+ isRemoteRuntimePtyId,
+ parseRemoteRuntimePtyId
+} from '../../../../shared/remote-runtime-pty-id'
type CopyTerminalHandleDeps = {
tabId: string
leafId: string
+ /** The pane's bound PTY; a paired-server PTY id already names the owner's handle. */
+ ptyId?: string | null
callRuntime: (request: {
method: 'terminal.resolvePane'
params: { paneKey: string }
@@ -14,9 +20,20 @@ type CopyTerminalHandleDeps = {
export async function copyTerminalHandleForPane({
tabId,
leafId,
+ ptyId,
callRuntime,
writeClipboardText
}: CopyTerminalHandleDeps): Promise {
+ // Why: this desktop's runtime does not own a paired server's pane; the owner's handle is what
+ // its CLI addresses, and the PTY binding already carries it.
+ if (ptyId && isRemoteRuntimePtyId(ptyId)) {
+ const ownerHandle = parseRemoteRuntimePtyId(ptyId)?.handle
+ if (!ownerHandle) {
+ throw new Error('Terminal ID unavailable')
+ }
+ await writeClipboardText(ownerHandle)
+ return ownerHandle
+ }
const paneKey = makePaneKey(tabId, leafId)
const response = await callRuntime({
method: 'terminal.resolvePane',
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts
index d8f5d8f0733..1b30b2156ee 100644
--- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts
@@ -55,7 +55,8 @@ export const copyTerminalPaneMenuPaneId = async (
export const copyTerminalPaneMenuTerminalId = async (
pane: ManagedPane | null,
- tabId: string
+ tabId: string,
+ ptyId: string | null
): Promise => {
if (!pane) {
return
@@ -64,6 +65,7 @@ export const copyTerminalPaneMenuTerminalId = async (
await copyTerminalHandleForPane({
tabId,
leafId: pane.leafId,
+ ptyId,
callRuntime: window.api.runtime.call,
writeClipboardText: window.api.ui.writeTerminalClipboardText
})
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
index 463a3a7f415..623dbba05b1 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
@@ -174,8 +174,11 @@ export function useTerminalPaneContextMenu({
const onCopyPaneId = async (): Promise =>
copyTerminalPaneMenuPaneId(resolveMenuPane(), tabId)
- const onCopyTerminalId = async (): Promise =>
- copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId)
+ const onCopyTerminalId = async (): Promise => {
+ const pane = resolveMenuPane()
+ const ptyId = pane ? (paneTransportsRef.current?.get(pane.id)?.getPtyId() ?? null) : null
+ return copyTerminalPaneMenuTerminalId(pane, tabId, ptyId)
+ }
const onCopyAgentSessionId = async (): Promise => {
const pane = resolveMenuPane()
diff --git a/src/renderer/src/lib/terminal-tab-owner-activation.test.ts b/src/renderer/src/lib/terminal-tab-owner-activation.test.ts
new file mode 100644
index 00000000000..2dca85c1496
--- /dev/null
+++ b/src/renderer/src/lib/terminal-tab-owner-activation.test.ts
@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ activateWebRuntimeSessionTab: vi.fn(),
+ getRuntimeEnvironmentIdForWorktree: vi.fn(),
+ resolveWebSessionVisibleTabId: vi.fn()
+}))
+
+vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree
+}))
+vi.mock('@/runtime/web-runtime-session', () => ({
+ activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab,
+ isWebRuntimeSessionActive: (environmentId: string | null | undefined) =>
+ Boolean(environmentId?.trim())
+}))
+
+vi.mock('@/runtime/web-session-focus-intent', () => ({
+ resolveWebSessionVisibleTabId: mocks.resolveWebSessionVisibleTabId
+}))
+
+import { activateTerminalTabOnOwner } from './terminal-tab-owner-activation'
+
+describe('activateTerminalTabOnOwner', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('tells the paired server that owns the workspace, not the focused one', () => {
+ mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('env-owner')
+ mocks.resolveWebSessionVisibleTabId.mockReturnValue('unified-visible')
+
+ activateTerminalTabOnOwner('wt-1', 'web-terminal-host-tab', 'leaf-b')
+
+ expect(mocks.getRuntimeEnvironmentIdForWorktree).toHaveBeenCalledWith({}, 'wt-1')
+ expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith({
+ worktreeId: 'wt-1',
+ tabId: 'web-terminal-host-tab',
+ environmentId: 'env-owner',
+ leafId: 'leaf-b',
+ // Why: a later navigation away from this tab must cancel the deferred focus.
+ expectedCurrentLocalTabId: 'unified-visible'
+ })
+ })
+
+ it('makes no owner round trip for a workspace this client runs', () => {
+ mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null)
+
+ activateTerminalTabOnOwner('wt-local', 'tab-1', null)
+
+ expect(mocks.activateWebRuntimeSessionTab).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/lib/terminal-tab-owner-activation.ts b/src/renderer/src/lib/terminal-tab-owner-activation.ts
new file mode 100644
index 00000000000..3682aca1bcb
--- /dev/null
+++ b/src/renderer/src/lib/terminal-tab-owner-activation.ts
@@ -0,0 +1,33 @@
+import { useAppStore } from '@/store'
+import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
+import {
+ activateWebRuntimeSessionTab,
+ isWebRuntimeSessionActive
+} from '@/runtime/web-runtime-session'
+import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent'
+
+/**
+ * Tells the paired server that owns `worktreeId` which tab and pane this client revealed, as a
+ * tab-strip click does: the owner stores it as this client's selection and wakes a parked pane,
+ * and a tab that has not reached this renderer yet is focused when it arrives.
+ * Call after the local activation; navigating away before the tab arrives cancels that focus.
+ * A workspace this client runs itself (local or SSH) needs no owner round trip.
+ */
+export function activateTerminalTabOnOwner(
+ worktreeId: string,
+ tabId: string,
+ leafId: string | null
+): void {
+ const state = useAppStore.getState()
+ const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
+ if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
+ return
+ }
+ void activateWebRuntimeSessionTab({
+ worktreeId,
+ tabId,
+ environmentId,
+ leafId,
+ expectedCurrentLocalTabId: resolveWebSessionVisibleTabId(state, worktreeId)
+ })
+}
diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts
index da2ca9224e3..8051cd07a54 100644
--- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts
+++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts
@@ -182,6 +182,31 @@ describe('web runtime session tab actions', () => {
expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalled()
})
+ it('carries the picked split pane and the visible tab guard to the owner and focus intent', async () => {
+ mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue('host-terminal')
+ const runtimeCall = vi.fn().mockResolvedValue({ id: 'activate', ok: true, result: {} })
+ vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
+
+ await activateWebRuntimeSessionTab({
+ worktreeId: WORKTREE_ID,
+ tabId: 'local-terminal',
+ leafId: 'leaf-b',
+ expectedCurrentLocalTabId: 'unified-local-terminal'
+ })
+
+ expect(runtimeCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'session.tabs.activate',
+ params: expect.objectContaining({ tabId: 'host-terminal', leafId: 'leaf-b' })
+ })
+ )
+ expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({
+ hostTabId: 'host-terminal',
+ leafId: 'leaf-b',
+ expectedCurrentLocalTabId: 'unified-local-terminal'
+ })
+ })
+
it('supersedes browser focus intent when a terminal is activated next', async () => {
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
diff --git a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts
index 0fbb18fb534..059a53346e4 100644
--- a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts
+++ b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts
@@ -28,6 +28,10 @@ export async function activateWebRuntimeSessionTab(args: {
worktreeId: string
tabId: string
environmentId?: string | null
+ /** The split pane the user picked; without it the host and focus intent pick the first. */
+ leafId?: string | null
+ /** The local tab visible at the gesture; later navigation away cancels the deferred focus. */
+ expectedCurrentLocalTabId?: string | null
}): Promise {
return (await callWebRuntimeSessionTabMethod('session.tabs.activate', args)) === 'applied'
}
@@ -60,6 +64,8 @@ async function callWebRuntimeSessionTabMethod(
reason?: RuntimeSessionTabCloseReason
publicationEpoch?: string | null
terminalHandle?: string | null
+ leafId?: string | null
+ expectedCurrentLocalTabId?: string | null
}
): Promise {
const environmentId =
@@ -110,7 +116,13 @@ async function callWebRuntimeSessionTabMethod(
recordWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId, Date.now())
} else {
activationHostTabId = hostTabId
- recordWebSessionFocusIntent(intentOwner, args.worktreeId, hostTabId)
+ recordWebSessionFocusIntent(
+ intentOwner,
+ args.worktreeId,
+ hostTabId,
+ args.leafId ?? undefined,
+ args.expectedCurrentLocalTabId
+ )
}
const response = await callEnvironment({
// Why: old hosts cannot route this additive method, so a generation
@@ -121,6 +133,7 @@ async function callWebRuntimeSessionTabMethod(
tabId: hostTabId,
...(method === 'session.tabs.activate'
? {
+ ...(args.leafId ? { leafId: args.leafId } : {}),
// Why: the additive navigation target protects new hosts while notifyClients:false protects old hosts.
notifyClients: false,
navigation: 'caller' as const,