diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c2cc96381..d715f699b 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 7570b6968..4ebf74766 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -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) + }) }) diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 688bef87d..0f97af252 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -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[], diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 36608042e..79108a8e8 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -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 = { 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 = { } export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { + '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> export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record = { claude: 'claude', + 'claude-agent-teams': 'orca claude-teams', openclaude: 'openclaude', codex: 'codex', grok: 'grok', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index db6c6593d..e4557abce 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 12935179c..75e94d3ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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() - 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 { + 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 { - 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 { - 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 { - 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 { - 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 { + 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, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 554edb239..b316ccb3e 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -145,7 +145,12 @@ function waitForWsClose(ws: WebSocket): Promise { }) } -async function authenticateMobileWs(pairingUrl: string): Promise { +type AuthenticatedMobileWs = { + ws: WebSocket + sharedKey: Uint8Array +} + +async function authenticateMobileWsSession(pairingUrl: string): Promise { const parsed = parsePairingCode(pairingUrl) expect(parsed).toBeTruthy() const ws = await connectWs(parsed!.endpoint) @@ -168,7 +173,83 @@ async function authenticateMobileWs(pairingUrl: string): Promise { type: 'e2ee_authenticated' }) - return ws + return { ws, sharedKey } +} + +async function authenticateMobileWs(pairingUrl: string): Promise { + return (await authenticateMobileWsSession(pairingUrl)).ws +} + +function sendEncryptedWsRequest( + session: AuthenticatedMobileWs, + request: Record +): void { + session.ws.send(encrypt(JSON.stringify(request), session.sharedKey)) +} + +function createEncryptedWsResponseReader(session: AuthenticatedMobileWs): { + next: ( + id: string, + predicate?: (response: Record) => boolean + ) => Promise> + dispose: () => void +} { + type Waiter = { + id: string + predicate: (response: Record) => boolean + resolve: (response: Record) => void + } + const queue: Record[] = [] + const waiters: Waiter[] = [] + + const takeQueued = ( + id: string, + predicate: (response: Record) => boolean + ): Record | 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 + 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) => boolean = () => true) => { + const queued = takeQueued(id, predicate) + if (queued) { + return Promise.resolve(queued) + } + return new Promise>((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) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index c9452dc8a..1c4cd98a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index e558e82a3..b861e3ee8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 59680d733..ea80c70cf 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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 = {} }, diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index e25a157bb..445af0741 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -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) }) }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 6661303a3..3d8ce8cb0 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -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>() diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 69cc19a25..f1fb8c52b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 90423efe1..7838a7bae 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 }