Fix remote runtime terminal sharing with mobile (#4901)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
90ac74d9b9
commit
f5efdd5945
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue