Fix remote session tab split parity (#2252)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
b25ea6b5bd
commit
fbf792c31c
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)[] = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: () => () => {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onFocusTerminal: () => noopUnsubscribe,
|
||||
onFocusEditorTab: () => noopUnsubscribe,
|
||||
onCloseSessionTab: () => noopUnsubscribe,
|
||||
onMoveSessionTab: () => noopUnsubscribe,
|
||||
onOpenFileFromMobile: () => noopUnsubscribe,
|
||||
onOpenDiffFromMobile: () => noopUnsubscribe,
|
||||
onMobileMarkdownRequest: () => noopUnsubscribe,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue