Fix remote session tab split parity (#2252)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-18 16:13:40 -04:00 committed by GitHub
parent b25ea6b5bd
commit fbf792c31c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2236 additions and 42 deletions

View File

@ -33,6 +33,7 @@ import type {
WorktreeStartupLaunch,
LinearIssueUpdate,
LinearWorkspaceSelection,
TabGroupLayoutNode,
TuiAgent
} from '../../shared/types'
import { splitWorktreeId } from '../../shared/worktree-id'
@ -76,6 +77,9 @@ import type {
RuntimeMobileSessionCreateTerminalResult,
RuntimeMobileSessionClientTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTerminalTab,
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult,
@ -445,6 +449,7 @@ type RuntimeNotifier = {
focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void
focusEditorTab?(tabId: string, worktreeId: string): void
closeSessionTab?(tabId: string, worktreeId: string): void
moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void
openFile?(worktreeId: string, filePath: string, relativePath: string): void
openDiff?(worktreeId: string, filePath: string, relativePath: string, staged: boolean): void
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
@ -1229,6 +1234,130 @@ export class OrcaRuntimeService {
return { closed: true }
}
async moveMobileSessionTab(
worktreeSelector: string,
move: RuntimeMobileSessionTabMove
): Promise<RuntimeMobileSessionTabMoveResult> {
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!this.notifier?.moveSessionTab) {
throw new Error('renderer_unavailable')
}
if (!snapshot) {
throw new Error('tab_not_found')
}
const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId)
if (!hostTabId) {
throw new Error('tab_not_found')
}
const publicSnapshot = this.toMobileSessionTabsResult(snapshot)
const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId)
if (!targetGroup) {
throw new Error('target_group_not_found')
}
// Why: web clients address terminal surfaces as tab::leaf, while desktop
// tab grouping is owned by the outer terminal tab id.
if (move.kind === 'reorder') {
const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder)
if (!tabOrder.includes(hostTabId)) {
throw new Error('invalid_tab_order')
}
this.notifier.moveSessionTab(worktreeId, {
...move,
tabId: hostTabId,
tabOrder
})
return { moved: true }
}
this.notifier.moveSessionTab(worktreeId, {
...move,
tabId: hostTabId
})
return { moved: true }
}
private normalizeMobileSessionTabOrder(
snapshot: RuntimeMobileSessionTabsSnapshot | undefined,
targetGroup: RuntimeMobileSessionTabGroup,
tabOrder: readonly string[]
): string[] {
const normalized: string[] = []
const seen = new Set<string>()
for (const tabId of tabOrder) {
const hostTabId = this.resolveMobileSessionHostTabId(snapshot, tabId)
if (!hostTabId) {
throw new Error('invalid_tab_order')
}
if (seen.has(hostTabId)) {
throw new Error('duplicate_tab_order')
}
seen.add(hostTabId)
normalized.push(hostTabId)
}
const returnedIds = this.collectPublicMobileSessionTabIds(snapshot)
const expected = targetGroup.tabOrder
.map((tabId) => this.resolveMobileSessionHostTabId(snapshot, tabId) ?? tabId)
// Why: clients reorder the sanitized session.tabs.list model; raw groups
// can still contain stale browser ids hidden from paired web clients.
.filter((tabId) => returnedIds.has(tabId))
// Why: reorder is a pure permutation of one existing group. Missing or
// extra ids would let a paired web client silently move/lose host tabs.
if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) {
throw new Error('invalid_tab_order')
}
return normalized
}
private collectPublicMobileSessionTabIds(
snapshot: RuntimeMobileSessionTabsSnapshot | undefined
): Set<string> {
const ids = new Set<string>()
if (!snapshot) {
return ids
}
const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree)
for (const tab of snapshot.tabs) {
if (tab.type === 'browser') {
const liveTab = tab.browserPageId
? liveBrowserTabsByPageId.get(tab.browserPageId)
: undefined
if (!liveTab) {
continue
}
ids.add(tab.id)
ids.add(tab.browserWorkspaceId)
continue
}
ids.add(tab.id)
if (tab.type === 'terminal') {
ids.add(tab.parentTabId)
}
}
return ids
}
private resolveMobileSessionHostTabId(
snapshot: RuntimeMobileSessionTabsSnapshot | undefined,
tabId: string
): string | null {
const tab =
snapshot?.tabs.find((candidate) => candidate.id === tabId) ??
snapshot?.tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId
) ??
snapshot?.tabs.find(
(candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId
)
if (!tab) {
return null
}
return tab.type === 'terminal' ? tab.parentTabId : tab.id
}
async readMobileMarkdownTab(
worktreeSelector: string,
tabId: string
@ -6501,7 +6630,7 @@ export class OrcaRuntimeService {
async createMobileSessionTerminal(
worktreeSelector: string,
opts: { afterTabId?: string; command?: string; activate?: boolean } = {}
opts: { afterTabId?: string; targetGroupId?: string; command?: string; activate?: boolean } = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
this.assertGraphReady()
const worktreeId = (await this.resolveWorktreeSelector(worktreeSelector)).id
@ -6551,6 +6680,7 @@ export class OrcaRuntimeService {
requestId,
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: opts.command
})
})
@ -7936,6 +8066,69 @@ export class OrcaRuntimeService {
return new Map(liveTabs.map((tab) => [tab.browserPageId, tab]))
}
private collectReturnedSessionTabIds(
tabs: readonly RuntimeMobileSessionClientTab[]
): Set<string> {
const ids = new Set<string>()
for (const tab of tabs) {
ids.add(tab.id)
if (tab.type === 'terminal') {
ids.add(tab.parentTabId)
} else if (tab.type === 'browser') {
ids.add(tab.browserWorkspaceId)
}
}
return ids
}
private sanitizeMobileSessionTabGroups(
groups: readonly RuntimeMobileSessionTabGroup[] | undefined,
returnedTabs: readonly RuntimeMobileSessionClientTab[]
): RuntimeMobileSessionTabGroup[] | undefined {
if (!groups || groups.length === 0) {
return undefined
}
const returnedIds = this.collectReturnedSessionTabIds(returnedTabs)
const sanitized = groups
.map((group): RuntimeMobileSessionTabGroup | null => {
const tabOrder = group.tabOrder.filter((tabId) => returnedIds.has(tabId))
if (tabOrder.length === 0) {
return null
}
const activeTabId =
group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (tabOrder[0] ?? null)
const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId))
return {
id: group.id,
activeTabId,
tabOrder,
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
}
})
.filter((group): group is RuntimeMobileSessionTabGroup => group !== null)
return sanitized.length > 0 ? sanitized : undefined
}
private pruneMobileSessionTabGroupLayout(
layout: TabGroupLayoutNode | null | undefined,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return validGroupIds.has(layout.groupId) ? layout : null
}
const first = this.pruneMobileSessionTabGroupLayout(layout.first, validGroupIds)
const second = this.pruneMobileSessionTabGroupLayout(layout.second, validGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
private toMobileSessionTabsResult(
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsResult {
@ -8012,13 +8205,33 @@ export class OrcaRuntimeService {
active && !tabs.some((tab) => tab.isActive)
? tabs.map((tab) => (tab.id === active.id ? { ...tab, isActive: true } : tab))
: tabs
const tabGroups = this.sanitizeMobileSessionTabGroups(snapshot.tabGroups, normalizedTabs)
const validGroupIds = new Set(tabGroups?.map((group) => group.id) ?? [])
const tabGroupLayout =
snapshot.tabGroupLayout === undefined
? undefined
: this.pruneMobileSessionTabGroupLayout(snapshot.tabGroupLayout, validGroupIds)
const activeGroupId =
snapshot.activeGroupId && validGroupIds.has(snapshot.activeGroupId)
? snapshot.activeGroupId
: (tabGroups?.find((group) =>
active
? group.tabOrder.some((tabId) =>
this.collectReturnedSessionTabIds([active]).has(tabId)
)
: false
)?.id ??
tabGroups?.[0]?.id ??
null)
return {
worktree: snapshot.worktree,
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion,
activeGroupId: snapshot.activeGroupId,
activeGroupId,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(tabGroups ? { tabGroups } : {}),
...(snapshot.tabGroupLayout !== undefined ? { tabGroupLayout } : {}),
tabs: normalizedTabs
}
}

View File

@ -0,0 +1,197 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../../orca-runtime'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types'
function setMobileSessionSnapshot(
runtime: OrcaRuntimeService,
snapshot: RuntimeMobileSessionTabsSnapshot
): void {
;(
runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
}
function terminalTab() {
return {
type: 'terminal' as const,
id: 'terminal-tab::leaf-1',
parentTabId: 'terminal-tab',
leafId: 'leaf-1',
title: 'Terminal',
isActive: true
}
}
function browserTab({
id,
workspaceId,
pageId,
url
}: {
id: string
workspaceId: string
pageId: string
url: string
}) {
return {
type: 'browser' as const,
id,
title: 'Browser',
browserWorkspaceId: workspaceId,
browserPageId: pageId,
url,
loading: false,
canGoBack: false,
canGoForward: false,
isActive: false
}
}
describe('session tab move validation', () => {
it('validates reorder moves against sanitized visible tab groups', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
runtime.setNotifier({ moveSessionTab } as never)
runtime.setAgentBrowserBridge({
tabList: vi.fn(() => ({
tabs: [
{
browserPageId: 'page-live',
title: 'Live Browser',
url: 'https://example.test/live'
}
]
}))
} as never)
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'terminal-tab',
tabOrder: ['terminal-tab', 'browser-stale', 'browser-live']
}
],
tabs: [
terminalTab(),
browserTab({
id: 'browser-stale-tab',
workspaceId: 'browser-stale',
pageId: 'page-stale',
url: 'https://example.test/stale'
}),
browserTab({
id: 'browser-live-tab',
workspaceId: 'browser-live',
pageId: 'page-live',
url: 'https://example.test/live'
})
]
})
await expect(
runtime.moveMobileSessionTab('id:wt-1', {
kind: 'reorder',
tabId: 'browser-live',
targetGroupId: 'group-1',
tabOrder: ['browser-live', 'terminal-tab']
})
).resolves.toEqual({ moved: true })
expect(moveSessionTab).toHaveBeenCalledWith('wt-1', {
kind: 'reorder',
tabId: 'browser-live-tab',
targetGroupId: 'group-1',
tabOrder: ['browser-live-tab', 'terminal-tab']
})
})
it('rejects moves into groups hidden from the sanitized session model', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
runtime.setNotifier({ moveSessionTab } as never)
runtime.setAgentBrowserBridge({
tabList: vi.fn(() => ({ tabs: [] }))
} as never)
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-visible',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{ id: 'group-visible', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] },
{ id: 'group-hidden', activeTabId: 'browser-stale', tabOrder: ['browser-stale'] }
],
tabs: [
terminalTab(),
browserTab({
id: 'browser-stale-tab',
workspaceId: 'browser-stale',
pageId: 'page-stale',
url: 'https://example.test/stale'
})
]
})
await expect(
runtime.moveMobileSessionTab('id:wt-1', {
kind: 'split',
tabId: 'terminal-tab',
targetGroupId: 'group-hidden',
splitDirection: 'right'
})
).rejects.toThrow('target_group_not_found')
expect(moveSessionTab).not.toHaveBeenCalled()
})
it('rejects reorder moves when the moved tab is absent from the target order', async () => {
const runtime = new OrcaRuntimeService()
const moveSessionTab = vi.fn()
runtime.setNotifier({ moveSessionTab } as never)
runtime.setAgentBrowserBridge({
tabList: vi.fn(() => ({
tabs: [{ browserPageId: 'page-live', title: 'Live Browser', url: 'https://example.test' }]
}))
} as never)
setMobileSessionSnapshot(runtime, {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'terminal-tab::leaf-1',
activeTabType: 'terminal',
tabGroups: [
{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] },
{ id: 'group-2', activeTabId: 'browser-live', tabOrder: ['browser-live'] }
],
tabs: [
terminalTab(),
browserTab({
id: 'browser-live-tab',
workspaceId: 'browser-live',
pageId: 'page-live',
url: 'https://example.test'
})
]
})
await expect(
runtime.moveMobileSessionTab('id:wt-1', {
kind: 'reorder',
tabId: 'browser-live',
targetGroupId: 'group-1',
tabOrder: ['terminal-tab']
})
).rejects.toThrow('invalid_tab_order')
expect(moveSessionTab).not.toHaveBeenCalled()
})
})

View File

@ -9,6 +9,120 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('session tab RPC methods', () => {
it('dispatches tab moves through the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
moveMobileSessionTab: vi.fn().mockResolvedValue({
moved: true
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.move', {
worktree: 'id:wt-1',
tabId: 'tab-1::leaf-1',
targetGroupId: 'group-left',
kind: 'reorder',
tabOrder: ['tab-2::leaf-1', 'tab-1::leaf-1']
})
)
expect(response.ok).toBe(true)
expect(runtime.moveMobileSessionTab).toHaveBeenCalledWith('id:wt-1', {
tabId: 'tab-1::leaf-1',
targetGroupId: 'group-left',
kind: 'reorder',
tabOrder: ['tab-2::leaf-1', 'tab-1::leaf-1']
})
})
it('rejects ambiguous tab move payloads', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
moveMobileSessionTab: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.move', {
worktree: 'id:wt-1',
tabId: 'tab-1',
targetGroupId: 'group-1',
kind: 'reorder',
splitDirection: 'right',
tabOrder: ['tab-1']
})
)
expect(response.ok).toBe(false)
expect(runtime.moveMobileSessionTab).not.toHaveBeenCalled()
})
it('dispatches split tab moves without reorder-only fields', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
moveMobileSessionTab: vi.fn().mockResolvedValue({ moved: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.move', {
worktree: 'id:wt-1',
tabId: 'tab-1',
targetGroupId: 'group-2',
kind: 'split',
splitDirection: 'right'
})
)
expect(response.ok).toBe(true)
expect(runtime.moveMobileSessionTab).toHaveBeenCalledWith('id:wt-1', {
tabId: 'tab-1',
targetGroupId: 'group-2',
kind: 'split',
splitDirection: 'right'
})
})
it('dispatches terminal creation with the requested tab group', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createMobileSessionTerminal: vi.fn().mockResolvedValue({
tab: {
type: 'terminal',
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready',
terminal: 'pty-1',
isActive: true
},
publicationEpoch: 'epoch-1',
snapshotVersion: 1
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.createTerminal', {
worktree: 'id:wt-1',
targetGroupId: 'group-left',
command: 'zsh',
activate: true
})
)
expect(response.ok).toBe(true)
expect(runtime.createMobileSessionTerminal).toHaveBeenCalledWith('id:wt-1', {
afterTabId: undefined,
targetGroupId: 'group-left',
command: 'zsh',
activate: true
})
})
it('streams all known session tab snapshots and later updates', async () => {
const unsubscribe = vi.fn()
const listeners: ((snapshot: unknown) => void)[] = []

View File

@ -17,10 +17,47 @@ const ActivateTab = WorktreeTabSelector.extend({
const CreateTerminalTab = WorktreeTabSelector.extend({
afterTabId: z.string().optional(),
targetGroupId: z.string().optional(),
command: z.string().optional(),
activate: z.boolean().optional()
})
const MoveTabBase = {
worktree: WorktreeTabSelector.shape.worktree,
tabId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing tab id')),
targetGroupId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing target group id'))
} as const
const MoveTab = z.discriminatedUnion('kind', [
z
.object({
...MoveTabBase,
kind: z.literal('reorder'),
tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order')
})
.strict(),
z
.object({
...MoveTabBase,
kind: z.literal('move-to-group'),
index: z.number().int().nonnegative().optional()
})
.strict(),
z
.object({
...MoveTabBase,
kind: z.literal('split'),
splitDirection: z.enum(['left', 'right', 'up', 'down'])
})
.strict()
])
const SaveMarkdownTab = ActivateTab.extend({
baseVersion: z
.unknown()
@ -60,10 +97,40 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) =>
runtime.createMobileSessionTerminal(params.worktree, {
afterTabId: params.afterTabId,
targetGroupId: params.targetGroupId,
command: params.command,
activate: params.activate
})
}),
defineMethod({
name: 'session.tabs.move',
params: MoveTab,
handler: async (params, { runtime }) => {
const base = {
tabId: params.tabId,
targetGroupId: params.targetGroupId
}
if (params.kind === 'reorder') {
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'reorder',
tabOrder: params.tabOrder
})
}
if (params.kind === 'split') {
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'split',
splitDirection: params.splitDirection
})
}
return runtime.moveMobileSessionTab(params.worktree, {
...base,
kind: 'move-to-group',
index: params.index
})
}
}),
defineStreamingMethod({
name: 'session.tabs.subscribe',
params: WorktreeTabSelector,

View File

@ -167,6 +167,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.createTerminal',
'session.tabs.list',
'session.tabs.listAll',
'session.tabs.move',
'session.tabs.subscribe',
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',

View File

@ -32,6 +32,7 @@ import type {
RuntimeMarkdownReadTabResult,
RuntimeMarkdownSaveTabResult
} from '../../shared/mobile-markdown-document'
import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types'
import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay'
export function attachMainWindowServices(
@ -288,6 +289,8 @@ function registerRuntimeWindowLifecycle(
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
closeSessionTab: (tabId, worktreeId) => send('ui:closeSessionTab', { tabId, worktreeId }),
moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) =>
send('ui:moveSessionTab', { worktreeId, ...move }),
openFile: (worktreeId, filePath, relativePath) =>
send('ui:openFileFromMobile', { worktreeId, filePath, relativePath }),
openDiff: (worktreeId, filePath, relativePath, staged) =>

View File

@ -162,6 +162,7 @@ import type {
} from '../shared/agent-status-types'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
RuntimeStatus,
RuntimeSyncWindowGraph,
RuntimeTerminalDriverState
@ -1608,6 +1609,7 @@ export type PreloadApi = {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
title?: string
activate?: boolean
@ -1639,6 +1641,9 @@ export type PreloadApi = {
onCloseSessionTab: (
callback: (data: { tabId: string; worktreeId: string }) => void
) => () => void
onMoveSessionTab: (
callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void
) => () => void
onOpenFileFromMobile: (
callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void
) => () => void

View File

@ -39,6 +39,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult } from '../shared/skills'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
RuntimeStatus,
RuntimeSyncWindowGraph,
RuntimeTerminalDriverState
@ -2323,6 +2324,7 @@ const api = {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
title?: string
activate?: boolean
@ -2334,6 +2336,7 @@ const api = {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
title?: string
activate?: boolean
@ -2410,6 +2413,16 @@ const api = {
ipcRenderer.on('ui:closeSessionTab', listener)
return () => ipcRenderer.removeListener('ui:closeSessionTab', listener)
},
onMoveSessionTab: (
callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { worktreeId: string } & RuntimeMobileSessionTabMove
) => callback(data)
ipcRenderer.on('ui:moveSessionTab', listener)
return () => ipcRenderer.removeListener('ui:moveSessionTab', listener)
},
onOpenFileFromMobile: (
callback: (data: { worktreeId: string; filePath: string; relativePath: string }) => void
): (() => void) => {

View File

@ -739,6 +739,7 @@ function App(): React.JSX.Element {
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.layoutByWorktree === previousState.layoutByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&

View File

@ -16,7 +16,12 @@ import {
useSensors
} from '@dnd-kit/core'
import type { TabGroup } from '../../../../shared/types'
import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types'
import { useAppStore } from '../../store'
import {
isWebRuntimeSessionActive,
moveWebRuntimeSessionTab
} from '../../runtime/web-runtime-session'
import type { TabSplitDirection } from '../../store/slices/tabs'
import {
resolveTabInsertion,
@ -55,6 +60,21 @@ export type HoveredTabDropTarget = {
zone: TabDropZone
}
function mirrorWebRuntimeTabMove(
args: RuntimeMobileSessionTabMove & {
worktreeId: string
}
): void {
const environmentId = useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ?? null
if (!isWebRuntimeSessionActive(environmentId)) {
return
}
void moveWebRuntimeSessionTab({
...args,
environmentId
})
}
export function canDropTabIntoPaneBody({
activeDrag,
groupsByWorktree,
@ -321,12 +341,29 @@ export function useTabDragSplit({
const nextOrder = targetGroup.tabOrder.filter((id) => id !== activeData.unifiedTabId)
nextOrder.splice(nextIndex, 0, activeData.unifiedTabId)
reorderUnifiedTabs(overData.groupId, nextOrder)
mirrorWebRuntimeTabMove({
kind: 'reorder',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
tabOrder: nextOrder
})
}
} else {
dropUnifiedTab(activeData.unifiedTabId, {
const index = overIndex === -1 ? targetGroup.tabOrder.length : rawInsertIndex
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
index: overIndex === -1 ? targetGroup.tabOrder.length : rawInsertIndex
index
})
if (moved) {
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
index
})
}
}
clearDragState()
@ -356,10 +393,28 @@ export function useTabDragSplit({
// Skip the call in that case to avoid misleading the user via a
// drop that silently does nothing.
if (zone !== 'center' || activeData.groupId !== overData.groupId) {
dropUnifiedTab(activeData.unifiedTabId, {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
splitDirection: zone === 'center' ? undefined : zone
})
if (moved) {
if (zone === 'center') {
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId
})
} else {
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
splitDirection: zone
})
}
}
}
}
}

View File

@ -582,6 +582,7 @@ export function useTabGroupWorkspaceModel({
if (
await createWebRuntimeSessionTerminal({
worktreeId,
targetGroupId: groupId,
activate: true
})
) {
@ -598,6 +599,7 @@ export function useTabGroupWorkspaceModel({
if (
await createWebRuntimeSessionTerminal({
worktreeId,
targetGroupId: groupId,
command: shellOverride,
activate: true
})

View File

@ -231,6 +231,7 @@ describe('useIpcEvents updater integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -446,6 +447,7 @@ describe('useIpcEvents updater integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -611,6 +613,7 @@ describe('useIpcEvents updater integration', () => {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
title?: string
activate?: boolean
@ -705,6 +708,7 @@ describe('useIpcEvents updater integration', () => {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
title?: string
activate?: boolean
@ -719,6 +723,7 @@ describe('useIpcEvents updater integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -821,12 +826,13 @@ describe('useIpcEvents updater integration', () => {
requestTerminalCreateListenerRef.current({
requestId: 'req-renderer-backed',
worktreeId: 'wt-2',
targetGroupId: 'group-left',
title: 'Codex',
command: 'codex',
activate: false
})
expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { activate: false })
expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { activate: false })
expect(setActiveView).not.toHaveBeenCalled()
expect(setActiveWorktree).not.toHaveBeenCalled()
expect(setActiveTabType).not.toHaveBeenCalled()
@ -1130,6 +1136,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -1340,6 +1347,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -1545,6 +1553,7 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -1768,6 +1777,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
@ -1968,6 +1978,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},

View File

@ -904,7 +904,7 @@ export function useIpcEvents(): void {
}
const tab = store.createTab(
worktreeId,
undefined,
data.targetGroupId,
undefined,
shouldActivate ? undefined : { activate: false }
)
@ -1038,6 +1038,22 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onMoveSessionTab((move) => {
const { tabId, targetGroupId } = move
const store = useAppStore.getState()
if (move.kind === 'reorder') {
store.reorderUnifiedTabs(targetGroupId, move.tabOrder)
return
}
store.dropUnifiedTab(tabId, {
groupId: targetGroupId,
...(move.kind === 'move-to-group' ? { index: move.index } : {}),
...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {})
})
})
)
unsubs.push(
window.api.ui.onOpenFileFromMobile(({ worktreeId, filePath, relativePath }) => {
const store = useAppStore.getState()

View File

@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getRuntimeMobileSessionSyncKey,
registerRuntimeTerminalTab,
runtimeMobileSessionSyncKeysEqual,
scheduleRuntimeGraphSync,
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
@ -15,6 +17,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'],
groupsByWorktree: {},
activeGroupIdByWorktree: {},
layoutByWorktree: {},
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileId: null,
@ -104,3 +107,36 @@ describe('scheduleRuntimeGraphSync', () => {
unregister()
})
})
describe('getRuntimeMobileSessionSyncKey scheduling inputs', () => {
it('changes when only tab group split ratios change', () => {
const base = makeState({
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' },
ratio: 0.5
}
} as AppState['layoutByWorktree']
})
const baseKey = getRuntimeMobileSessionSyncKey(base)
const resized = makeState({
...base,
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' },
ratio: 0.65
}
} as AppState['layoutByWorktree']
})
const resizedKey = getRuntimeMobileSessionSyncKey(resized, base, baseKey)
expect(runtimeMobileSessionSyncKeysEqual(baseKey, resizedKey)).toBe(false)
})
})

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: this file keeps terminal session publication
* fixtures together so split-pane and split-tab parity assertions do not drift. */
import { describe, expect, it } from 'vitest'
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph'
import type { AppState } from '../store/types'
@ -120,6 +122,215 @@ describe('terminal mobile session layout publication', () => {
])
})
it('publishes split tab groups so remote clients mirror terminal tab splits', () => {
const leftLeaf = '11111111-1111-4111-8111-111111111111'
const rightLeaf = '22222222-2222-4222-8222-222222222222'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-right' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-left',
activeTabId: 'term-left',
tabOrder: ['term-left']
},
{
id: 'group-right',
activeTabId: 'term-right',
tabOrder: ['term-right']
}
]
} as unknown as AppState['groupsByWorktree'],
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
} as unknown as AppState['layoutByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'term-left',
groupId: 'group-left',
contentType: 'terminal',
entityId: 'term-left',
title: 'Left'
},
{
id: 'term-right',
groupId: 'group-right',
contentType: 'terminal',
entityId: 'term-right',
title: 'Right'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
tabsByWorktree: {
'wt-1': [
{
id: 'term-left',
worktreeId: 'wt-1',
ptyId: 'pty-left',
title: 'Left',
defaultTitle: 'Left',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: 'term-right',
worktreeId: 'wt-1',
ptyId: 'pty-right',
title: 'Right',
defaultTitle: 'Right',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
} as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {
'term-left': {
root: { type: 'leaf', leafId: leftLeaf },
activeLeafId: leftLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [leftLeaf]: 'pty-left' }
},
'term-right': {
root: { type: 'leaf', leafId: rightLeaf },
activeLeafId: rightLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [rightLeaf]: 'pty-right' }
}
} as unknown as AppState['terminalLayoutsByTabId']
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs.map((tab) => tab.id)).toEqual([
`term-left::${leftLeaf}`,
`term-right::${rightLeaf}`
])
expect(snapshot?.tabGroups).toEqual([
{ id: 'group-left', activeTabId: 'term-left', tabOrder: ['term-left'], recentTabIds: [] },
{ id: 'group-right', activeTabId: 'term-right', tabOrder: ['term-right'], recentTabIds: [] }
])
expect(snapshot?.tabGroupLayout).toEqual({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
})
})
it('publishes the active tab from the active split group', () => {
const rightLeaf = '22222222-2222-4222-8222-222222222222'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-right' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-left',
activeTabId: 'browser-left',
tabOrder: ['browser-left']
},
{
id: 'group-right',
activeTabId: 'term-right',
tabOrder: ['term-right']
}
]
} as unknown as AppState['groupsByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-left',
groupId: 'group-left',
contentType: 'browser',
entityId: 'browser-1',
title: 'Browser'
},
{
id: 'term-right',
groupId: 'group-right',
contentType: 'terminal',
entityId: 'term-right',
title: 'Terminal'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
tabsByWorktree: {
'wt-1': [
{
id: 'term-right',
worktreeId: 'wt-1',
ptyId: 'pty-right',
title: 'Terminal',
defaultTitle: 'Terminal',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
} as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {
'term-right': {
root: { type: 'leaf', leafId: rightLeaf },
activeLeafId: rightLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [rightLeaf]: 'pty-right' }
}
} as unknown as AppState['terminalLayoutsByTabId'],
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-1',
worktreeId: 'wt-1',
activePageId: 'page-1',
pageIds: ['page-1'],
url: 'https://example.test',
title: 'Browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
} as unknown as AppState['browserTabsByWorktree'],
browserPagesByWorkspace: {
'browser-1': [
{
id: 'page-1',
workspaceId: 'browser-1',
worktreeId: 'wt-1',
url: 'https://example.test',
title: 'Browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
} as unknown as AppState['browserPagesByWorkspace']
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.activeTabId).toBe(`term-right::${rightLeaf}`)
expect(snapshot?.tabs.find((tab) => tab.id === 'browser-left')).toMatchObject({
isActive: false
})
})
it('does not publish web-mirrored terminal tabs back to the host session', () => {
const leaf = '11111111-1111-4111-8111-111111111111'
const state = makeState({

View File

@ -14,14 +14,24 @@ import type {
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionFileTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot,
RuntimeSyncWindowGraph
} from '../../../shared/runtime-types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types'
import { getActiveTabNavOrder } from '../components/tab-bar/group-tab-order'
import type {
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../../shared/types'
import {
getActiveTabNavOrder,
getGroupVisibleTabOrder,
type VisibleTabRef
} from '../components/tab-bar/group-tab-order'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
type RegisteredTerminalTab = {
@ -60,6 +70,7 @@ const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWor
const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {}
const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {}
const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {}
let syncScheduled = false
let syncInFlight = false
let syncPendingAfterFlight = false
@ -161,6 +172,7 @@ export type RuntimeMobileSessionSyncKey = {
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
groupsByWorktree: AppState['groupsByWorktree']
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
layoutByWorktree: AppState['layoutByWorktree']
unifiedTabsByWorktree: AppState['unifiedTabsByWorktree']
tabBarOrderByWorktree: AppState['tabBarOrderByWorktree']
activeFileId: AppState['activeFileId']
@ -198,6 +210,7 @@ export function getRuntimeMobileSessionSyncKey(
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
groupsByWorktree: state.groupsByWorktree,
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
activeFileId: state.activeFileId,
@ -347,6 +360,7 @@ export function runtimeMobileSessionSyncKeysEqual(
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
a.groupsByWorktree === b.groupsByWorktree &&
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
a.layoutByWorktree === b.layoutByWorktree &&
a.unifiedTabsByWorktree === b.unifiedTabsByWorktree &&
a.tabBarOrderByWorktree === b.tabBarOrderByWorktree &&
a.activeFileId === b.activeFileId &&
@ -461,9 +475,6 @@ export function buildMobileSessionTabSnapshots(
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
for (const worktreeId of worktreeIds) {
const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null
const order = getActiveTabNavOrder(state, worktreeId, {
editorIds: openFileIndexes.idsByWorktree.get(worktreeId) ?? []
})
const terminalTabByIdForWorktree = new Map(
(state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
@ -473,9 +484,18 @@ export function buildMobileSessionTabSnapshots(
workspace
])
)
const editorIds = openFileIndexes.idsByWorktree.get(worktreeId) ?? []
const publishableTerminalIds = [...terminalTabByIdForWorktree.values()]
.filter((terminal) => !isWebOnlyMirroredTerminalTab(state, terminal))
.map((terminal) => terminal.id)
const groupProjection = buildMobileSessionGroupProjection(state, worktreeId, {
terminalIds: publishableTerminalIds,
editorIds,
browserIds: [...browserWorkspaceByIdForWorktree.keys()]
})
const tabs: RuntimeMobileSessionSnapshotTab[] = []
for (const item of order) {
for (const item of groupProjection.order) {
if (item.type === 'terminal') {
const terminal = terminalTabByIdForWorktree.get(item.id)
if (!terminal) {
@ -519,6 +539,10 @@ export function buildMobileSessionTabSnapshots(
activeGroupId,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(groupProjection.tabGroups && groupProjection.tabGroups.length > 0
? { tabGroups: groupProjection.tabGroups }
: {}),
...(groupProjection.tabGroupLayout ? { tabGroupLayout: groupProjection.tabGroupLayout } : {}),
tabs
})
}
@ -577,6 +601,125 @@ function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes {
return cachedOpenFileIndexes
}
function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] {
const result: string[] = []
const visit = (node: TabGroupLayoutNode | undefined): void => {
if (!node) {
return
}
if (node.type === 'leaf') {
result.push(node.groupId)
return
}
visit(node.first)
visit(node.second)
}
visit(layout)
return result
}
function pruneTabGroupLayout(
layout: TabGroupLayoutNode | undefined,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return validGroupIds.has(layout.groupId) ? layout : null
}
const first = pruneTabGroupLayout(layout.first, validGroupIds)
const second = pruneTabGroupLayout(layout.second, validGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
function getOrderedTabGroups(
groups: readonly TabGroup[],
layout: TabGroupLayoutNode | undefined
): TabGroup[] {
const byId = new Map(groups.map((group) => [group.id, group]))
const seen = new Set<string>()
const ordered: TabGroup[] = []
for (const groupId of collectTabGroupLayoutIds(layout)) {
const group = byId.get(groupId)
if (!group || seen.has(group.id)) {
continue
}
seen.add(group.id)
ordered.push(group)
}
for (const group of groups) {
if (!seen.has(group.id)) {
ordered.push(group)
}
}
return ordered
}
function buildMobileSessionGroupProjection(
state: AppState,
worktreeId: string,
ids: {
terminalIds: string[]
editorIds: string[]
browserIds: string[]
}
): {
order: VisibleTabRef[]
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
} {
const groups = state.groupsByWorktree[worktreeId] ?? []
if (groups.length === 0) {
return {
order: getActiveTabNavOrder(state, worktreeId, {
editorIds: ids.editorIds
})
}
}
const terminalIds = new Set(ids.terminalIds)
const editorIds = new Set(ids.editorIds)
const browserIds = new Set(ids.browserIds)
const tabs = state.unifiedTabsByWorktree[worktreeId] ?? []
const order: VisibleTabRef[] = []
const tabGroups: RuntimeMobileSessionTabGroup[] = []
const layoutByWorktree = state.layoutByWorktree ?? {}
for (const group of getOrderedTabGroups(groups, layoutByWorktree[worktreeId])) {
const groupTabs = tabs.filter((tab) => tab.groupId === group.id)
const visibleOrder = getGroupVisibleTabOrder(
group,
groupTabs,
terminalIds,
editorIds,
browserIds
)
if (visibleOrder.length === 0) {
continue
}
const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id)
order.push(...visibleOrder)
tabGroups.push({
id: group.id,
activeTabId:
group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : null,
tabOrder,
recentTabIds: group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) ?? []
})
}
const validGroupIds = new Set(tabGroups.map((group) => group.id))
return {
order,
tabGroups,
tabGroupLayout: pruneTabGroupLayout(layoutByWorktree[worktreeId], validGroupIds)
}
}
function getEditorDraftVersionByFileId(
editorDrafts: AppState['editorDrafts']
): Map<string, string> {
@ -735,9 +878,7 @@ function buildMobileMarkdownTab(
mode: file.mode,
isDirty: file.isDirty || sourceFile.isDirty,
isActive: unifiedTabId
? state.groupsByWorktree[file.worktreeId]?.some(
(group) => group.activeTabId === unifiedTabId
) === true
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
: state.activeFileId === file.id,
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
@ -765,9 +906,7 @@ function buildMobileFileTab(
...(diffSource ? { diffSource } : {}),
isDirty: file.isDirty,
isActive: unifiedTabId
? state.groupsByWorktree[file.worktreeId]?.some(
(group) => group.activeTabId === unifiedTabId
) === true
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
: state.activeFileId === file.id
}
}
@ -799,13 +938,24 @@ function buildMobileBrowserTab(
canGoBack: activePage?.canGoBack ?? workspace.canGoBack,
canGoForward: activePage?.canGoForward ?? workspace.canGoForward,
isActive: unifiedTabId
? state.groupsByWorktree[workspace.worktreeId]?.some(
(group) => group.activeTabId === unifiedTabId
) === true
? isUnifiedTabActiveInActiveGroup(state, workspace.worktreeId, unifiedTabId)
: state.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspace.id
}
}
function isUnifiedTabActiveInActiveGroup(
state: AppState,
worktreeId: string,
unifiedTabId: string
): boolean {
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
return (
state.groupsByWorktree[worktreeId]?.some(
(group) => group.id === activeGroupId && group.activeTabId === unifiedTabId
) === true
)
}
function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {

View File

@ -2,8 +2,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
activateWebRuntimeSessionTab,
closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal
createWebRuntimeSessionTerminal,
moveWebRuntimeSessionTab
} from './web-runtime-session'
const mocks = vi.hoisted(() => ({
@ -12,7 +15,8 @@ const mocks = vi.hoisted(() => ({
createBrowserTab: vi.fn(),
setRemoteBrowserPageHandle: vi.fn(),
focusBrowserTabInWorktree: vi.fn(),
applyFreshWebSessionTabsSnapshot: vi.fn()
applyFreshWebSessionTabsSnapshot: vi.fn(),
resolveHostSessionTabIdForWebSessionTab: vi.fn()
}))
vi.mock('../store', () => ({
@ -23,7 +27,8 @@ vi.mock('../store', () => ({
}))
vi.mock('./web-session-tabs-sync', () => ({
applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot
applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot,
resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab
}))
const ENVIRONMENT_ID = 'web-env-1'
@ -67,6 +72,7 @@ describe('createWebRuntimeSessionBrowserTab', () => {
pageIds: ['local-page-1']
})
mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' })
mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null)
})
afterEach(() => {
@ -300,6 +306,7 @@ describe('createWebRuntimeSessionTerminal', () => {
})
})
mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' })
mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null)
})
afterEach(() => {
@ -355,6 +362,7 @@ describe('createWebRuntimeSessionTerminal', () => {
createWebRuntimeSessionTerminal({
worktreeId: WORKTREE_ID,
afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1',
targetGroupId: 'group-left',
command: 'zsh',
activate: true
})
@ -366,6 +374,7 @@ describe('createWebRuntimeSessionTerminal', () => {
params: {
worktree: `id:${WORKTREE_ID}`,
afterTabId: 'host-tab-1::leaf-1',
targetGroupId: 'group-left',
command: 'zsh',
activate: true
},
@ -386,3 +395,275 @@ describe('createWebRuntimeSessionTerminal', () => {
)
})
})
describe('moveWebRuntimeSessionTab', () => {
beforeEach(() => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
mocks.getState.mockReturnValue({
settings: {
activeRuntimeEnvironmentId: ENVIRONMENT_ID
}
})
mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => {
updater({
state: 'before',
activeWorktreeId: WORKTREE_ID
})
})
mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('moves paired web tabs through the host session API without an eager stale refresh', async () => {
const runtimeCall = vi.fn().mockResolvedValueOnce({
id: 'move',
ok: true,
result: { moved: true }
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
moveWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'web-terminal-host-tab-1%3A%3Aleaf-1',
targetGroupId: 'group-right',
kind: 'split',
splitDirection: 'right'
})
).resolves.toBe(true)
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
selector: ENVIRONMENT_ID,
method: 'session.tabs.move',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-tab-1::leaf-1',
targetGroupId: 'group-right',
kind: 'split',
splitDirection: 'right'
},
timeoutMs: 15_000
})
expect(runtimeCall).toHaveBeenCalledTimes(1)
expect(mocks.applyFreshWebSessionTabsSnapshot).not.toHaveBeenCalled()
})
it('maps mirrored local browser unified ids back to host session tab ids', async () => {
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
args.tabId === 'local-browser-unified'
? 'host-browser-unified'
: args.tabId === 'local-terminal-unified'
? 'host-terminal'
: null
)
const runtimeCall = vi.fn().mockResolvedValueOnce({
id: 'move',
ok: true,
result: { moved: true }
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
moveWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'local-browser-unified',
targetGroupId: 'group-right',
kind: 'reorder',
tabOrder: ['local-terminal-unified', 'local-only-unified', 'local-browser-unified']
})
).resolves.toBe(true)
expect(runtimeCall).toHaveBeenCalledWith({
selector: ENVIRONMENT_ID,
method: 'session.tabs.move',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-browser-unified',
targetGroupId: 'group-right',
kind: 'reorder',
tabOrder: ['host-terminal', 'host-browser-unified']
},
timeoutMs: 15_000
})
})
it('counts only host-backed tabs for mirrored move-to-group indexes', async () => {
mocks.getState.mockReturnValue({
settings: {
activeRuntimeEnvironmentId: ENVIRONMENT_ID
},
groupsByWorktree: {
[WORKTREE_ID]: [
{
id: 'group-right',
activeTabId: 'local-only-unified',
tabOrder: ['local-only-unified', 'local-terminal-unified']
}
]
}
})
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
args.tabId === 'local-browser-unified'
? 'host-browser-unified'
: args.tabId === 'local-terminal-unified'
? 'host-terminal'
: null
)
const runtimeCall = vi.fn().mockResolvedValueOnce({
id: 'move',
ok: true,
result: { moved: true }
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
moveWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'local-browser-unified',
targetGroupId: 'group-right',
kind: 'move-to-group',
index: 1
})
).resolves.toBe(true)
expect(runtimeCall).toHaveBeenCalledWith({
selector: ENVIRONMENT_ID,
method: 'session.tabs.move',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-browser-unified',
targetGroupId: 'group-right',
kind: 'move-to-group',
index: 0
},
timeoutMs: 15_000
})
})
it('does not mirror a reorder when the dragged tab is local-only', async () => {
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
args.tabId === 'local-terminal-unified' ? 'host-terminal' : null
)
const runtimeCall = vi.fn().mockResolvedValueOnce({
id: 'move',
ok: true,
result: { moved: true }
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
moveWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'local-only-unified',
targetGroupId: 'group-right',
kind: 'reorder',
tabOrder: ['local-only-unified', 'local-terminal-unified']
})
).resolves.toBe(false)
expect(runtimeCall).not.toHaveBeenCalled()
})
})
describe('web runtime session tab actions', () => {
beforeEach(() => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
mocks.getState.mockReturnValue({
settings: {
activeRuntimeEnvironmentId: ENVIRONMENT_ID
}
})
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
(_state, args: { tabId: string }) =>
args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('maps mirrored local browser unified ids for activate and close', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'action',
ok: true,
result: {}
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
await expect(
activateWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'local-browser-unified'
})
).resolves.toBe(true)
await expect(
closeWebRuntimeSessionTab({
worktreeId: WORKTREE_ID,
tabId: 'local-browser-unified'
})
).resolves.toBe(true)
expect(runtimeCall).toHaveBeenNthCalledWith(1, {
selector: ENVIRONMENT_ID,
method: 'session.tabs.activate',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-browser-unified'
},
timeoutMs: 15_000
})
expect(runtimeCall).toHaveBeenNthCalledWith(2, {
selector: ENVIRONMENT_ID,
method: 'session.tabs.close',
params: {
worktree: `id:${WORKTREE_ID}`,
tabId: 'host-browser-unified'
},
timeoutMs: 15_000
})
})
})

View File

@ -3,6 +3,8 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type {
BrowserTabCreateResult,
RuntimeMobileSessionCreateTerminalResult,
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabsResult,
RuntimeTerminalSplit
} from '../../../shared/runtime-types'
@ -10,7 +12,7 @@ import type { AppState } from '../store/types'
import { useAppStore } from '../store'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
import { toHostSessionTabId } from './web-terminal-surface-id'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
export {
HOST_TERMINAL_SURFACE_SEPARATOR,
@ -33,6 +35,7 @@ export async function createWebRuntimeSessionTerminal(args: {
worktreeId: string
environmentId?: string | null
afterTabId?: string
targetGroupId?: string
command?: string
activate?: boolean
}): Promise<boolean> {
@ -52,6 +55,7 @@ export async function createWebRuntimeSessionTerminal(args: {
params: {
worktree: `id:${args.worktreeId}`,
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
targetGroupId: args.targetGroupId,
command: args.command,
activate: args.activate !== false
},
@ -276,6 +280,99 @@ export async function closeWebRuntimeSessionTab(args: {
return callWebRuntimeSessionTabMethod('session.tabs.close', args)
}
export async function moveWebRuntimeSessionTab(
args: RuntimeMobileSessionTabMove & {
worktreeId: string
environmentId?: string | null
}
): Promise<boolean> {
const environmentId =
args.environmentId?.trim() ??
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ??
null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
try {
const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync')
const state = useAppStore.getState()
const resolveHostBackedTabId = (tabId: string): string | null =>
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId
}) ?? (isWebTerminalSurfaceTabId(tabId) ? toHostSessionTabId(tabId) : null)
const toHostTabId = (tabId: string): string => resolveHostBackedTabId(tabId) ?? tabId
const movedHostTabId =
args.kind === 'reorder' ? resolveHostBackedTabId(args.tabId) : toHostTabId(args.tabId)
if (!movedHostTabId) {
return false
}
const reorderedHostTabOrder =
args.kind === 'reorder'
? args.tabOrder
.map(resolveHostBackedTabId)
.filter((tabId): tabId is string => Boolean(tabId))
: null
if (reorderedHostTabOrder && !reorderedHostTabOrder.includes(movedHostTabId)) {
return false
}
const targetHostIndex =
args.kind === 'move-to-group' && typeof args.index === 'number'
? (state.groupsByWorktree?.[args.worktreeId]
?.find((group) => group.id === args.targetGroupId)
?.tabOrder.slice(0, args.index)
.map(resolveHostBackedTabId)
.filter((tabId): tabId is string => Boolean(tabId)).length ?? args.index)
: args.kind === 'move-to-group'
? args.index
: undefined
const base = {
worktree: `id:${args.worktreeId}`,
tabId: movedHostTabId,
targetGroupId: args.targetGroupId
}
const move =
args.kind === 'reorder'
? {
...base,
kind: 'reorder' as const,
// Why: paired web groups can contain local-only tabs alongside
// host-mirrored tabs. The host reorder API only accepts host tab
// ids, so local ids must be omitted from the mirrored order.
tabOrder: reorderedHostTabOrder
}
: args.kind === 'split'
? {
...base,
kind: 'split' as const,
splitDirection: args.splitDirection
}
: {
...base,
kind: 'move-to-group' as const,
// Why: web groups can contain local-only tabs. Host insertion
// indexes must be counted in the filtered host-backed order.
index: targetHostIndex
}
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'session.tabs.move',
params: move,
timeoutMs: 15_000
})
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<RuntimeMobileSessionTabMoveResult>)
return true
} catch (error) {
console.warn(
'[web-runtime-session] failed to move tab:',
error instanceof Error ? error.message : String(error)
)
return false
}
}
async function callWebRuntimeSessionTabMethod(
method: 'session.tabs.activate' | 'session.tabs.close',
args: {
@ -293,12 +390,20 @@ async function callWebRuntimeSessionTabMethod(
}
try {
const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync')
const state = useAppStore.getState()
const hostTabId =
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId: args.tabId
}) ?? toHostSessionTabId(args.tabId)
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method,
params: {
worktree: `id:${args.worktreeId}`,
tabId: toHostSessionTabId(args.tabId)
tabId: hostTabId
},
timeoutMs: 15_000
})

View File

@ -9,6 +9,7 @@ import {
applyFreshWebSessionTabsSnapshot,
applyWebSessionTabsSnapshot,
applyWebSessionTabsSnapshots,
resolveHostSessionTabIdForWebSessionTab,
resetWebSessionTabsSnapshotFreshnessForTests,
type WebSessionTabsSyncState
} from './web-session-tabs-sync'
@ -138,6 +139,338 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId)
})
it('hydrates host split tab groups with mirrored terminal tab ids', () => {
const rightLeafId = SECOND_LEAF_ID
const patch = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot(
[
{
type: 'terminal',
id: `host-left::${LEAF_ID}`,
title: 'left shell',
parentTabId: 'host-left',
leafId: LEAF_ID,
isActive: false,
status: 'ready',
terminal: 'terminal-left'
},
{
type: 'terminal',
id: `host-right::${rightLeafId}`,
title: 'right shell',
parentTabId: 'host-right',
leafId: rightLeafId,
isActive: true,
status: 'ready',
terminal: 'terminal-right'
}
],
{
activeGroupId: 'group-right',
activeTabId: `host-right::${rightLeafId}`,
tabGroups: [
{ id: 'group-left', activeTabId: 'host-left', tabOrder: ['host-left'] },
{ id: 'group-right', activeTabId: 'host-right', tabOrder: ['host-right'] }
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
}
),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
const leftId = patch.tabsByWorktree?.[WT]?.find((tab) => tab.title === 'left shell')?.id
const rightId = patch.tabsByWorktree?.[WT]?.find((tab) => tab.title === 'right shell')?.id
expect(leftId).toBeTruthy()
expect(rightId).toBeTruthy()
expect(patch.unifiedTabsByWorktree?.[WT]).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: leftId, groupId: 'group-left' }),
expect.objectContaining({ id: rightId, groupId: 'group-right' })
])
)
expect(patch.groupsByWorktree?.[WT]).toEqual([
{
id: 'group-left',
worktreeId: WT,
activeTabId: leftId,
tabOrder: [leftId],
recentTabIds: [leftId]
},
{
id: 'group-right',
worktreeId: WT,
activeTabId: rightId,
tabOrder: [rightId],
recentTabIds: [rightId]
}
])
expect(patch.layoutByWorktree?.[WT]).toEqual({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
})
expect(patch.activeGroupIdByWorktree?.[WT]).toBe('group-right')
})
it('assigns mirrored terminal, browser, and editor tabs to their host split groups', () => {
const patch = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot(
[
{
type: 'terminal',
id: `host-terminal::${LEAF_ID}`,
title: 'host shell',
parentTabId: 'host-terminal',
leafId: LEAF_ID,
isActive: false,
status: 'ready',
terminal: 'terminal-1'
},
{
type: 'browser',
id: 'host-browser-unified',
title: 'Example Domain',
browserWorkspaceId: 'host-browser-workspace',
browserPageId: 'host-browser-page',
url: 'https://example.com/',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: false
},
{
type: 'markdown',
id: 'host-readme-unified',
title: 'README.md',
filePath: '/repo/README.md',
relativePath: 'README.md',
language: 'markdown',
mode: 'edit',
isDirty: false,
isActive: true,
sourceFileId: '/repo/README.md',
sourceFilePath: '/repo/README.md',
sourceRelativePath: 'README.md',
documentVersion: 'file:/repo/README.md'
}
],
{
activeGroupId: 'group-editor',
activeTabId: 'host-readme-unified',
activeTabType: 'markdown',
tabGroups: [
{ id: 'group-terminal', activeTabId: 'host-terminal', tabOrder: ['host-terminal'] },
{
id: 'group-browser',
activeTabId: 'host-browser-unified',
tabOrder: ['host-browser-unified']
},
{
id: 'group-editor',
activeTabId: 'host-readme-unified',
tabOrder: ['host-readme-unified']
}
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-terminal' },
second: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'group-browser' },
second: { type: 'leaf', groupId: 'group-editor' }
}
}
}
),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
const unifiedTabs = patch.unifiedTabsByWorktree?.[WT] ?? []
const terminalTab = unifiedTabs.find((tab) => tab.contentType === 'terminal')
const browserTab = unifiedTabs.find((tab) => tab.contentType === 'browser')
const editorTab = unifiedTabs.find((tab) => tab.contentType === 'editor')
expect(terminalTab).toMatchObject({ groupId: 'group-terminal' })
expect(browserTab).toMatchObject({ id: 'host-browser-unified', groupId: 'group-browser' })
expect(editorTab).toMatchObject({ id: 'host-readme-unified', groupId: 'group-editor' })
})
it('keeps retained local-only groups reachable when applying a host layout', () => {
const localTab: Tab = {
id: 'local-editor-tab',
entityId: 'local-editor-file',
groupId: 'local-group',
worktreeId: WT,
contentType: 'editor',
label: 'notes.md',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: NOW,
isPreview: false,
isPinned: false
}
const currentLayout = {
type: 'split' as const,
direction: 'horizontal' as const,
first: { type: 'leaf' as const, groupId: 'host-group-1' },
second: { type: 'leaf' as const, groupId: 'local-group' }
}
const patch = applyWebSessionTabsSnapshot(
makeState({
unifiedTabsByWorktree: { [WT]: [localTab] },
groupsByWorktree: {
[WT]: [
{
id: 'host-group-1',
worktreeId: WT,
activeTabId: null,
tabOrder: []
},
{
id: 'local-group',
worktreeId: WT,
activeTabId: localTab.id,
tabOrder: [localTab.id],
recentTabIds: [localTab.id]
}
]
},
layoutByWorktree: { [WT]: currentLayout }
}),
makeSnapshot(
[
{
type: 'terminal',
id: `host-terminal::${LEAF_ID}`,
title: 'host shell',
parentTabId: 'host-terminal',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1'
}
],
{
activeGroupId: 'host-group-1',
activeTabId: `host-terminal::${LEAF_ID}`,
activeTabType: 'terminal',
tabGroups: [
{ id: 'host-group-1', activeTabId: 'host-terminal', tabOrder: ['host-terminal'] }
],
tabGroupLayout: { type: 'leaf', groupId: 'host-group-1' }
}
),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
expect(patch.groupsByWorktree?.[WT]).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'local-group',
tabOrder: [localTab.id]
})
])
)
expect(patch.layoutByWorktree).toBeUndefined()
})
it('keeps retained local-only groups reachable when host omits layout', () => {
const localTab: Tab = {
id: 'local-editor-tab',
entityId: 'local-editor-file',
groupId: 'local-group',
worktreeId: WT,
contentType: 'editor',
label: 'notes.md',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: NOW,
isPreview: false,
isPinned: false
}
const currentLayout = {
type: 'split' as const,
direction: 'horizontal' as const,
first: { type: 'leaf' as const, groupId: 'host-group-1' },
second: { type: 'leaf' as const, groupId: 'local-group' }
}
const patch = applyWebSessionTabsSnapshot(
makeState({
unifiedTabsByWorktree: { [WT]: [localTab] },
groupsByWorktree: {
[WT]: [
{
id: 'host-group-1',
worktreeId: WT,
activeTabId: null,
tabOrder: []
},
{
id: 'local-group',
worktreeId: WT,
activeTabId: localTab.id,
tabOrder: [localTab.id],
recentTabIds: [localTab.id]
}
]
},
layoutByWorktree: { [WT]: currentLayout }
}),
makeSnapshot(
[
{
type: 'terminal',
id: `host-terminal::${LEAF_ID}`,
title: 'host shell',
parentTabId: 'host-terminal',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1'
}
],
{
activeGroupId: 'host-group-1',
activeTabId: `host-terminal::${LEAF_ID}`,
activeTabType: 'terminal',
tabGroups: [
{ id: 'host-group-1', activeTabId: 'host-terminal', tabOrder: ['host-terminal'] }
]
}
),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
expect(patch.groupsByWorktree?.[WT]).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'local-group',
tabOrder: [localTab.id]
})
])
)
expect(patch.layoutByWorktree).toBeUndefined()
})
it('preserves host pane titles without synthesizing them from tab titles', () => {
const patch = applyWebSessionTabsSnapshot(
makeState(),
@ -758,6 +1091,13 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([
'local-browser-unified'
])
expect(
resolveHostSessionTabIdForWebSessionTab(makeState(), {
environmentId: ENV,
worktreeId: WT,
tabId: 'local-browser-unified'
})
).toBe('host-browser-unified')
})
it('removes mirrored browser tabs when the host closes the page', () => {

View File

@ -14,6 +14,7 @@ import type {
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionFileTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTerminalClientTab
} from '../../../shared/runtime-types'
import type {
@ -55,6 +56,7 @@ type SnapshotFreshness = {
}
const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
const hostSessionTabIdByLocalKey = new Map<string, string>()
type TerminalSurface = RuntimeMobileSessionTerminalClientTab
type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' }
@ -63,6 +65,7 @@ type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSession
type MirroredTerminalTab = {
tab: TerminalTab
hostTabId: string
ptyIds: string[]
layout: TerminalLayoutSnapshot
}
@ -72,6 +75,7 @@ type MirroredBrowserTab = {
page: BrowserPage
remotePageId: string
unifiedTab: Tab
hostTabId: string
}
type MirroredEditorTab = {
@ -147,6 +151,26 @@ export function shouldApplyWebSessionTabsSnapshot(
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
latestSessionTabsSnapshotByWorktree.clear()
hostSessionTabIdByLocalKey.clear()
}
function hostSessionTabMappingKey(args: {
environmentId: string
worktreeId: string
tabId: string
}): string {
return `${args.environmentId}:${args.worktreeId}:${args.tabId}`
}
export function resolveHostSessionTabIdForWebSessionTab(
_state: WebSessionTabsSyncState,
args: {
environmentId: string
worktreeId: string
tabId: string
}
): string | null {
return hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? null
}
function isReadyTerminalTab(
@ -341,6 +365,7 @@ function buildMirroredTerminalTabs(
sortOrder: sortOffset + index,
createdAt: existing?.createdAt ?? now + index
},
hostTabId: parentTabId,
ptyIds,
layout: chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId)
}
@ -531,7 +556,8 @@ function buildMirroredEditorTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
state: WebSessionTabsSyncState,
groupId: string,
hostGroupIdByTabId: ReadonlyMap<string, string>,
fallbackGroupId: string,
sortOffset: number,
now: number
): MirroredEditorTab[] {
@ -547,6 +573,7 @@ function buildMirroredEditorTabs(
tab.id
)
const sourceFileId = editorSourceFileId(tab)
const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId
const file: OpenFile = {
...existingFile,
id: fileId,
@ -614,7 +641,8 @@ function buildMirroredBrowserTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
state: WebSessionTabsSyncState,
groupId: string,
hostGroupIdByTabId: ReadonlyMap<string, string>,
fallbackGroupId: string,
sortOffset: number,
now: number
): MirroredBrowserTab[] {
@ -628,6 +656,7 @@ function buildMirroredBrowserTabs(
const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId
const pageId = existing?.page.id ?? tab.browserPageId
const createdAt = existing?.page.createdAt ?? now + sortOffset + index
const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId
const title = tab.title.trim() || 'Browser'
const page: BrowserPage = {
id: pageId,
@ -663,7 +692,8 @@ function buildMirroredBrowserTabs(
workspace,
page,
remotePageId: tab.browserPageId,
unifiedTab: buildBrowserUnifiedTab(workspace, existing?.unifiedTab?.id ?? tab.id, groupId)
unifiedTab: buildBrowserUnifiedTab(workspace, existing?.unifiedTab?.id ?? tab.id, groupId),
hostTabId: tab.id
}
})
}
@ -711,6 +741,240 @@ function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set<stri
return result
}
function buildHostGroupIdByTabId(
hostGroups: readonly RuntimeMobileSessionTabGroup[] | undefined
): Map<string, string> {
const result = new Map<string, string>()
for (const group of hostGroups ?? []) {
for (const tabId of group.tabOrder) {
result.set(tabId, group.id)
}
if (group.activeTabId) {
result.set(group.activeTabId, group.id)
}
}
return result
}
function pruneTabGroupLayout(
layout: TabGroupLayoutNode | null | undefined,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return validGroupIds.has(layout.groupId) ? layout : null
}
const first = pruneTabGroupLayout(layout.first, validGroupIds)
const second = pruneTabGroupLayout(layout.second, validGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
function appendTabGroupLayout(
first: TabGroupLayoutNode | null,
second: TabGroupLayoutNode | null
): TabGroupLayoutNode | null {
if (!first) {
return second
}
if (!second) {
return first
}
return {
type: 'split',
direction: 'horizontal',
first,
second
}
}
function tabGroupLayoutEqual(
a: TabGroupLayoutNode | null | undefined,
b: TabGroupLayoutNode | null | undefined
): boolean {
if (!a || !b) {
return !a && !b
}
if (a.type !== b.type) {
return false
}
if (a.type === 'leaf') {
return b.type === 'leaf' && a.groupId === b.groupId
}
return (
b.type === 'split' &&
a.direction === b.direction &&
a.ratio === b.ratio &&
tabGroupLayoutEqual(a.first, b.first) &&
tabGroupLayoutEqual(a.second, b.second)
)
}
function mapHostRecentTabIds(
recentTabIds: readonly string[] | undefined,
hostToLocalTabId: ReadonlyMap<string, string>,
tabOrder: readonly string[]
): string[] {
if (!recentTabIds || recentTabIds.length === 0) {
return []
}
const valid = new Set(tabOrder)
return sanitizeRecentTabIds(
recentTabIds.map((tabId) => hostToLocalTabId.get(tabId) ?? '').filter(Boolean),
[...valid]
)
}
function buildHostToLocalTabIdMap({
terminalSurfaces,
terminalTabs,
browserTabs,
editorTabs
}: {
terminalSurfaces: readonly TerminalSurface[]
terminalTabs: readonly TerminalTab[]
browserTabs: readonly MirroredBrowserTab[]
editorTabs: readonly MirroredEditorTab[]
}): Map<string, string> {
const hostToLocal = new Map<string, string>()
const terminalIds = new Set(terminalTabs.map((tab) => tab.id))
for (const surface of terminalSurfaces) {
const localId = toWebTerminalSurfaceTabId(surface.parentTabId)
if (terminalIds.has(localId)) {
hostToLocal.set(surface.parentTabId, localId)
hostToLocal.set(surface.id, localId)
}
}
for (const entry of browserTabs) {
hostToLocal.set(entry.hostTabId, entry.unifiedTab.id)
hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id)
}
for (const entry of editorTabs) {
hostToLocal.set(entry.hostTabId, entry.unifiedTab.id)
}
return hostToLocal
}
function updateHostSessionTabIdMappings(args: {
environmentId: string
worktreeId: string
terminalSurfaces: readonly TerminalSurface[]
terminalTabs: readonly TerminalTab[]
browserTabs: readonly MirroredBrowserTab[]
editorTabs: readonly MirroredEditorTab[]
}): void {
const keyPrefix = `${args.environmentId}:${args.worktreeId}:`
for (const key of hostSessionTabIdByLocalKey.keys()) {
if (key.startsWith(keyPrefix)) {
hostSessionTabIdByLocalKey.delete(key)
}
}
const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id))
for (const surface of args.terminalSurfaces) {
const localId = toWebTerminalSurfaceTabId(surface.parentTabId)
if (mirroredTerminalIds.has(localId)) {
hostSessionTabIdByLocalKey.set(
hostSessionTabMappingKey({ ...args, tabId: localId }),
surface.parentTabId
)
}
}
for (const entry of args.browserTabs) {
hostSessionTabIdByLocalKey.set(
hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }),
entry.hostTabId
)
}
for (const entry of args.editorTabs) {
hostSessionTabIdByLocalKey.set(
hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }),
entry.hostTabId
)
}
}
function buildMirroredHostGroups({
currentGroups,
hostGroups,
hostToLocalTabId,
mirroredUnifiedIds,
nextActiveUnifiedTabId,
validUnifiedTabIds,
worktreeId
}: {
currentGroups: readonly TabGroup[]
hostGroups: readonly RuntimeMobileSessionTabGroup[]
hostToLocalTabId: ReadonlyMap<string, string>
mirroredUnifiedIds: ReadonlySet<string>
nextActiveUnifiedTabId: string | null
validUnifiedTabIds: ReadonlySet<string>
worktreeId: string
}): TabGroup[] | null {
const strippedGroups = currentGroups.map((group) => {
const tabOrder = group.tabOrder.filter(
(tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId)
)
return {
...group,
tabOrder,
recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder)
}
})
const groupsById = new Map(strippedGroups.map((group) => [group.id, group]))
const orderedGroups: TabGroup[] = []
const seen = new Set<string>()
for (const hostGroup of hostGroups) {
const existing = groupsById.get(hostGroup.id)
const localHostOrder = hostGroup.tabOrder
.map((tabId) => hostToLocalTabId.get(tabId))
.filter((tabId): tabId is string => tabId !== undefined && validUnifiedTabIds.has(tabId))
const tabOrder = [
...(existing?.tabOrder.filter((tabId) => !localHostOrder.includes(tabId)) ?? []),
...localHostOrder
]
if (tabOrder.length === 0) {
continue
}
const activeFromHost =
hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null
const activeTabId =
nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId)
? nextActiveUnifiedTabId
: activeFromHost && tabOrder.includes(activeFromHost)
? activeFromHost
: existing?.activeTabId && tabOrder.includes(existing.activeTabId)
? existing.activeTabId
: (tabOrder[0] ?? null)
orderedGroups.push({
id: hostGroup.id,
worktreeId,
tabOrder,
activeTabId,
recentTabIds: activeTabId
? pushRecentTabId(
mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder),
activeTabId
)
: []
})
seen.add(hostGroup.id)
}
for (const group of strippedGroups) {
if (!seen.has(group.id) && group.tabOrder.length > 0) {
orderedGroups.push(group)
}
}
return orderedGroups.length > 0 ? orderedGroups : null
}
function sameStringArray(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
@ -1064,12 +1328,14 @@ export function applyWebSessionTabsSnapshot(
)
const targetGroupId = chooseTargetGroupId(state, snapshot)
const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups)
const readyBrowserTabs = snapshot.tabs.filter(isReadyBrowserTab)
const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId))
const mirroredBrowserTabs = buildMirroredBrowserTabs(
snapshot,
environmentId,
state,
hostGroupIdByTabId,
targetGroupId,
mirroredTerminalTabEntries.length,
now
@ -1109,6 +1375,7 @@ export function applyWebSessionTabsSnapshot(
snapshot,
environmentId,
state,
hostGroupIdByTabId,
targetGroupId,
mirroredTerminalTabEntries.length + mirroredBrowserTabs.length,
now
@ -1161,8 +1428,8 @@ export function applyWebSessionTabsSnapshot(
}
return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id)
})
const mirroredTerminalUnifiedTabs = mirroredTerminalTabEntries.map((tab) =>
buildTerminalUnifiedTab(tab, targetGroupId)
const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) =>
buildTerminalUnifiedTab(entry.tab, hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId)
)
const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab)
const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab)
@ -1256,12 +1523,37 @@ export function applyWebSessionTabsSnapshot(
nextActiveTerminalId)
: nextActiveTerminalId
const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id))
const hostToLocalTabId = buildHostToLocalTabIdMap({
terminalSurfaces: terminalSurfaceTabs,
terminalTabs: mirroredTerminalTabEntries,
browserTabs: mirroredBrowserTabs,
editorTabs: mirroredEditorTabs
})
updateHostSessionTabIdMappings({
environmentId,
worktreeId,
terminalSurfaces: terminalSurfaceTabs,
terminalTabs: mirroredTerminalTabEntries,
browserTabs: mirroredBrowserTabs,
editorTabs: mirroredEditorTabs
})
const currentGroups = state.groupsByWorktree[worktreeId] ?? []
const nextGroups = (() => {
if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) {
return null
}
if (snapshot.tabGroups && snapshot.tabGroups.length > 0) {
return buildMirroredHostGroups({
currentGroups,
hostGroups: snapshot.tabGroups,
hostToLocalTabId,
mirroredUnifiedIds,
nextActiveUnifiedTabId,
validUnifiedTabIds,
worktreeId
})
}
const strippedGroups = currentGroups.map((group) => ({
...group,
tabOrder: group.tabOrder.filter(
@ -1312,9 +1604,15 @@ export function applyWebSessionTabsSnapshot(
...retainedUnifiedTabs.map((tab) => tab.id),
...mirroredUnifiedTabs.map((tab) => tab.id)
])
const hostTabBarOrder =
snapshot.tabGroups?.flatMap((group) =>
group.tabOrder
.map((tabId) => hostToLocalTabId.get(tabId))
.filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId))
) ?? []
return [
...current.filter((tabId) => validTabBarIds.has(tabId) && !mirroredUnifiedIds.has(tabId)),
...mirroredUnifiedTabs.map((tab) => tab.id)
...(hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id))
]
})()
@ -1436,17 +1734,56 @@ export function applyWebSessionTabsSnapshot(
nextGroups,
sameGroups
)
const nextActiveGroupId =
nextGroups?.find((group) => group.id === snapshot.activeGroupId)?.id ??
nextGroups?.find((group) => group.activeTabId === nextActiveUnifiedTabId)?.id ??
nextGroups?.[0]?.id ??
null
const nextActiveGroupIdByWorktree =
nextGroups && state.activeGroupIdByWorktree[worktreeId] !== targetGroupId
? { ...state.activeGroupIdByWorktree, [worktreeId]: targetGroupId }
nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId
? { ...state.activeGroupIdByWorktree, [worktreeId]: nextActiveGroupId ?? targetGroupId }
: state.activeGroupIdByWorktree
const nextLayoutByWorktree =
nextGroups && !state.layoutByWorktree[worktreeId]
? {
...state.layoutByWorktree,
[worktreeId]: { type: 'leaf' as const, groupId: targetGroupId }
}
: state.layoutByWorktree
const nextLayoutByWorktree = (() => {
if (!nextGroups) {
return state.layoutByWorktree
}
const validGroupIds = new Set(nextGroups.map((group) => group.id))
const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds)
const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId }
const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined)
const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? [])
const extraGroupIds = new Set(
nextGroups
.map((group) => group.id)
.filter((groupId) =>
hostLayout
? !hostLayoutGroupIds.has(groupId)
: snapshot.tabGroups && snapshot.tabGroups.length > 0
? !hostGroupIds.has(groupId)
: false
)
)
const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds)
const hostBaseLayout =
hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null)
const fallbackLayout =
appendTabGroupLayout(hostBaseLayout, localExtraLayout) ??
(snapshot.tabGroups && snapshot.tabGroups.length > 0
? defaultLeafLayout
: state.layoutByWorktree[worktreeId]
? null
: defaultLeafLayout)
if (!fallbackLayout) {
return state.layoutByWorktree
}
if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) {
return state.layoutByWorktree
}
return {
...state.layoutByWorktree,
[worktreeId]: fallbackLayout
}
})()
const nextTabBarOrderByWorktree = withWorktreeEntry(
state.tabBarOrderByWorktree,
worktreeId,

View File

@ -834,6 +834,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onFocusTerminal: () => noopUnsubscribe,
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
onMoveSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,

View File

@ -7,6 +7,7 @@ import type {
BrowserSessionProfileSource,
GitWorktreeInfo,
Repo,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
Worktree,
WorktreeLineage,
@ -172,6 +173,36 @@ export type RuntimeMobileSessionClientTab =
| RuntimeMobileSessionFileTab
| RuntimeMobileSessionBrowserTab
export type RuntimeMobileSessionTabGroup = {
id: string
activeTabId: string | null
tabOrder: string[]
recentTabIds?: string[]
}
type RuntimeMobileSessionTabMoveBase = {
tabId: string
targetGroupId: string
}
export type RuntimeMobileSessionTabMove =
| (RuntimeMobileSessionTabMoveBase & {
kind: 'reorder'
tabOrder: string[]
})
| (RuntimeMobileSessionTabMoveBase & {
kind: 'move-to-group'
index?: number
})
| (RuntimeMobileSessionTabMoveBase & {
kind: 'split'
splitDirection: 'left' | 'right' | 'up' | 'down'
})
export type RuntimeMobileSessionTabMoveResult = {
moved: true
}
export type RuntimeMobileSessionTabsSnapshot = {
worktree: string
publicationEpoch: string
@ -179,6 +210,8 @@ export type RuntimeMobileSessionTabsSnapshot = {
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionSnapshotTab[]
}
@ -189,6 +222,8 @@ export type RuntimeMobileSessionTabsResult = {
activeGroupId: string | null
activeTabId: string | null
activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
tabs: RuntimeMobileSessionClientTab[]
}