Fix remote runtime terminal sharing with mobile (#4901)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-08 17:26:07 -04:00 committed by GitHub
parent 90ac74d9b9
commit f5efdd5945
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1133 additions and 119 deletions

View File

@ -115,6 +115,7 @@ import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
terminalRecordsEqual,
type TerminalRecord
} from '../../../../src/session/mobile-terminal-records'
@ -130,6 +131,7 @@ import {
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
import type { DiffComment } from '../../../../../src/shared/types'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
type Terminal = TerminalRecord
@ -144,6 +146,7 @@ type MobileSessionTab =
leafId?: string
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@ -242,59 +245,6 @@ type DirtyMarkdownDraft = {
content: string
}
function mobileSessionTabsEqual(a: MobileSessionTab[], b: MobileSessionTab[]): boolean {
return a.length === b.length && a.every((tab, index) => mobileSessionTabEqual(tab, b[index]))
}
function mobileSessionTabEqual(a: MobileSessionTab, b: MobileSessionTab | undefined): boolean {
if (
!b ||
a.type !== b.type ||
a.id !== b.id ||
a.title !== b.title ||
a.isActive !== b.isActive
) {
return false
}
switch (a.type) {
case 'terminal':
return (
b.type === 'terminal' &&
a.parentTabId === b.parentTabId &&
a.leafId === b.leafId &&
a.status === b.status &&
a.terminal === b.terminal &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
case 'markdown':
return (
b.type === 'markdown' &&
a.filePath === b.filePath &&
a.relativePath === b.relativePath &&
a.isDirty === b.isDirty &&
a.documentVersion === b.documentVersion
)
case 'file':
return (
b.type === 'file' &&
a.filePath === b.filePath &&
a.relativePath === b.relativePath &&
a.language === b.language &&
a.isDirty === b.isDirty
)
case 'browser':
return (
b.type === 'browser' &&
a.browserWorkspaceId === b.browserWorkspaceId &&
a.browserPageId === b.browserPageId &&
a.url === b.url &&
a.loading === b.loading &&
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
}
}
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
terminalHandle: string | null

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
mobileSessionTabsEqual,
type MobileTerminalSessionTab,
type TerminalRecord
} from './mobile-terminal-records'
@ -73,4 +74,43 @@ describe('mobile terminal records', () => {
])
).toEqual([])
})
it('treats terminal agent-status changes as session-tab changes', () => {
const base: MobileTerminalSessionTab = {
type: 'terminal',
id: 'term-1::leaf-1',
parentTabId: 'term-1',
leafId: 'leaf-1',
title: 'Claude',
status: 'ready',
terminal: 'pty-1',
isActive: true,
agentStatus: {
state: 'working',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
paneKey: 'term-1:leaf-1',
terminalHandle: 'pty-1',
stateHistory: []
}
}
expect(
mobileSessionTabsEqual(
[base],
[
{
...base,
agentStatus: {
...base.agentStatus!,
state: 'blocked',
updatedAt: 2,
stateStartedAt: 2
}
}
]
)
).toBe(false)
})
})

View File

@ -1,4 +1,5 @@
import type { MobileTerminalTheme } from '../terminal/TerminalWebView'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
export type TerminalRecord = {
handle: string
@ -15,6 +16,7 @@ export type MobileTerminalSessionTab = {
leafId?: string
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@ -22,12 +24,97 @@ export type MobileTerminalSessionTab = {
type MobileSessionTabLike =
| MobileTerminalSessionTab
| {
type: string
type: 'markdown'
id: string
title?: string
terminal?: unknown
terminalTheme?: MobileTerminalTheme
filePath?: string
relativePath?: string
isDirty?: boolean
documentVersion?: string
isActive?: boolean
}
| {
type: 'file'
id: string
title?: string
filePath?: string
relativePath?: string
language?: string
isDirty?: boolean
isActive?: boolean
}
| {
type: 'browser'
id: string
title?: string
browserWorkspaceId?: string
browserPageId?: string | null
url?: string
loading?: boolean
canGoBack?: boolean
canGoForward?: boolean
isActive?: boolean
}
export function mobileSessionTabsEqual(
a: readonly MobileSessionTabLike[],
b: readonly MobileSessionTabLike[]
): boolean {
return a.length === b.length && a.every((tab, index) => mobileSessionTabEqual(tab, b[index]))
}
function mobileSessionTabEqual(
a: MobileSessionTabLike,
b: MobileSessionTabLike | undefined
): boolean {
if (
!b ||
a.type !== b.type ||
a.id !== b.id ||
a.title !== b.title ||
a.isActive !== b.isActive
) {
return false
}
switch (a.type) {
case 'terminal':
return (
b.type === 'terminal' &&
a.parentTabId === b.parentTabId &&
a.leafId === b.leafId &&
a.status === b.status &&
a.terminal === b.terminal &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
case 'markdown':
return (
b.type === 'markdown' &&
a.filePath === b.filePath &&
a.relativePath === b.relativePath &&
a.isDirty === b.isDirty &&
a.documentVersion === b.documentVersion
)
case 'file':
return (
b.type === 'file' &&
a.filePath === b.filePath &&
a.relativePath === b.relativePath &&
a.language === b.language &&
a.isDirty === b.isDirty
)
case 'browser':
return (
b.type === 'browser' &&
a.browserWorkspaceId === b.browserWorkspaceId &&
a.browserPageId === b.browserPageId &&
a.url === b.url &&
a.loading === b.loading &&
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
}
}
export function mergeTerminalRecordsByCurrentOrder(
terminalTabs: TerminalRecord[],

View File

@ -5,6 +5,7 @@ import type { TuiAgent } from '../../../src/shared/types'
// mirrored with src/shared/tui-agent-selection.ts and assert parity in tests.
export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'claude',
'claude-agent-teams',
'openclaude',
'codex',
'grok',
@ -38,6 +39,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
claude: 'Claude',
'claude-agent-teams': 'Claude Agent Teams',
openclaude: 'OpenClaude',
codex: 'Codex',
grok: 'Grok',
@ -70,6 +72,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
}
export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>> = {
'claude-agent-teams': 'anthropic.com',
openclaude: 'openclaude.gitlawb.com',
grok: 'x.ai',
copilot: 'github.com',
@ -100,6 +103,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record<TuiAgent, string> = {
claude: 'claude',
'claude-agent-teams': 'orca claude-teams',
openclaude: 'openclaude',
codex: 'codex',
grok: 'grok',

View File

@ -39,6 +39,7 @@ import {
OrcaRuntimeService,
type RuntimeTerminalAgentStatusEvent
} from './orca-runtime'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@ -5056,12 +5057,14 @@ describe('OrcaRuntimeService', () => {
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
await expect(runtime.renameTerminal(handle, 'Worker')).resolves.toMatchObject({
const renamed = await runtime.renameTerminal(handle, 'Worker')
expect(renamed).toMatchObject({
handle,
tabId: 'pty:pty-bg',
title: 'Worker'
})
expect(renamed.tabId).not.toContain(':')
await expect(runtime.showTerminal(handle)).resolves.toMatchObject({
tabId: renamed.tabId,
title: 'Worker'
})
})
@ -7190,6 +7193,343 @@ describe('OrcaRuntimeService', () => {
])
})
it('publishes laptop-created remote runtime terminals to phone session tabs', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-empty',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
command: "claude 'work on the issue'",
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
runtime.onPtyData('laptop-created-pty', '\x1b]0;Codex working\x07', Date.now())
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', Date.now())
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(laptopTerminal.surface).toBe('background')
expect(phoneTabs.tabs).toEqual([
expect.objectContaining({
type: 'terminal',
parentTabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID,
status: 'ready',
terminal: laptopTerminal.handle,
agentStatus: expect.objectContaining({
state: 'working',
paneKey: `laptop-tab:${HEADLESS_LEAF_ID}`,
terminalHandle: laptopTerminal.handle
})
})
])
await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({
tail: ['Claude is working...']
})
})
it('replaces pending phone session tabs when a laptop-created remote PTY becomes live', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer-pending',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `laptop-tab::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabGroups: [{ id: 'group-1', activeTabId: 'laptop-tab', tabOrder: ['laptop-tab'] }],
tabs: [
{
type: 'terminal',
id: `laptop-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Starting Claude',
isActive: true
}
]
}
]
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(phoneTabs.tabs).toHaveLength(1)
expect(phoneTabs.tabs[0]).toMatchObject({
type: 'terminal',
id: `laptop-tab::${HEADLESS_LEAF_ID}`,
status: 'ready',
terminal: laptopTerminal.handle
})
})
it('publishes laptop-created remote runtime split terminals to phone session tabs', async () => {
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'laptop-created-pty' })
.mockResolvedValueOnce({ id: 'laptop-split-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
const split = await runtime.splitTerminal(laptopTerminal.handle, {
direction: 'vertical'
})
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const terminalTabs = phoneTabs.tabs.filter((tab) => tab.type === 'terminal')
expect(split.tabId).toBe('laptop-tab')
expect(terminalTabs).toHaveLength(2)
expect(terminalTabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
parentTabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID,
status: 'ready',
terminal: laptopTerminal.handle
}),
expect.objectContaining({
parentTabId: 'laptop-tab',
status: 'ready',
terminal: split.handle
})
])
)
})
it('pushes PTY-backed mobile session tab title and agent status changes to subscribers', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123)
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude waiting for permission\x07', 124)
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
title: 'Claude working',
agentStatus: expect.objectContaining({
state: 'working',
terminalHandle: laptopTerminal.handle
})
})
]
}),
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
agentStatus: expect.objectContaining({
state: 'blocked',
terminalHandle: laptopTerminal.handle
})
})
]
})
])
expect(events[1]!.snapshotVersion).toBeGreaterThan(events[0]!.snapshotVersion)
unsubscribe()
})
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const events: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
runtime.onPtyExit('laptop-created-pty', 0)
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
parentTabId: 'laptop-tab',
status: 'pending-handle',
terminal: null
})
]
})
])
await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({
status: 'exited'
})
})
it('operates PTY-backed mobile session terminals without a renderer graph', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill,
getForegroundProcess: async () => null
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
await expect(runtime.renameTerminal(laptopTerminal.handle, 'Shared Claude')).resolves.toEqual({
handle: laptopTerminal.handle,
tabId: 'laptop-tab',
title: 'Shared Claude'
})
await expect(runtime.focusTerminal(laptopTerminal.handle)).resolves.toEqual({
handle: laptopTerminal.handle,
tabId: 'laptop-tab',
worktreeId: TEST_WORKTREE_ID
})
await expect(runtime.closeTerminal(laptopTerminal.handle)).resolves.toEqual({
handle: laptopTerminal.handle,
tabId: 'laptop-tab',
ptyKilled: true
})
expect(kill).toHaveBeenCalledWith('laptop-created-pty')
})
it('lists PTY-backed mobile session terminals without a renderer graph', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID
})
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07hello\r\n', 123)
await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({
terminals: [
expect.objectContaining({
handle: laptopTerminal.handle,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude working',
connected: true,
preview: 'hello'
})
],
totalCount: 1,
truncated: false
})
})
it('shows and resolves active PTY-backed mobile session terminals without a renderer graph', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID,
activate: true
})
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07hello\r\n', 123)
await expect(runtime.resolveActiveTerminal(`id:${TEST_WORKTREE_ID}`)).resolves.toBe(
laptopTerminal.handle
)
await expect(runtime.showTerminal(laptopTerminal.handle)).resolves.toMatchObject({
handle: laptopTerminal.handle,
tabId: 'laptop-tab',
leafId: HEADLESS_LEAF_ID,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude working',
connected: true,
ptyId: 'laptop-created-pty'
})
})
it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.syncWindowGraph(0, {

View File

@ -10,7 +10,8 @@ import type { AgentStatus } from '../../shared/agent-detection'
import {
AGENT_STATUS_STALE_AFTER_MS,
type ParsedAgentStatusPayload,
type AgentStatusOrchestrationContext
type AgentStatusOrchestrationContext,
type AgentStatusEntry
} from '../../shared/agent-status-types'
import {
createAgentStatusOscProcessor,
@ -2322,6 +2323,96 @@ export class OrcaRuntimeService {
return nextGroups
}
private publishPtyBackedMobileSessionTerminal(
worktreeId: string,
pty: RuntimePtyWorktreeRecord,
args: { tabId: string; leafId: string; title: string | null; activate: boolean }
): void {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const title = args.title ?? pty.title ?? pty.lastOscTitle ?? 'Terminal'
const existingTab = existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
candidate.parentTabId === args.tabId &&
candidate.leafId === args.leafId
)
const parentLayout = this.buildMaterializedHeadlessParentLayout(
args.leafId,
pty.ptyId,
existingTab?.parentLayout
)
const tab: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: `${args.tabId}::${args.leafId}`,
parentTabId: args.tabId,
leafId: args.leafId,
ptyId: pty.ptyId,
title,
parentLayout,
isActive: args.activate || existing?.activeTabId == null
}
const existingTabs = (existing?.tabs ?? []).filter(
(candidate) =>
!(
candidate.type === 'terminal' &&
candidate.parentTabId === args.tabId &&
candidate.leafId === args.leafId
)
)
const tabs = this.mergeMobileSessionSnapshotTabs(
existingTabs.map((candidate) => ({
...candidate,
isActive: tab.isActive ? false : candidate.isActive
})),
[tab]
)
const activeTab =
(tab.isActive ? tab : tabs.find((candidate) => candidate.id === existing?.activeTabId)) ??
tabs.find((candidate) => candidate.isActive) ??
tabs[0] ??
null
const terminalTabs = tabs.filter(
(candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
)
const next: RuntimeMobileSessionTabsSnapshot = {
worktree: worktreeId,
publicationEpoch:
existing?.publicationEpoch ?? `headless:pty-backed:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId),
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
tabGroups: this.mergeMobileSessionTabGroups(
worktreeId,
existing?.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
),
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.notifyMobileSessionTabsChanged(worktreeId)
}
private touchMobileSessionSnapshotsForPty(ptyId: string): void {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
const hasPtyBackedTab = snapshot.tabs.some(
(tab) =>
tab.type === 'terminal' &&
(tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId)
)
if (!hasPtyBackedTab) {
continue
}
this.mobileSessionTabsByWorktree.set(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
this.notifyMobileSessionTabsChanged(worktreeId)
}
}
private buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[]
@ -3201,6 +3292,7 @@ export class OrcaRuntimeService {
return normalizedData
}
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
let shouldTouchPtyBackedSessionTabs = false
const ptyTailBefore = pty
? {
lines: pty.tailBuffer,
@ -3227,8 +3319,11 @@ export class OrcaRuntimeService {
pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine)
if (oscTitle !== null) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
pty.lastOscTitle = oscTitle
pty.lastAgentStatus = agentStatus
shouldTouchPtyBackedSessionTabs =
prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
@ -3308,6 +3403,9 @@ export class OrcaRuntimeService {
}
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
if (shouldTouchPtyBackedSessionTabs) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
const listeners = this.dataListeners.get(ptyId)
if (listeners) {
@ -4519,6 +4617,7 @@ export class OrcaRuntimeService {
pty.lastExitCode = exitCode
this.resolvePtyExitWaiters(pty, ptyId)
this.pruneDisconnectedPtyTranscript(pty)
this.touchMobileSessionSnapshotsForPty(ptyId)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
@ -5641,13 +5740,15 @@ export class OrcaRuntimeService {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const graphEpoch = this.captureReadyGraphEpoch()
const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null
const targetWorktreeId = worktreeSelector
? (getExplicitWorktreeIdSelector(worktreeSelector) ??
(await this.resolveWorktreeSelector(worktreeSelector)).id)
: null
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
const resolvedWorktrees = [...worktreesById.values()]
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
@ -5661,17 +5762,19 @@ export class OrcaRuntimeService {
const terminals: RuntimeTerminalSummary[] = []
const ptyIdsFromLeaves = new Set<string>()
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
if (graphEpoch !== null) {
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
}
if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) {
continue
}
if (leaf.ptyId) {
ptyIdsFromLeaves.add(leaf.ptyId)
}
terminals.push(this.buildTerminalSummary(leaf, worktreesById))
}
if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) {
continue
}
if (leaf.ptyId) {
ptyIdsFromLeaves.add(leaf.ptyId)
}
terminals.push(this.buildTerminalSummary(leaf, worktreesById))
}
// Why: worktree.ps can classify active worktrees from PTY records even when
@ -5697,6 +5800,32 @@ export class OrcaRuntimeService {
// Why: when --terminal is omitted, the CLI auto-resolves to the active
// terminal in the current worktree — matching browser's implicit active tab.
async resolveActiveTerminal(worktreeSelector?: string): Promise<string> {
if (this.graphStatus !== 'ready') {
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
const snapshots = targetWorktreeId
? [this.getMobileSessionTabsForWorktree(targetWorktreeId)]
: await this.listAllMobileSessionTabs()
for (const snapshot of snapshots) {
const activeTerminal = snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' &&
tab.isActive &&
tab.status === 'ready' &&
typeof tab.terminal === 'string'
)
if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) {
return activeTerminal.terminal
}
}
const listed = await this.listTerminals(worktreeSelector)
const first = listed.terminals[0]?.handle
if (first) {
return first
}
throw new Error('no_active_terminal')
}
this.assertGraphReady()
const targetWorktreeId = worktreeSelector
@ -5730,18 +5859,21 @@ export class OrcaRuntimeService {
}
async showTerminal(handle: string): Promise<RuntimeTerminalShow> {
const graphEpoch = this.captureReadyGraphEpoch()
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const worktreesById = await this.getResolvedWorktreeMap()
return {
...this.buildPtyTerminalSummary(pty.pty, worktreesById),
tabId: pty.pty.tabId ?? pty.record.tabId,
leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId,
paneRuntimeId: -1,
ptyId: pty.pty.ptyId,
rendererGraphEpoch: this.rendererGraphEpoch
}
}
const graphEpoch = this.captureReadyGraphEpoch()
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
const { leaf } = this.getLiveLeafForHandle(handle)
const summary = this.buildTerminalSummary(leaf, worktreesById)
return {
@ -10769,18 +10901,19 @@ export class OrcaRuntimeService {
}
async renameTerminal(handle: string, title: string | null): Promise<RuntimeTerminalRename> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
if (pty) {
pty.pty.title = title
this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId)
for (const leaf of this.leaves.values()) {
if (leaf.ptyId === pty.pty.ptyId) {
this.notifier?.renameTerminal(leaf.tabId, title)
return { handle, tabId: leaf.tabId, title }
}
}
return { handle, tabId: pty.record.tabId, title }
return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title }
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
this.notifier?.renameTerminal(leaf.tabId, title)
return { handle, tabId: leaf.tabId, title }
@ -10889,6 +11022,14 @@ export class OrcaRuntimeService {
pty.paneKey = paneKey
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, pty, {
tabId,
leafId,
title: opts.title ?? null,
activate: opts.activate === true
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
if (this.notifier?.revealTerminalSession) {
try {
@ -11357,7 +11498,6 @@ export class OrcaRuntimeService {
}
async focusTerminal(handle: string): Promise<RuntimeTerminalFocus> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
@ -11376,19 +11516,20 @@ export class OrcaRuntimeService {
worktreeId: pty.pty.worktreeId
}
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId)
return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId }
}
async closeTerminal(handle: string): Promise<RuntimeTerminalClose> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
if (pty) {
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
return { handle, tabId: pty.record.tabId, ptyKilled }
return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, ptyKilled }
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
let ptyKilled = false
if (leaf.ptyId) {
@ -11513,6 +11654,14 @@ export class OrcaRuntimeService {
this.ptyController.kill?.(result.id)
throw error
}
if (createdPty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, createdPty, {
tabId: parentTabId,
leafId,
title: null,
activate: opts.activate !== false
})
}
return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 }
}
@ -12959,10 +13108,12 @@ export class OrcaRuntimeService {
id: tab.id,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title,
title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.lastOscTitle ?? pty?.title ?? tab.title,
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
...(tab.agentStatus ? { agentStatus: tab.agentStatus } : {}),
...(tab.agentStatus
? { agentStatus: tab.agentStatus }
: this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
isActive: tab.isActive,
...(terminalHandle
@ -13009,6 +13160,36 @@ export class OrcaRuntimeService {
}
}
private buildPtyMobileAgentStatus(
pty: RuntimePtyWorktreeRecord | null,
tab: RuntimeMobileSessionTerminalTab,
terminalHandle: string | null
): { agentStatus: AgentStatusEntry } | Record<string, never> {
if (!pty?.lastAgentStatus) {
return {}
}
const now = pty.lastOutputAt ?? Date.now()
return {
agentStatus: {
state:
pty.lastAgentStatus === 'working'
? 'working'
: pty.lastAgentStatus === 'permission'
? 'blocked'
: 'done',
prompt: '',
updatedAt: now,
stateStartedAt: now,
paneKey: this.getMobileTerminalPaneKey(tab),
...(terminalHandle ? { terminalHandle } : {}),
worktreeId: pty.worktreeId,
tabId: tab.parentTabId,
terminalTitle: pty.lastOscTitle ?? pty.title ?? tab.title,
stateHistory: []
}
}
}
private findPtyForMobileTerminalTab(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab,

View File

@ -145,7 +145,12 @@ function waitForWsClose(ws: WebSocket): Promise<void> {
})
}
async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
type AuthenticatedMobileWs = {
ws: WebSocket
sharedKey: Uint8Array
}
async function authenticateMobileWsSession(pairingUrl: string): Promise<AuthenticatedMobileWs> {
const parsed = parsePairingCode(pairingUrl)
expect(parsed).toBeTruthy()
const ws = await connectWs(parsed!.endpoint)
@ -168,7 +173,83 @@ async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
type: 'e2ee_authenticated'
})
return ws
return { ws, sharedKey }
}
async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
return (await authenticateMobileWsSession(pairingUrl)).ws
}
function sendEncryptedWsRequest(
session: AuthenticatedMobileWs,
request: Record<string, unknown>
): void {
session.ws.send(encrypt(JSON.stringify(request), session.sharedKey))
}
function createEncryptedWsResponseReader(session: AuthenticatedMobileWs): {
next: (
id: string,
predicate?: (response: Record<string, unknown>) => boolean
) => Promise<Record<string, unknown>>
dispose: () => void
} {
type Waiter = {
id: string
predicate: (response: Record<string, unknown>) => boolean
resolve: (response: Record<string, unknown>) => void
}
const queue: Record<string, unknown>[] = []
const waiters: Waiter[] = []
const takeQueued = (
id: string,
predicate: (response: Record<string, unknown>) => boolean
): Record<string, unknown> | null => {
const index = queue.findIndex((response) => response.id === id && predicate(response))
if (index === -1) {
return null
}
const [response] = queue.splice(index, 1)
return response ?? null
}
const onMessage = (data: WebSocket.RawData): void => {
const decrypted = decrypt(
typeof data === 'string' ? data : data.toString('utf-8'),
session.sharedKey
)
expect(decrypted).toBeTruthy()
const response = JSON.parse(decrypted!) as Record<string, unknown>
const waiterIndex = waiters.findIndex(
(waiter) => response.id === waiter.id && waiter.predicate(response)
)
if (waiterIndex === -1) {
queue.push(response)
return
}
const [waiter] = waiters.splice(waiterIndex, 1)
waiter?.resolve(response)
}
session.ws.on('message', onMessage)
return {
next: (id: string, predicate: (response: Record<string, unknown>) => boolean = () => true) => {
const queued = takeQueued(id, predicate)
if (queued) {
return Promise.resolve(queued)
}
return new Promise<Record<string, unknown>>((resolve) => {
waiters.push({ id, predicate, resolve })
})
},
dispose: () => {
session.ws.off('message', onMessage)
waiters.length = 0
queue.length = 0
}
}
}
class FakeWebSocket extends EventEmitter {
@ -2149,6 +2230,248 @@ describe('OrcaRuntimeRpcServer', () => {
await server.stop()
})
it('mirrors laptop-created remote runtime terminals into phone session tabs over RPC', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore() as never)
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)
const endpoint = metadata!.transports[0]!.endpoint
const authToken = metadata!.authToken
const leafId = '11111111-1111-4111-8111-111111111111'
const createResponse = await sendRequest(endpoint, {
id: 'laptop_create',
authToken,
method: 'terminal.create',
params: {
worktree: 'id:repo-1::/tmp/worktree-a',
command: "claude 'work on the issue'",
tabId: 'laptop-tab',
leafId
}
})
expect(createResponse).toMatchObject({
id: 'laptop_create',
ok: true,
result: {
terminal: {
worktreeId: 'repo-1::/tmp/worktree-a',
surface: 'background'
}
}
})
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456)
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456)
const listResponse = await sendRequest(endpoint, {
id: 'phone_list',
authToken,
method: 'session.tabs.list',
params: {
worktree: 'id:repo-1::/tmp/worktree-a'
}
})
const terminal = (
createResponse.result as {
terminal: { handle: string }
}
).terminal
expect(listResponse).toMatchObject({
id: 'phone_list',
ok: true,
result: {
tabs: [
{
type: 'terminal',
id: `laptop-tab::${leafId}`,
parentTabId: 'laptop-tab',
leafId,
status: 'ready',
terminal: terminal.handle,
agentStatus: {
state: 'working',
paneKey: `laptop-tab:${leafId}`,
terminalHandle: terminal.handle
}
}
]
}
})
const readResponse = await sendRequest(endpoint, {
id: 'phone_read',
authToken,
method: 'terminal.read',
params: {
terminal: terminal.handle
}
})
expect(readResponse).toMatchObject({
id: 'phone_read',
ok: true,
result: {
terminal: {
tail: ['Claude is working...']
}
}
})
await server.stop()
})
it('streams laptop-created runtime terminals to a paired phone WebSocket client', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const writes: string[] = []
const runtime = new OrcaRuntimeService(makeStore() as never)
const spawn = vi.fn().mockResolvedValue({ id: 'paired-laptop-pty' })
runtime.setPtyController({
spawn,
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
const phoneOffer = server.createPairingOffer({
address: '127.0.0.1',
name: 'phone',
scope: 'mobile'
})
expect(phoneOffer.available).toBe(true)
if (!phoneOffer.available) {
throw new Error('WebSocket pairing unavailable')
}
const phone = await authenticateMobileWsSession(phoneOffer.pairingUrl)
const phoneResponses = createEncryptedWsResponseReader(phone)
const metadata = readRuntimeMetadata(userDataPath)
const laptopEndpoint = metadata!.transports[0]!.endpoint
const laptopAuthToken = metadata!.authToken
const worktree = 'id:repo-1::/tmp/worktree-a'
const leafId = '11111111-1111-4111-8111-111111111111'
try {
sendEncryptedWsRequest(phone, {
id: 'phone_subscribe_tabs',
method: 'session.tabs.subscribe',
params: { worktree }
})
await expect(
phoneResponses.next('phone_subscribe_tabs', (response) => {
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
return result?.type === 'snapshot' && result.tabs?.length === 0
})
).resolves.toMatchObject({
ok: true,
streaming: true
})
const blockedUpdate = phoneResponses.next('phone_subscribe_tabs', (response) => {
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
const tab = result?.tabs?.[0] as { agentStatus?: { state?: string } } | undefined
return result?.type === 'updated' && tab?.agentStatus?.state === 'blocked'
})
const createResponse = await sendRequest(laptopEndpoint, {
id: 'laptop_create',
authToken: laptopAuthToken,
method: 'terminal.create',
params: {
worktree,
command: "claude 'work on the issue'",
tabId: 'laptop-tab',
leafId,
activate: true
}
})
const terminal = (
createResponse.result as {
terminal: { handle: string }
}
).terminal
runtime.onPtyData('paired-laptop-pty', '\x1b]0;Claude waiting for permission\x07', 456)
runtime.onPtyData('paired-laptop-pty', 'Need approval\r\n', 457)
await expect(blockedUpdate).resolves.toMatchObject({
ok: true,
streaming: true,
result: {
type: 'updated',
tabs: [
{
type: 'terminal',
id: `laptop-tab::${leafId}`,
parentTabId: 'laptop-tab',
leafId,
status: 'ready',
terminal: terminal.handle,
agentStatus: {
state: 'blocked',
paneKey: `laptop-tab:${leafId}`,
terminalHandle: terminal.handle
}
}
]
}
})
sendEncryptedWsRequest(phone, {
id: 'phone_read',
method: 'terminal.read',
params: { terminal: terminal.handle }
})
await expect(phoneResponses.next('phone_read')).resolves.toMatchObject({
ok: true,
result: {
terminal: {
tail: ['Need approval']
}
}
})
sendEncryptedWsRequest(phone, {
id: 'phone_send',
method: 'terminal.send',
params: {
terminal: terminal.handle,
text: 'approved'
}
})
await expect(phoneResponses.next('phone_send')).resolves.toMatchObject({
ok: true,
result: {
send: {
accepted: true
}
}
})
expect(writes).toEqual(['approved'])
} finally {
phoneResponses.dispose()
phone.ws.close()
await server.stop()
}
})
it('serves worktree.ps from the runtime summary builder', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)

View File

@ -1107,7 +1107,7 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 }))
})
it('forwards input and cleanup through runtime RPC', async () => {
it('forwards input over the stream and disconnects without closing shared remote sessions', async () => {
vi.useFakeTimers()
try {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
@ -1135,12 +1135,11 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.disconnect()
expect(unsubscribeFn).toHaveBeenCalled()
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.close',
params: { terminal: 'term-remote' },
timeoutMs: 15_000
})
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.close'
})
)
} finally {
vi.useRealTimers()
}

View File

@ -236,6 +236,27 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('detaches laptop-created remote runtime terminals without closing the server session', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
runtimeCall.mockClear()
transport.destroy?.()
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.close'
})
)
})
it('retires stale host-owned terminal handles without surfacing pane errors', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()

View File

@ -65,9 +65,6 @@ export function createRemoteRuntimePtyTransport(
let destroyed = false
let handle: string | null = null
let remotePtyId: string | null = null
// Why: web session mirrors attach to host-owned handles; only terminals this
// transport created should be closed by this transport's teardown path.
let ownsRemoteTerminal = false
let currentRuntimeEnvironmentId = runtimeEnvironmentId
let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null
let desiredViewport: { cols: number; rows: number } | null = null
@ -169,7 +166,6 @@ export function createRemoteRuntimePtyTransport(
}
handle = hostHandle
ownsRemoteTerminal = false
remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId)
connected = true
desiredViewport = {
@ -386,8 +382,9 @@ export function createRemoteRuntimePtyTransport(
...(activate === true ? { activate: true } : {})
})
handle = created.terminal.handle
ownsRemoteTerminal = true
if (destroyed) {
// Why: this is a cancelled launch, not a connected shared session.
// Close the server PTY so rapid tab-open/tab-close does not leak.
await closeRemoteTerminal(created.terminal.handle)
return
}
@ -427,7 +424,6 @@ export function createRemoteRuntimePtyTransport(
return
}
remotePtyId = options.existingPtyId
ownsRemoteTerminal = false
connected = true
desiredViewport = {
cols: options.cols ?? 80,
@ -449,12 +445,8 @@ export function createRemoteRuntimePtyTransport(
const id = remotePtyId
multiplexedStream?.close()
multiplexedStream = null
if (ownsRemoteTerminal) {
void closeRemoteTerminal()
}
handle = null
remotePtyId = null
ownsRemoteTerminal = false
storedCallbacks.onDisconnect?.()
if (id) {
onPtyExit?.(id)
@ -468,7 +460,6 @@ export function createRemoteRuntimePtyTransport(
connected = false
multiplexedStream?.close()
multiplexedStream = null
ownsRemoteTerminal = false
storedCallbacks = {}
},

View File

@ -8,6 +8,7 @@ import {
consumePendingWebRuntimeSplitMirrorTelemetry,
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive,
moveWebRuntimeSessionTab,
splitWebRuntimeTerminal
} from './web-runtime-session'
@ -872,8 +873,18 @@ describe('splitWebRuntimeTerminal', () => {
expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled()
})
it('ignores local panes and inactive web sessions', () => {
const runtimeCall = vi.fn()
it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'split',
ok: true,
result: {
split: {
handle: 'terminal-2',
tabId: 'tab-1',
ptyId: 'pty-2'
}
}
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
@ -885,10 +896,10 @@ describe('splitWebRuntimeTerminal', () => {
expect(splitWebRuntimeTerminal('pty-local-1', 'horizontal', 'keyboard')).toBe(false)
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'horizontal', 'keyboard')).toBe(
false
true
)
expect(runtimeCall).not.toHaveBeenCalled()
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
})
})
@ -935,8 +946,18 @@ describe('closeWebRuntimeTerminal', () => {
})
})
it('ignores local panes and inactive web sessions', () => {
const runtimeCall = vi.fn()
it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'close',
ok: true,
result: {
close: {
handle: 'terminal-1',
tabId: 'tab-1',
ptyKilled: true
}
}
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
@ -947,8 +968,16 @@ describe('closeWebRuntimeTerminal', () => {
expect(closeWebRuntimeTerminal('pty-local-1')).toBe(false)
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(false)
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true)
expect(runtimeCall).not.toHaveBeenCalled()
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
})
it('treats any configured remote runtime environment as a shared session', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
expect(isWebRuntimeSessionActive('env-1')).toBe(true)
expect(isWebRuntimeSessionActive(' ')).toBe(false)
expect(isWebRuntimeSessionActive(null)).toBe(false)
})
})

View File

@ -29,10 +29,9 @@ export {
export function isWebRuntimeSessionActive(
activeRuntimeEnvironmentId: string | null | undefined
): boolean {
return (
Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) &&
Boolean(activeRuntimeEnvironmentId?.trim())
)
// Why: headless serve sessions are owned by the remote runtime, regardless
// of whether the attaching client is web or desktop Electron.
return Boolean(activeRuntimeEnvironmentId?.trim())
}
const pendingWebRuntimeSplitMirrorTelemetry = new Map<string, Set<string>>()

View File

@ -17,6 +17,7 @@ import {
shouldApplyWebSessionTabsSnapshot,
shouldBootstrapInitialWebRuntimeTerminal,
shouldRespawnWebRuntimeTerminalAfterWake,
shouldSyncRuntimeSessionTabs,
type WebSessionTabsSyncState
} from './web-session-tabs-sync'
@ -191,6 +192,31 @@ describe('applyWebSessionTabsSnapshot', () => {
).toBe(true)
})
it('syncs session tabs for desktop remote runtime clients, not only web clients', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true
})
).toBe(true)
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
activeWorktreeId: WT,
workspaceSessionReady: true,
requireActiveWorktree: true
})
).toBe(true)
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: null,
workspaceSessionReady: true
})
).toBe(false)
})
it('clears web session tracking maps when the host removes a worktree snapshot', () => {
const workspace: BrowserWorkspace = {
id: 'local-browser-workspace',

View File

@ -123,10 +123,6 @@ export type WebSessionTabsSyncState = Pick<
| 'sortEpoch'
>
function isWebClient(): boolean {
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}
function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult {
return (
Boolean(value) &&
@ -228,6 +224,19 @@ export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
return hostTerminalTabCount === 0
}
export function shouldSyncRuntimeSessionTabs(args: {
activeRuntimeEnvironmentId: string | null | undefined
activeWorktreeId?: string | null
workspaceSessionReady: boolean
requireActiveWorktree?: boolean
}): boolean {
const environmentId = args.activeRuntimeEnvironmentId?.trim()
if (!environmentId || !args.workspaceSessionReady) {
return false
}
return args.requireActiveWorktree === true ? Boolean(args.activeWorktreeId) : true
}
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
latestSessionTabsSnapshotByWorktree.clear()
lastHostTerminalTabCountByWorktree.clear()
@ -2142,7 +2151,13 @@ export function useWebSessionTabsSync(): void {
const environmentId = activeRuntimeEnvironmentId?.trim()
// Why: startup hydration writes browser-local session state; applying the
// host snapshot before that point gets clobbered and leaves the sidebar stale.
if (!isWebClient() || !environmentId || !workspaceSessionReady) {
if (
!shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId,
workspaceSessionReady
}) ||
!environmentId
) {
return
}
@ -2251,7 +2266,16 @@ export function useWebSessionTabsSync(): void {
useEffect(() => {
const environmentId = activeRuntimeEnvironmentId?.trim()
if (!isWebClient() || !activeWorktreeId || !environmentId || !workspaceSessionReady) {
if (
!shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId,
activeWorktreeId,
workspaceSessionReady,
requireActiveWorktree: true
}) ||
!environmentId ||
!activeWorktreeId
) {
return
}