From fbf792c31ce401650db573cc1dc14794cdb6b661 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 18 May 2026 16:13:40 -0400 Subject: [PATCH] Fix remote session tab split parity (#2252) Co-authored-by: Orca --- src/main/runtime/orca-runtime.ts | 217 ++++++++++- .../session-tabs-move-validation.test.ts | 197 ++++++++++ .../runtime/rpc/methods/session-tabs.test.ts | 114 ++++++ src/main/runtime/rpc/methods/session-tabs.ts | 67 ++++ src/main/runtime/runtime-rpc.ts | 1 + .../window/attach-main-window-services.ts | 3 + src/preload/api-types.ts | 5 + src/preload/index.ts | 13 + src/renderer/src/App.tsx | 1 + .../components/tab-group/useTabDragSplit.ts | 61 ++- .../tab-group/useTabGroupWorkspaceModel.ts | 2 + src/renderer/src/hooks/useIpcEvents.test.ts | 13 +- src/renderer/src/hooks/useIpcEvents.ts | 18 +- .../sync-runtime-graph-scheduling.test.ts | 36 ++ ...sync-runtime-graph-terminal-layout.test.ts | 211 ++++++++++ .../src/runtime/sync-runtime-graph.ts | 180 ++++++++- .../src/runtime/web-runtime-session.test.ts | 287 +++++++++++++- .../src/runtime/web-runtime-session.ts | 109 +++++- .../src/runtime/web-session-tabs-sync.test.ts | 340 ++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 367 +++++++++++++++++- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/runtime-types.ts | 35 ++ 22 files changed, 2236 insertions(+), 42 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2631ca36b..cb6620cdb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 @@ -1229,6 +1234,130 @@ export class OrcaRuntimeService { return { closed: true } } + async moveMobileSessionTab( + worktreeSelector: string, + move: RuntimeMobileSessionTabMove + ): Promise { + 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() + 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 { + const ids = new Set() + 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 { 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 { + const ids = new Set() + 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 + ): 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 } } diff --git a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts new file mode 100644 index 000000000..f593e91e4 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts @@ -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 + } + ).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() + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 352dcaa83..c1da8871b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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)[] = [] diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index bbb8c85c3..eddfb846a 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 950f36a83..44a7bd750 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 46fff3e6d..9c4cea153 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e41fa6a00..fc17d2062 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index a85e9670e..e77cbdd07 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5f4a1620b..eee045cc5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 && diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index fda59371c..448c26832 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -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 + }) + } + } } } } diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 6efc351b1..678b22eb0 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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 }) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0d4c2550b..353f115f1 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 863ddb34f..c91b5a764 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() diff --git a/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts b/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts index 7c9fb69ff..ed280b256 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts @@ -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 { 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) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts index 888d6b042..264b67912 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts @@ -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({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 29c14051b..9759f0ae1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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 +): 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() + 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 { @@ -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) { diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index c173d9719..b2f85022a 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 9543e9547..f51aaab98 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -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 { @@ -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 { + 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) + 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 }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index d50e90b0f..daa966143 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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 + + 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 + + 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 + + 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 + + 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', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8b99ef4e3..ca0f2e5d7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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() +const hostSessionTabIdByLocalKey = new Map() 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, + 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, + 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 { + const result = new Map() + 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 +): 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, + 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 { + const hostToLocal = new Map() + 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 + mirroredUnifiedIds: ReadonlySet + nextActiveUnifiedTabId: string | null + validUnifiedTabIds: ReadonlySet + 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() + + 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, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 8f88f7bf0..c820b9cc4 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -834,6 +834,7 @@ function createWebUiApi(): NonNullable['ui']> { onFocusTerminal: () => noopUnsubscribe, onFocusEditorTab: () => noopUnsubscribe, onCloseSessionTab: () => noopUnsubscribe, + onMoveSessionTab: () => noopUnsubscribe, onOpenFileFromMobile: () => noopUnsubscribe, onOpenDiffFromMobile: () => noopUnsubscribe, onMobileMarkdownRequest: () => noopUnsubscribe, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6bb3df71e..0f7e55a01 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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[] }