diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts index 6dfed428e75..75ae9350c3b 100644 --- a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts @@ -1,6 +1,10 @@ import { useAppStore } from '../../store' import type { TabSplitDirection } from '../../store/slices/tabs' import { mirrorWebRuntimeTabMove } from './web-runtime-tab-move-mirror' +import { + isDesktopCreatedTabInFrameDrawnServerWorkspace, + serverTabCount +} from '@/lib/frame-drawn-desktop-created-tabs' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { dropTabThroughLayoutOwner } from '../tab-group/tab-group-layout-owner-commands' @@ -26,8 +30,12 @@ export function canMoveTabToNewPaneColumnFromState( return false } // Why: mirror dropUnifiedTab — splitting the only tab in a group onto an - // adjacent split pane is a layout no-op the store rejects. - return group.tabOrder.length > 1 + // adjacent split pane is a layout no-op the store rejects. A tab this desktop created never + // splits off, and counts for nothing: the server's group lacks it. + return ( + !isDesktopCreatedTabInFrameDrawnServerWorkspace(worktreeId, unifiedTabId) && + serverTabCount(worktreeId, group.tabOrder) > 1 + ) } return false } diff --git a/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts index 5d40f6da463..8e2821e935d 100644 --- a/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts +++ b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts @@ -3,6 +3,8 @@ import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-ow import { useAppStore } from '../../store' import { isWebRuntimeSessionActive } from '../../runtime/web-runtime-session-environment' import { moveWebRuntimeSessionTab } from '../../runtime/web-runtime-session-tab-move' +import { isDesktopCreatedTabInFrameDrawnServerWorkspace } from '@/lib/frame-drawn-desktop-created-tabs' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' export function mirrorWebRuntimeTabMove( args: RuntimeMobileSessionTabMove & { @@ -16,6 +18,14 @@ export function mirrorWebRuntimeTabMove( if (!isWebRuntimeSessionActive(environmentId)) { return } + // Switch point: a tab only this desktop has moves here only; the server never had it, and its + // frames keep it where this window drew it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + isDesktopCreatedTabInFrameDrawnServerWorkspace(args.worktreeId, args.tabId) + ) { + return + } void moveWebRuntimeSessionTab({ ...args, environmentId diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts index 686b7e3f241..904d92d3227 100644 --- a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.ts @@ -16,6 +16,7 @@ import { import { tabSelectionKey } from '@/store/slices/tabs/moved-tab-selection' import { layoutFramesFlowing } from '@/runtime/layout-frame-position' import { splitRatioIntentAt } from '../../../../shared/workspace-layout/split-ratio-intents' +import { serverTabCount } from '@/lib/frame-drawn-desktop-created-tabs' import { tabOrderAnchor } from '../../../../shared/workspace-layout/tab-order' /** A tab drop as the window resolved it: within its group, into another, or beside a group. */ @@ -86,7 +87,7 @@ function commandFor(worktreeId: string, drop: TabDrop): WindowLayoutCommand | nu sourceGroupId: source.id, targetGroupId: groupId, splitDirection: drop.direction, - sourceTabCount: source.tabOrder.length, + sourceTabCount: serverTabCount(worktreeId, source.tabOrder), layout: state.layoutByWorktree[worktreeId] }) ) { diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts index d7bab07f12f..10c3d8b78ac 100644 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -12,6 +12,10 @@ import { isTabDragData, type TabDragItemData } from './tab-drag-data' +import { + isDesktopCreatedTabInFrameDrawnServerWorkspace, + serverTabCount +} from '@/lib/frame-drawn-desktop-created-tabs' export type TabGroupPanelGeometryEntry = { groupId: string @@ -146,6 +150,10 @@ export function resolvePanelEdgePaneColumnSplit({ panelRect?: DOMRect | null bodyRect?: DOMRect | null }): PaneColumnSplitTarget | null { + // A tab this desktop created has no place of its own in the server's groups: no split. + if (isDesktopCreatedTabInFrameDrawnServerWorkspace(worktreeId, activeDrag.unifiedTabId)) { + return null + } const panelRect = providedPanelRect ?? getTabGroupPanelRect(targetGroupId, worktreeId) if (!panelRect) { return null @@ -179,7 +187,7 @@ export function resolvePanelEdgePaneColumnSplit({ sourceGroupId: activeDrag.groupId, targetGroupId, splitDirection: zone, - sourceTabCount: sourceGroup?.tabOrder.length ?? 0, + sourceTabCount: serverTabCount(worktreeId, sourceGroup?.tabOrder ?? []), layout: layoutByWorktree[worktreeId] }) ) { diff --git a/src/renderer/src/lib/desktop-created-server-tabs-switch-off.test.ts b/src/renderer/src/lib/desktop-created-server-tabs-switch-off.test.ts new file mode 100644 index 00000000000..296986603d2 --- /dev/null +++ b/src/renderer/src/lib/desktop-created-server-tabs-switch-off.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DESKTOP_CREATED_SERVER_TAB_HOLDS, + forgetDesktopCreatedServerTabsOf, + noteDesktopCreatedServerTab, + pruneDesktopCreatedServerTabs +} from './desktop-created-server-tabs' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false })) + +describe('the desktop-created server tab record in a switch-off build', () => { + it('never reads nor keeps the record', () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() } + vi.stubGlobal('localStorage', storage) + noteDesktopCreatedServerTab('web-env-1', 'repo::/worktree', 'simulator-1') + pruneDesktopCreatedServerTabs({ unifiedTabsByWorktree: {} }) + forgetDesktopCreatedServerTabsOf('repo::/worktree') + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld('repo::/worktree', 'simulator-1')).toBe(false) + expect(storage.getItem).not.toHaveBeenCalled() + expect(storage.setItem).not.toHaveBeenCalled() + expect(storage.removeItem).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/desktop-created-server-tabs.test.ts b/src/renderer/src/lib/desktop-created-server-tabs.test.ts new file mode 100644 index 00000000000..36c32698556 --- /dev/null +++ b/src/renderer/src/lib/desktop-created-server-tabs.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } +const storage = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key) +}) + +import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' +import { + DESKTOP_CREATED_SERVER_TAB_HOLDS, + _reloadDesktopCreatedServerTabsForTests, + _resetDesktopCreatedServerTabsForTests, + forgetDesktopCreatedServerTab, + forgetDesktopCreatedServerTabsOf, + forgetPublishedServerTabs, + isDesktopCreatedServerTab, + noteDesktopCreatedServerTab +} from './desktop-created-server-tabs' + +const ENV = 'web-env-1' +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const held = (workspace: string, id: string): boolean => + DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(workspace, id) + +beforeEach(() => { + storage.clear() + _resetDesktopCreatedServerTabsForTests() +}) + +describe('the desktop-created server tab record', () => { + it('records an id per workspace: one workspace’s publish or removal leaves the other’s', () => { + noteDesktopCreatedServerTab(ENV, WT1, 'tab-x') + noteDesktopCreatedServerTab(ENV, WT2, 'tab-x') + expect([held(WT1, 'tab-x'), held(WT2, 'tab-x')]).toEqual([true, true]) + + forgetPublishedServerTabs(WT1, ['tab-x']) + expect([held(WT1, 'tab-x'), held(WT2, 'tab-x')]).toEqual([false, true]) + + noteDesktopCreatedServerTab(ENV, WT1, 'tab-x') + forgetDesktopCreatedServerTabsOf(WT2) + expect([held(WT1, 'tab-x'), held(WT2, 'tab-x')]).toEqual([true, false]) + }) + + it('forgets a workspace’s every id, and a closed id in any workspace', () => { + noteDesktopCreatedServerTab(ENV, WT1, 'tab-a') + noteDesktopCreatedServerTab(ENV, WT1, 'tab-b') + noteDesktopCreatedServerTab(ENV, WT2, 'tab-c') + forgetDesktopCreatedServerTabsOf(WT1) + expect([held(WT1, 'tab-a'), held(WT1, 'tab-b'), held(WT2, 'tab-c')]).toEqual([ + false, + false, + true + ]) + forgetDesktopCreatedServerTab('tab-c') + expect(held(WT2, 'tab-c')).toBe(false) + }) + + it('names a tab recorded by its entity id by its tab-bar id too', () => { + noteDesktopCreatedServerTab(ENV, WT1, 'browser-workspace-1') + const tabs = [{ id: 'tab-bar-1', entityId: 'browser-workspace-1' }] + expect(isDesktopCreatedServerTab(tabs, WT1, 'tab-bar-1')).toBe(true) + expect(isDesktopCreatedServerTab(tabs, WT2, 'tab-bar-1')).toBe(false) + }) + + it('drops the removed worktree’s ids when the worktree is removed, and only those', async () => { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + } + }) + noteDesktopCreatedServerTab(ENV, WT1, 'simulator-1') + noteDesktopCreatedServerTab(ENV, WT2, 'simulator-2') + + const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null }) + + expect(result).toEqual({ ok: true }) + expect([held(WT1, 'simulator-1'), held(WT2, 'simulator-2')]).toEqual([false, true]) + // Gone from this desktop's storage too: a reload brings back only the other worktree's. + expect([...storage.values()].join()).not.toContain('simulator-1') + _reloadDesktopCreatedServerTabsForTests() + expect([held(WT1, 'simulator-1'), held(WT2, 'simulator-2')]).toEqual([false, true]) + }) +}) diff --git a/src/renderer/src/lib/desktop-created-server-tabs.ts b/src/renderer/src/lib/desktop-created-server-tabs.ts new file mode 100644 index 00000000000..896724e3b41 --- /dev/null +++ b/src/renderer/src/lib/desktop-created-server-tabs.ts @@ -0,0 +1,175 @@ +// Tabs this desktop created in a paired server's workspace that the server's layout never lists (a +// simulator, a client-hosted browser tab), by tab-bar or entity id: that server's frame builds keep +// them as drawn, inside the server's own groups (ledgers L273, L274). A tab the server published is +// not one: it follows the server's frames. Kept in this desktop's localStorage, by server and +// workspace, so a reload keeps the tabs its session restores; never a session field nor sent. + +import type { Tab } from '../../../shared/tab-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { AppState } from '@/store/types' +import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs' + +const STORAGE_KEY = 'orca.desktop-created-server-tabs.v1' + +type Entry = { environmentId: string; worktreeId: string; ids: Set } + +/** By `environmentId\0worktreeId`; loaded on first use. */ +let records: Map | undefined + +function recordMap(): Map { + // Switch point: only a layout-owner build draws a server's frames; any other never reads or + // keeps the record. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return new Map() + } + if (records) { + return records + } + records = new Map() + try { + const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') + for (const entry of Array.isArray(saved) ? saved : []) { + if ( + Array.isArray(entry) && + typeof entry[0] === 'string' && + typeof entry[1] === 'string' && + Array.isArray(entry[2]) + ) { + const ids = entry[2].filter((id: unknown): id is string => typeof id === 'string') + records.set(`${entry[0]}\0${entry[1]}`, { + environmentId: entry[0], + worktreeId: entry[1], + ids: new Set(ids) + }) + } + } + } catch { + // Storage may be unavailable: the record then lasts until reload. + } + return records +} + +/** Drops emptied entries, then writes the record through. */ +function save(map: Map): void { + for (const [key, entry] of map) { + if (entry.ids.size === 0) { + map.delete(key) + } + } + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } + try { + const entries = [...map.values()].map((e) => [e.environmentId, e.worktreeId, [...e.ids]]) + if (entries.length === 0) { + localStorage.removeItem(STORAGE_KEY) + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)) + } + } catch {} +} + +const entriesOf = (workspace: string): Entry[] => + [...recordMap().values()].filter((entry) => entry.worktreeId === workspace) + +export function noteDesktopCreatedServerTab( + environmentId: string, + workspace: string, + id: string +): void { + const map = recordMap() + const key = `${environmentId}\0${workspace}` + const entry = map.get(key) ?? { environmentId, worktreeId: workspace, ids: new Set() } + if (map.get(key) === entry && entry.ids.has(id)) { + return + } + map.set(key, entry) + entry.ids.add(id) + save(map) +} + +/** Deletes `ids` from `entries`; writes through when any was there. */ +function forgetIn(entries: readonly Entry[], ids: Iterable): void { + let changed = false + for (const id of ids) { + for (const entry of entries) { + changed = entry.ids.delete(id) || changed + } + } + if (changed) { + save(recordMap()) + } +} + +export function forgetDesktopCreatedServerTab(id: string): void { + forgetIn([...recordMap().values()], [id]) +} + +/** A removed workspace's tabs. */ +export function forgetDesktopCreatedServerTabsOf(workspace: string): void { + const entries = entriesOf(workspace) + forgetIn( + entries, + entries.flatMap((entry) => [...entry.ids]) + ) +} + +/** The server's frame now lists these ids: from here on its frames draw them. */ +export function forgetPublishedServerTabs(workspace: string, ids: Iterable): void { + const entries = entriesOf(workspace) + if (entries.length > 0) { + forgetIn(entries, ids) + } +} + +/** After a restore, before any frame: an id whose tab the session did not restore is gone. */ +export function pruneDesktopCreatedServerTabs( + state: Pick +): void { + const map = recordMap() + for (const entry of map.values()) { + const rows = new Set( + (state.unifiedTabsByWorktree[entry.worktreeId] ?? []).flatMap((tab) => [tab.id, tab.entityId]) + ) + forgetIn( + [entry], + [...entry.ids].filter((id) => !rows.has(id)) + ) + } +} + +const isCreated = (workspace: string, id: string): boolean => + entriesOf(workspace).some((entry) => entry.ids.has(id)) + +/** The servers whose record names `id` in `workspace`. */ +export function desktopCreatedServerTabEnvironments(workspace: string, id: string): string[] { + return entriesOf(workspace).flatMap((entry) => (entry.ids.has(id) ? [entry.environmentId] : [])) +} + +/** Whether `tabId` (a tab-bar id) is such a tab, also when it is recorded by its entity id. */ +export function isDesktopCreatedServerTab( + tabs: readonly Pick[] | undefined, + workspace: string, + tabId: string +): boolean { + const entityId = tabs?.find((tab) => tab.id === tabId)?.entityId + return isCreated(workspace, tabId) || (entityId !== undefined && isCreated(workspace, entityId)) +} + +export const DESKTOP_CREATED_SERVER_TAB_HOLDS: HeldDrawnTabs = { + isTabHeld: isCreated, + isGroupLayoutHeld: () => false, + keepsInMainGroups: isCreated +} + +/** As a reload: the in-memory record is gone, localStorage stays. */ +export function _reloadDesktopCreatedServerTabsForTests(): void { + records = undefined +} + +export function _resetDesktopCreatedServerTabsForTests(): void { + records = new Map() + try { + localStorage.removeItem(STORAGE_KEY) + } catch {} +} diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index 055ba8fc45d..94fb223136b 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -14,6 +14,8 @@ import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { + frameDrawnServerOfSimulatorTab, + noteSimulatorTabCreatedHere, openingSimulatorTabId, openSimulatorTabThroughLayoutOwner } from './simulator-tab-layout-owner' @@ -134,7 +136,16 @@ export function ensureSimulatorTab( options?.placement === 'rightSplit' && shouldSurface ? findReusableRightSplitGroupId(store.layoutByWorktree[worktreeId], sourceGroupId) : null - const rightSplit = options?.placement === 'rightSplit' && shouldSurface && !reusableRightGroupId + // Switch point: a paired server's frame-drawn workspace has no group of this desktop's own, so + // the tab opens in the group it would split beside. + const serverEnvironmentId = ORCA_LAYOUT_OWNER_IS_MAIN + ? frameDrawnServerOfSimulatorTab(store, worktreeId) + : null + const rightSplit = + options?.placement === 'rightSplit' && + shouldSurface && + !reusableRightGroupId && + !serverEnvironmentId const groupId = reusableRightGroupId ?? sourceGroupId const drawHere = (tabId?: string): string | null => { // Why: publish the simulator directly in its split group. A two-step @@ -185,7 +196,11 @@ export function ensureSimulatorTab( return sent } } - return drawHere() + const drawnId = drawHere() + if (drawnId && serverEnvironmentId) { + noteSimulatorTabCreatedHere(serverEnvironmentId, worktreeId, drawnId) + } + return drawnId } function hostOf(executionHostId: ExecutionHostId | undefined): { diff --git a/src/renderer/src/lib/frame-drawn-desktop-created-tabs.ts b/src/renderer/src/lib/frame-drawn-desktop-created-tabs.ts new file mode 100644 index 00000000000..2eeb1632f5a --- /dev/null +++ b/src/renderer/src/lib/frame-drawn-desktop-created-tabs.ts @@ -0,0 +1,22 @@ +// Tabs this desktop created (desktop-created-server-tabs) in a workspace their server's frames draw +// now: the server's groups have no place of their own for them. Kept out of the record so store +// slices can import the record without the frame runtime. + +import { isFrameDrawnServerWorkspace } from '@/runtime/web-session-tabs-sync/frame-drawn-server-workspaces' +import { desktopCreatedServerTabEnvironments } from './desktop-created-server-tabs' + +/** By tab-bar or entity id. Such a tab moves only within the server's groups: it never splits off. */ +export function isDesktopCreatedTabInFrameDrawnServerWorkspace( + workspace: string, + id: string +): boolean { + return desktopCreatedServerTabEnvironments(workspace, id).some((environmentId) => + isFrameDrawnServerWorkspace(environmentId, workspace) + ) +} + +/** A group's tabs the server's group has too: all but this desktop's own. */ +export function serverTabCount(workspace: string, tabOrder: readonly string[]): number { + return tabOrder.filter((id) => !isDesktopCreatedTabInFrameDrawnServerWorkspace(workspace, id)) + .length +} diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.ts b/src/renderer/src/lib/simulator-tab-layout-owner.ts index 1f1c5d802d3..eb47b329eff 100644 --- a/src/renderer/src/lib/simulator-tab-layout-owner.ts +++ b/src/renderer/src/lib/simulator-tab-layout-owner.ts @@ -5,6 +5,9 @@ import { createBrowserUuid } from './browser-uuid' import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' import { afterPendingTab, isPendingTab } from '@/runtime/layout-owner-pending-tabs' import type { AppState } from '@/store/types' +import { isFrameDrawnServerWorkspace } from '@/runtime/web-session-tabs-sync/frame-drawn-server-workspaces' +import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner' +import { noteDesktopCreatedServerTab } from './desktop-created-server-tabs' export type SimulatorTabOpen = { worktreeId: string @@ -57,3 +60,21 @@ export function openSimulatorTabThroughLayoutOwner( }) return tabId } + +/** The paired server whose frames draw `worktreeId` now: its simulator tab is this desktop's own. */ +export function frameDrawnServerOfSimulatorTab(state: AppState, worktreeId: string): string | null { + const environmentId = runtimeEnvironmentIdUnlessTwin(state, worktreeId) + return environmentId && isFrameDrawnServerWorkspace(environmentId, worktreeId) + ? environmentId + : null +} + +/** A simulator tab this desktop drew in that server's workspace: its frames keep it (L273). */ +export function noteSimulatorTabCreatedHere( + environmentId: string, + worktreeId: string, + tabId: string +): void { + // L290 deletes this: post-flip, the host runs the simulator and streams it (#6701). + noteDesktopCreatedServerTab(environmentId, worktreeId, tabId) +} diff --git a/src/renderer/src/runtime/held-tab-group-follow.ts b/src/renderer/src/runtime/held-tab-group-follow.ts new file mode 100644 index 00000000000..6c433096f47 --- /dev/null +++ b/src/renderer/src/runtime/held-tab-group-follow.ts @@ -0,0 +1,53 @@ +// A frame build that moved a held tab the user was on out of a group main lacks (opened to the +// side) leaves the user's group gone: they follow the tab into main's group it now shows in. + +import type { AppState } from '@/store/types' +import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs' + +type FollowState = Pick< + AppState, + 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'activeGroupIdByWorktree' +> + +/** Each workspace in `keys` whose user group the build removed, with the group to focus. */ +function heldTabGroupFollows( + before: FollowState, + after: FollowState, + keys: readonly string[], + held: HeldDrawnTabs +): { worktreeId: string; groupId: string }[] { + return keys.flatMap((worktreeId) => { + const userGroupId = before.activeGroupIdByWorktree[worktreeId] + const groups = after.groupsByWorktree[worktreeId] ?? [] + if (!userGroupId || groups.some((group) => group.id === userGroupId)) { + return [] + } + const tabId = (before.groupsByWorktree[worktreeId] ?? []).find( + (group) => group.id === userGroupId + )?.activeTabId + if (!tabId || !held.keepsInMainGroups?.(worktreeId, tabId)) { + return [] + } + const groupId = after.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.id === tabId + )?.groupId + return groupId && groupId !== after.activeGroupIdByWorktree[worktreeId] + ? [{ worktreeId, groupId }] + : [] + }) +} + +/** Focuses, when called, the groups the user follows their held tabs into after a build. */ +export function heldTabGroupFocus( + before: FollowState, + store: { getState: () => AppState }, + keys: readonly string[], + held: HeldDrawnTabs +): () => void { + const follows = heldTabGroupFollows(before, store.getState(), keys, held) + return () => { + for (const { worktreeId, groupId } of follows) { + store.getState().focusGroup(worktreeId, groupId) + } + } +} diff --git a/src/renderer/src/runtime/layout-frame-stream.ts b/src/renderer/src/runtime/layout-frame-stream.ts index e397790357c..58be405e699 100644 --- a/src/renderer/src/runtime/layout-frame-stream.ts +++ b/src/renderer/src/runtime/layout-frame-stream.ts @@ -2,6 +2,7 @@ // window's ids. Only the local stream's positions answer this window's layout.apply replies. import type { AppState } from '@/store/types' +import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' import { @@ -16,6 +17,8 @@ import { framesDrawWorkspace } from './workspace-layout-frame-drawn-workspaces' export type LayoutFrameSource = { target: RuntimeClientTarget draws: (state: AppState) => (key: string) => boolean + /** Tabs this window draws that the source's frames never carry: every build keeps them as drawn. */ + heldTabs?: HeldDrawnTabs /** Frames under this window's ids; the local stream's already are. */ translate?: (frame: WorkspaceLayoutChangeFrame) => WorkspaceLayoutChangeFrame onStatus?: (status: RuntimeLayoutStreamStatus) => void diff --git a/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts b/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts new file mode 100644 index 00000000000..494a035b3e3 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts @@ -0,0 +1,548 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type * as ResolveOwnerModule from '@/lib/resolve-owner' +import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner' +import type * as RuntimeRpcClientModule from './runtime-rpc-client' +import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { + PublishedTab, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { + DESKTOP_CREATED_SERVER_TAB_HOLDS, + _reloadDesktopCreatedServerTabsForTests, + _resetDesktopCreatedServerTabsForTests, + noteDesktopCreatedServerTab +} from '@/lib/desktop-created-server-tabs' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { _resetLayoutFramePositionForTests } from './layout-frame-position' +import { useAppStore } from '@/store' +import { stageWebRuntimeBrowserTab } from './web-runtime-browser-tab-staging' +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import type { DragEndEvent } from '@dnd-kit/core' +import { commitTabDragDrop } from '@/components/tab-group/tab-drag-drop-commit' +import { + canMoveTabToNewPaneColumn, + moveTabToNewPaneColumn +} from '@/components/tab-bar/tab-move-to-pane-column' +import { resolvePanelEdgePaneColumnSplit } from '@/components/tab-group/tab-group-panel-split-target' +import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture' +import { + CAPABLE, + ENV, + HOST_GROUP, + LEAF_ID, + SECOND_LEAF_ID, + SERVER_LAYOUT, + WT, + createFakeLayoutStreams, + createServerHarness, + mirrorSnapshot, + senderSnapshot, + serverLayout, + serverTerminalTab, + sessionTabs, + settle, + surface, + type TestStore +} from './server-layout-frames.test-fixture' +import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { resetFrameDrawnServerWorkspacesForTests } from './web-session-tabs-sync/frame-drawn-server-workspaces' +import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +type Subscribe = (...args: never[]) => { close: () => void } +type Sent = { method: string; params: unknown } +const hoisted = vi.hoisted( + (): { + owners: Map + resync: Mock<(environmentId: string, worktreeId: string) => Promise> + subscribe: Subscribe | null + sent: Sent[] + } => ({ owners: new Map(), resync: vi.fn(), subscribe: null, sent: [] }) +) + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args) +})) +vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({ + mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) => + hoisted.owners.get(key) ?? null +})) +vi.mock('@/lib/resolve-owner', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentIdUnlessTwin: (_state: unknown, key: string) => hoisted.owners.get(key) ?? null +})) +vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentIdOrLocalUnlessTwin: (_state: unknown, key: string) => + hoisted.owners.get(key) ?? null +})) +vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({ + ...(await importOriginal()), + resyncMirroredServerWorkspace: hoisted.resync +})) +// Every RPC this window sends, whichever client sends it. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + callRuntimeRpc: (_target: RuntimeClientTarget, method: string, params?: unknown) => { + hoisted.sent.push({ method, params }) + // A call that fails releases what it held: no hold outlives its test. + return Promise.reject(new Error('not connected')) + } + } +}) + +/** This desktop's localStorage, which outlives a reload. */ +const storage = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key) +}) + +const api = createStoreSessionMockApi() +const environmentCall = vi.fn((args: { method: string; params?: unknown }) => { + hoisted.sent.push({ method: args.method, params: args.params }) + return Promise.resolve({ + id: 'test', + ok: false as const, + error: { code: 'not_connected', message: 'not connected' } + }) +}) +Object.assign(api.runtimeEnvironments, { call: environmentCall }) + +const streams = createFakeLayoutStreams() +hoisted.subscribe = streams.subscribe + +const TAB_1 = toWebTerminalSurfaceTabId('host-tab-1') +const TAB_2 = toWebTerminalSurfaceTabId('host-tab-2') +const GROUP_2 = 'host-group-2' +const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted) + +/** The server's layout once it opened a second terminal tab: a records frame that rebuilds WT. */ +const WITH_TAB_2 = serverLayout([ + serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1'), + serverTerminalTab('host-tab-2', SECOND_LEAF_ID, 'server-pty-2', 2) +]) + +/** The server's two groups, split by another client: `first` holds tab 1, `second` tab 2. */ +function serverSplit( + first = HOST_GROUP, + second = GROUP_2, + ratio?: number +): PublishedWorkspaceLayout { + return { + ...WITH_TAB_2, + groups: [ + { id: first, tabIds: ['host-tab-1'] }, + { id: second, tabIds: ['host-tab-2'] } + ], + groupLayout: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', groupId: first }, + second: { type: 'leaf', groupId: second }, + ...(ratio === undefined ? {} : { ratio }) + } + } +} + +const tabIds = (store: TestStore) => + (store.getState().unifiedTabsByWorktree[WT] ?? []).map((tab) => tab.id) +const groups = (store: TestStore) => + (store.getState().groupsByWorktree[WT] ?? []).map((group) => group.tabOrder) +const groupIds = (store: TestStore) => + (store.getState().groupsByWorktree[WT] ?? []).map((group) => group.id) +/** The app store as it loads: tests that drive app-store paths start from it. */ +const APP_STORE_AT_LOAD = useAppStore.getState() + +/** As frameDrawnStore, on the app store, which the staging and drag paths write. */ +async function appStoreFrameDrawn(layout = SERVER_LAYOUT) { + const store = useAppStore + hoisted.owners.set(WT, ENV) + mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1')], 1)) + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + const frames = mainLayoutFrameSender() + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1)) + if (layout !== SERVER_LAYOUT) { + streams.send(stream, frames.records(WT, layout)) + } + await settle() + return { store, stream, frames } +} + +let check: ReturnType +beforeEach(() => { + useAppStore.setState(APP_STORE_AT_LOAD, true) + hoisted.owners.clear() + hoisted.resync.mockReset().mockResolvedValue(undefined) + hoisted.sent.length = 0 + streams.all.length = 0 + resetWebSessionTabsSyncTestState() + resetFrameDrawnServerWorkspacesForTests() + resetMirroredTerminalHandlesForTests() + _resetLayoutFramePositionForTests() + storage.clear() + _resetDesktopCreatedServerTabsForTests() + check = installLayoutFrameApplyCheck() +}) +afterEach(() => { + uninstallAll() + check.uninstall() + expect(check.failures).toEqual([]) +}) + +/** A client-hosted browser tab staged as the create path stages it: this desktop's alone. */ +function stageHeld(groupId = HOST_GROUP) { + const staged = stageWebRuntimeBrowserTab({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'client-page-1', + activate: true, + clientHosted: true, + targetGroupId: groupId + })! + expect(staged.tabId).toBeDefined() + return { staged, held: staged.tabId! } +} + +const groupOf = (store: TestStore, tabId: string) => + store.getState().unifiedTabsByWorktree[WT]?.find((tab) => tab.id === tabId)?.groupId +const shownIn = (store: TestStore, groupId: string) => + store.getState().groupsByWorktree[WT]?.find((group) => group.id === groupId)?.activeTabId +const mentions = (...ids: string[]) => + hoisted.sent.filter((call) => ids.some((id) => JSON.stringify(call.params ?? null).includes(id))) + +/** A reload: streams, frame state and the in-memory record go; restored rows and storage stay. */ +function reload(): void { + uninstallAll() + streams.all.length = 0 + resetFrameDrawnServerWorkspacesForTests() + resetMirroredTerminalHandlesForTests() + _resetLayoutFramePositionForTests() + _reloadDesktopCreatedServerTabsForTests() +} + +async function firstFrameAfterReload(store: TestStore) { + install(store) + setServers(store, [{ ...CAPABLE, runtimeId: 'runtime-2' }]) + const stream = streams.openTo(ENV) + streams.send(stream, senderSnapshot(mainLayoutFrameSender(), { [WT]: WITH_TAB_2 }, 1)) + await settle() +} + +describe('a held tab in a server’s frame-drawn workspace', () => { + it('keeps its tab, page and handle in its group position through a frame and a reconnect', async () => { + const { store, stream, frames } = await appStoreFrameDrawn() + const { staged, held } = stageHeld() + const kept = () => { + const state = store.getState() + return { + group: groupOf(store, held), + order: state.groupsByWorktree[WT]?.find((group) => group.id === HOST_GROUP)?.tabOrder, + page: state.browserPagesByWorkspace[staged.workspaceId]?.map((page) => page.id), + handle: state.remoteBrowserPageHandlesByPageId[staged.pageId] + } + } + const before = kept() + expect(before.order).toEqual([TAB_1, held]) + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + // Presence: the frame's build ran and drew the server's new tab. + expect(check.workspaceBuilt).toContain(WT) + expect(kept()).toEqual({ ...before, order: [TAB_1, held, TAB_2] }) + stream.onStatus({ state: 'lost' }) + stream.onStatus({ state: 'connecting' }) + check.forgetBuilds() + streams.send(stream, senderSnapshot(mainLayoutFrameSender(), { [WT]: WITH_TAB_2 }, 5)) + await settle() + expect(check.fullBuilt).toContain(WT) + expect(kept()).toEqual({ ...before, order: [TAB_1, held, TAB_2] }) + // Staging and the frames send nothing that names it. + expect(mentions(staged.workspaceId, staged.pageId, held)).toEqual([]) + }) + + it('opened to the side, shows as the user’s tab in the nearest server group, with the server’s tree', async () => { + const { store, stream, frames } = await appStoreFrameDrawn(serverSplit()) + const serverTree = store.getState().layoutByWorktree[WT] + const side = store.getState().createEmptySplitGroup(WT, GROUP_2, 'right', { activate: true })! + const { held } = stageHeld(side) + // Precondition: drawn in the window's own group, which the user is in. + expect(groupOf(store, held)).toBe(side) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(side) + + streams.send(stream, frames.records(WT, serverSplit(HOST_GROUP, GROUP_2, 0.7))) + await settle() + expect(groups(store)).toEqual([[TAB_1], [TAB_2, held]]) + expect(store.getState().layoutByWorktree[WT]).toEqual({ ...serverTree, ratio: 0.7 }) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(GROUP_2) + expect(shownIn(store, GROUP_2)).toBe(held) + }) + + it('moves unshown when another client closes its group while the user is in another', async () => { + const { store, stream, frames } = await appStoreFrameDrawn(serverSplit()) + const { held } = stageHeld(GROUP_2) + store.getState().focusGroup(WT, HOST_GROUP) + // Precondition: shown in the second group; the user is on tab 1 in the first. + expect(shownIn(store, GROUP_2)).toBe(held) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(HOST_GROUP) + expect(shownIn(store, HOST_GROUP)).toBe(TAB_1) + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + expect(groups(store)).toEqual([[TAB_1, held, TAB_2]]) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(HOST_GROUP) + expect(shownIn(store, HOST_GROUP)).toBe(TAB_1) + }) + + it('is not moved by an unrelated server frame sent twice', async () => { + const { store, stream, frames } = await appStoreFrameDrawn(serverSplit()) + const { held } = stageHeld(GROUP_2) + const shown = { groups: groups(store), layout: store.getState().layoutByWorktree[WT] } + const recoloured = serverSplit() + recoloured.tabs[0] = { ...recoloured.tabs[0]!, color: '#ff0000' } + for (const frame of [recoloured, recoloured]) { + streams.send(stream, frames.records(WT, structuredClone(frame))) + await settle() + expect(groups(store)).toEqual(shown.groups) + expect(store.getState().layoutByWorktree[WT]).toEqual(shown.layout) + expect(groupOf(store, held)).toBe(GROUP_2) + } + // Presence: the frame reached the store. + expect(store.getState().unifiedTabsByWorktree[WT]?.find((tab) => tab.id === TAB_1)?.color).toBe( + '#ff0000' + ) + }) + + it('dragged to another group, moves here only: no RPC names it', async () => { + const { store } = await appStoreFrameDrawn(serverSplit()) + const { staged, held } = stageHeld(HOST_GROUP) + const finishDrag = vi.fn() + commitTabDragDrop({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a drop with no pointer: the commit reads only active.data, its rect and over.data. + event: { + active: { + id: held, + data: { + current: { + kind: 'tab', + worktreeId: WT, + groupId: HOST_GROUP, + unifiedTabId: held, + visibleTabId: held, + tabType: 'browser', + label: 'Docs' + } + }, + rect: { current: { initial: null } } + }, + over: { + id: GROUP_2, + data: { current: { kind: 'pane-body', worktreeId: WT, groupId: GROUP_2 } } + } + } as unknown as DragEndEvent, + worktreeId: WT, + dragGeometryRef: { current: null }, + dropUnifiedTab: store.getState().dropUnifiedTab, + reorderUnifiedTabs: store.getState().reorderUnifiedTabs, + finishDrag + }) + await settle() + // Presence: the drop ran and moved the tab here. + expect(finishDrag).toHaveBeenCalled() + expect(groupOf(store, held)).toBe(GROUP_2) + expect(groupIds(store)).toEqual([HOST_GROUP, GROUP_2]) + expect(mentions(staged.workspaceId, held)).toEqual([]) + }) +}) + +describe('a held tab and the split commands', () => { + const rect = { left: 0, top: 0, width: 1000, height: 600 } + const rightEdge = { x: 990, y: 300 } + const dragOf = (tabId: string, groupId: string) => ({ + kind: 'tab' as const, + worktreeId: WT, + groupId, + unifiedTabId: tabId, + visibleTabId: tabId, + tabType: 'browser' as const, + label: tabId + }) + const edgeSplit = (store: TestStore, tabId: string, groupId: string, target: string) => + resolvePanelEdgePaneColumnSplit({ + activeDrag: dragOf(tabId, groupId), + targetGroupId: target, + worktreeId: WT, + pointer: rightEdge, + groupsByWorktree: store.getState().groupsByWorktree, + layoutByWorktree: store.getState().layoutByWorktree, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the split target reads only the rect's edges. + panelRect: rect as DOMRect, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + bodyRect: { ...rect, top: 40, height: 560 } as DOMRect + }) + + it('offers a held tab no new split, by drag or by the pane-column command', async () => { + const { store } = await appStoreFrameDrawn(serverSplit()) + const { held } = stageHeld(HOST_GROUP) + // Presence: the server's own tab gets the edge split in the same place. + expect(edgeSplit(store, TAB_1, HOST_GROUP, GROUP_2)).toMatchObject({ zone: 'right' }) + + expect(edgeSplit(store, held, HOST_GROUP, GROUP_2)).toBeNull() + expect(canMoveTabToNewPaneColumn(held, HOST_GROUP)).toBe(false) + expect( + moveTabToNewPaneColumn({ unifiedTabId: held, groupId: HOST_GROUP, direction: 'right' }) + ).toBe(false) + expect(groupIds(store)).toEqual([HOST_GROUP, GROUP_2]) + }) + + it('counts only the server’s tabs: the server group’s one tab cannot split off its own group', async () => { + const { store } = await appStoreFrameDrawn(serverSplit()) + stageHeld(HOST_GROUP) + // Presence: the group holds two tabs here, one of them the server's. + expect(groups(store)[0]).toHaveLength(2) + + expect(canMoveTabToNewPaneColumn(TAB_1, HOST_GROUP)).toBe(false) + expect(edgeSplit(store, TAB_1, HOST_GROUP, HOST_GROUP)).toBeNull() + expect( + moveTabToNewPaneColumn({ unifiedTabId: TAB_1, groupId: HOST_GROUP, direction: 'right' }) + ).toBe(false) + expect(mentions(TAB_1, 'host-tab-1')).toEqual([]) + }) +}) + +describe('a simulator tab opened on this desktop in a server’s frame-drawn workspace', () => { + it('opens as the user’s tab in the server group it would split beside, and is never sent', async () => { + const { store } = await appStoreFrameDrawn(serverSplit()) + store.getState().focusGroup(WT, HOST_GROUP) + + const simulator = ensureSimulatorTab(WT, { targetGroupId: GROUP_2, placement: 'rightSplit' })! + await settle() + expect(groupIds(store)).toEqual([HOST_GROUP, GROUP_2]) + expect(groupOf(store, simulator)).toBe(GROUP_2) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(GROUP_2) + expect(shownIn(store, GROUP_2)).toBe(simulator) + expect(mentions(simulator)).toEqual([]) + }) + + it('survives the next server frame and a reload’s first frame', async () => { + const { store, stream, frames } = await appStoreFrameDrawn() + const simulator = ensureSimulatorTab(WT, { targetGroupId: HOST_GROUP })! + expect(groups(store)).toEqual([[TAB_1, simulator]]) + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + // Presence: the frame's build ran and drew the server's new tab. + expect(check.workspaceBuilt).toContain(WT) + expect(groups(store)).toEqual([[TAB_1, simulator, TAB_2]]) + + reload() + await firstFrameAfterReload(store) + expect(groups(store)).toEqual([[TAB_1, simulator, TAB_2]]) + expect(mentions(simulator)).toEqual([]) + }) + + it('follows the server’s frames once the server publishes it: its close drops it here', async () => { + const { store, stream, frames } = await appStoreFrameDrawn() + const simulator = ensureSimulatorTab(WT, { targetGroupId: HOST_GROUP })! + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, simulator)).toBe(true) + const published: PublishedTab = { + id: simulator, + kind: 'simulator', + entityId: simulator, + executionHostId: 'local', + createdAt: 3, + customTitle: null, + color: null + } + streams.send(stream, frames.records(WT, serverLayout([...SERVER_LAYOUT.tabs, published]))) + await settle() + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, simulator)).toBe(false) + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + expect(tabIds(store)).toEqual([TAB_1, TAB_2]) + }) + + it('is recorded only while a server’s frames draw it: a mirrored server workspace records nothing', async () => { + const store = useAppStore + hoisted.owners.set(WT, ENV) + mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1')], 1)) + const before = groupIds(store) + + const simulator = ensureSimulatorTab(WT, { placement: 'rightSplit' }) + await settle() + // Drawn here as today, in a split of its own: no server's frames draw this workspace. + expect(simulator).toBeTruthy() + expect(tabIds(store)).toContain(simulator) + expect(groupIds(store)).toHaveLength(before.length + 1) + expect(before).not.toContain(groupOf(store, simulator!)) + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, simulator!)).toBe(false) + expect(storage.size).toBe(0) + }) +}) + +describe('a held tab across a window reload', () => { + it('survives the first server frame after the reload', async () => { + const { store } = await appStoreFrameDrawn() + const { held } = stageHeld() + expect(groups(store)).toEqual([[TAB_1, held]]) + + reload() + await firstFrameAfterReload(store) + // Presence: the frame drew the server's new tab. + expect(tabIds(store)).toContain(TAB_2) + expect(groups(store)).toEqual([[TAB_1, held, TAB_2]]) + }) + + it('forgets a recorded tab the restored session no longer has, so it never comes back', async () => { + const { store } = await appStoreFrameDrawn() + noteDesktopCreatedServerTab(ENV, WT, 'gone-tab') + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, 'gone-tab')).toBe(true) + + reload() + await firstFrameAfterReload(store) + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, 'gone-tab')).toBe(false) + expect([...storage.values()].join()).not.toContain('gone-tab') + _reloadDesktopCreatedServerTabsForTests() + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, 'gone-tab')).toBe(false) + }) +}) + +describe('a tab in this window’s own frame-drawn workspace', () => { + it('shows as main’s frames have it: no desktop hold keeps it', async () => { + const store = mirroredStore() + hoisted.owners.clear() + const sync = openWorkspaceLayoutFrameSync(store) + const stream = streams.all.find((open) => open.target.kind === 'local' && !open.closed)! + const frames = mainLayoutFrameSender() + streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1)) + sync.start() + await settle() + store + .getState() + .createUnifiedTab(WT, 'simulator', { id: 'emulator-1', targetGroupId: HOST_GROUP }) + noteDesktopCreatedServerTab(ENV, WT, 'emulator-1') + + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + expect(check.workspaceBuilt).toContain(WT) + expect(tabIds(store)).not.toContain('emulator-1') + sync.close() + }) +}) diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts index 7084a23330e..ff10282c64e 100644 --- a/src/renderer/src/runtime/server-layout-frames.ts +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -20,6 +20,11 @@ import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { + DESKTOP_CREATED_SERVER_TAB_HOLDS, + forgetPublishedServerTabs, + pruneDesktopCreatedServerTabs +} from '@/lib/desktop-created-server-tabs' import { serverHeldPanes, showServerLayoutHeldPanes } from './server-held-panes' import { createServerViewReport, onServerControlConnected } from './server-layout-view-report' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' @@ -143,8 +148,16 @@ function openServerFrames( const ours = draws(state) return (key) => gated.has(key) && ours(key) }, + heldTabs: DESKTOP_CREATED_SERVER_TAB_HOLDS, translate: (frame) => { const translated = translator.translate(frame) + // A tab this desktop created that the server now lists is the server's from here on. + for (const key of translator.keys()) { + forgetPublishedServerTabs( + key, + (translator.serverLayout(key)?.tabs ?? []).flatMap((tab) => [tab.id, tab.entityId]) + ) + } live ||= frame.type === 'snapshot' if (frame.type === 'snapshot') { viewReport.reset() @@ -208,6 +221,8 @@ function openServerFrames( /** Opens one stream per reachable server advertising the capability; reopens on a new runtime or * pairing, and closes when the server is removed or stops advertising it. */ export function installServerLayoutFrames(store: FramesStore): () => void { + // Before any frame: a held tab the restored session no longer has must not come back. + pruneDesktopCreatedServerTabs(store.getState()) const open = new Map() let inputs: unknown[] = [] const reconcile = (state: AppState): void => { diff --git a/src/renderer/src/runtime/web-runtime-browser-creation-context.ts b/src/renderer/src/runtime/web-runtime-browser-creation-context.ts index 279c9169afc..3c33993b41b 100644 --- a/src/renderer/src/runtime/web-runtime-browser-creation-context.ts +++ b/src/renderer/src/runtime/web-runtime-browser-creation-context.ts @@ -212,6 +212,7 @@ export function restageWebRuntimeBrowserCreation( if (context.staged) { context.staged = restageWebRuntimeBrowserTabHostingIntent(context.staged, { environmentId: context.environmentId, + worktreeId: context.args.worktreeId, remotePageId: context.provisionalPageId, clientHosted }) diff --git a/src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts b/src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts index 771a58a7304..7cbbf6ecbf4 100644 --- a/src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts +++ b/src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts @@ -13,6 +13,7 @@ vi.mock('../store', () => ({ useAppStore: { getState: mocks.getState } })) const ENVIRONMENT_ID = 'web-env-1' const REMOTE_PAGE_ID = 'remote-page-1' +const WORKTREE_ID = 'repo::/worktree' const STAGED: StagedWebRuntimeBrowserTab = { workspaceId: 'workspace-1', pageId: 'page-1', @@ -37,6 +38,7 @@ describe('restageWebRuntimeBrowserTabHostingIntent', () => { const next = restageWebRuntimeBrowserTabHostingIntent(STAGED, { environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, remotePageId: REMOTE_PAGE_ID, clientHosted: true }) @@ -60,7 +62,12 @@ describe('restageWebRuntimeBrowserTabHostingIntent', () => { const next = restageWebRuntimeBrowserTabHostingIntent( { ...STAGED, clientHosted: true }, - { environmentId: ENVIRONMENT_ID, remotePageId: REMOTE_PAGE_ID, clientHosted: false } + { + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: REMOTE_PAGE_ID, + clientHosted: false + } ) expect(next).toEqual({ ...STAGED, clientHosted: false }) @@ -76,6 +83,7 @@ describe('restageWebRuntimeBrowserTabHostingIntent', () => { const next = restageWebRuntimeBrowserTabHostingIntent(STAGED, { environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, remotePageId: REMOTE_PAGE_ID, clientHosted: false }) @@ -89,6 +97,7 @@ describe('restageWebRuntimeBrowserTabHostingIntent', () => { const next = restageWebRuntimeBrowserTabHostingIntent(STAGED, { environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, remotePageId: REMOTE_PAGE_ID, clientHosted: true }) diff --git a/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts b/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts index 8a7259c4199..341e83e5c2c 100644 --- a/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts +++ b/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts @@ -1,11 +1,18 @@ import { useAppStore } from '../store' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { + forgetDesktopCreatedServerTab, + noteDesktopCreatedServerTab +} from '@/lib/desktop-created-server-tabs' /** Local rows minted for a browser tab whose host create RPC has not answered yet. */ export type StagedWebRuntimeBrowserTab = { workspaceId: string pageId: string clientHosted: boolean + /** Its tab-bar entry, once staging drew one. */ + tabId?: string } /** @@ -53,7 +60,19 @@ export function stageWebRuntimeBrowserTab(args: { staged: true, ...(args.clientHosted ? { stagedClientHosted: true } : {}) }) - return { workspaceId: workspace.id, pageId, clientHosted: args.clientHosted === true } + const tabId = useAppStore + .getState() + .unifiedTabsByWorktree[args.worktreeId]?.find( + (tab) => tab.contentType === 'browser' && tab.entityId === workspace.id + )?.id + const staged = { + workspaceId: workspace.id, + pageId, + clientHosted: args.clientHosted === true, + ...(tabId ? { tabId } : {}) + } + noteClientHosting(args.environmentId, args.worktreeId, staged) + return staged } catch (error) { // Why: staging is an optimization; a store-side refusal must not fail the create itself. console.warn( @@ -155,7 +174,7 @@ export function rehomeStagedWebRuntimeBrowserTab( */ export function restageWebRuntimeBrowserTabHostingIntent( staged: StagedWebRuntimeBrowserTab, - args: { environmentId: string; remotePageId: string; clientHosted: boolean } + args: { environmentId: string; worktreeId: string; remotePageId: string; clientHosted: boolean } ): StagedWebRuntimeBrowserTab { if (staged.clientHosted === args.clientHosted) { return staged @@ -170,7 +189,29 @@ export function restageWebRuntimeBrowserTabHostingIntent( staged: true, ...(args.clientHosted ? { stagedClientHosted: true } : {}) }) - return { ...staged, clientHosted: args.clientHosted } + const restaged = { ...staged, clientHosted: args.clientHosted } + noteClientHosting(args.environmentId, args.worktreeId, restaged) + return restaged +} + +/** A client-hosted page never enters its server's layout, so that server's frames keep the tab (L274). */ +function noteClientHosting( + environmentId: string, + worktreeId: string, + staged: StagedWebRuntimeBrowserTab +): void { + // Switch point: only a server's frames, which a layout-owner build draws, need the record. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } + // By both ids: its browser workspace and its tab-bar entry. + for (const id of [staged.workspaceId, ...(staged.tabId ? [staged.tabId] : [])]) { + if (staged.clientHosted) { + noteDesktopCreatedServerTab(environmentId, worktreeId, id) + } else { + forgetDesktopCreatedServerTab(id) + } + } } /** diff --git a/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts b/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts index 4b97e7d0f34..2d09b25a508 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-apply-check.test-fixture.ts @@ -3,7 +3,6 @@ // for the same cache and the store as the flush found it. Mirrors main's model check. import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' -import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds' import { isTabPaneTreeHeld } from './layout-owner-pane-intents' import { observeLayoutFrameApply, @@ -80,6 +79,7 @@ export function layoutFrameApplyErrors({ changed, fullBuilt, rowFieldsTaken, + held, selectTakenMovedTabs }: LayoutFrameApplyObservation): string[] { const keys = changed.filter((key) => key in cache && !fullBuilt.includes(key)) @@ -92,7 +92,7 @@ export function layoutFrameApplyErrors({ ...fullLayoutFrameBuildPatch(before, cache, keys, { keepsTerminalLayout: isTabPaneTreeHeld, newRowFields: (tabId) => rowFieldsTaken.get(tabId) ?? null, - held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld } + held }) }) const whole = fullBuilt.length === 0 diff --git a/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts b/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts index aa46fda31bd..7d6dec86f81 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-apply-observer.ts @@ -4,6 +4,7 @@ import type { AppState } from '@/store/types' import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' +import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs' import type { TerminalTab } from '../../../shared/terminal-tab-types' export type LayoutFrameApplyObservation = { @@ -18,6 +19,8 @@ export type LayoutFrameApplyObservation = { workspaceBuilt: readonly string[] /** The window's own fields each new row took (each intent is taken once). */ rowFieldsTaken: ReadonlyMap> + /** The tabs the flush's builds kept as drawn. */ + held: HeldDrawnTabs /** `state` with the moved tabs selected whose selection the flush wrote with its frame (each * intent is taken once), as the flush selected them. */ selectTakenMovedTabs: (state: AppState) => AppState diff --git a/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts b/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts index c999d8260b2..fab70e700b3 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-operations.test.ts @@ -2,6 +2,7 @@ // from the store's own view, without reading the window's session back, and leaves the store as // the full build does (the apply check, installed below). +import { NO_HELD_TABS } from '@/store/workspace-layout-frame-held-tabs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' @@ -229,6 +230,7 @@ describe('the apply check on a flush that built one workspace in full', () => { fullBuilt: ['repo-other::/elsewhere'], workspaceBuilt: [GIT_KEY], rowFieldsTaken: new Map(), + held: NO_HELD_TABS, selectTakenMovedTabs: (state) => state }) // Precondition: the store as booted is what the full build shows. @@ -262,6 +264,7 @@ describe('the apply check on a flush that built one workspace in full', () => { fullBuilt: ['repo-other::/elsewhere'], workspaceBuilt: [GIT_KEY], rowFieldsTaken: new Map(), + held: NO_HELD_TABS, selectTakenMovedTabs: (state) => withMovedTabsSelected(state, [moved]) }) // Precondition: the flush's selection changes what the workspace shows. diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 9a3949bed54..d3c959ab525 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -32,6 +32,8 @@ import { type LayoutFrameBuildHolds, type LayoutFrameBuildPatch } from './workspace-layout-frame-builds' +import { eitherHeld } from '@/store/workspace-layout-frame-held-tabs' +import { heldTabGroupFocus } from './held-tab-group-follow' import { shownContentRekeyPatch } from './layout-owner-content-rekeys' import { provideLayoutFrameWorkspaces } from './layout-frame-workspaces' import { @@ -145,6 +147,7 @@ export function openWorkspaceLayoutFrameSync( const rowFieldsTaken = new Map>() const holds: LayoutFrameBuildHolds = { ...FRAME_BUILD_HOLDS, + held: eitherHeld(FRAME_BUILD_HOLDS.held, source.heldTabs), newRowFields: (tabId) => { const fields = takeTabRowFields(tabId) if (fields) { @@ -206,7 +209,10 @@ export function openWorkspaceLayoutFrameSync( store.getState().updateTabTitles(titleUpdates) } showFramePaneStarts(store, changed, cache, paneStartsShown) - for (const key of [...fullBuilt, ...workspaceBuilt]) { + // Run after the apply check: the focus is the user's selection, not the frame's. + const builtKeys = [...fullBuilt, ...workspaceBuilt] + const followHeldTabs = heldTabGroupFocus(base, store, builtKeys, holds.held) + for (const key of builtKeys) { shown.add(key) // Against the store, not the frame: a held tab's tree may lag the pane trees drawn since. paneTreeKeys.add(key) @@ -214,6 +220,7 @@ export function openWorkspaceLayoutFrameSync( // A closed stream's cache is frozen: answers the window draws itself (a split, close or create) // show then; any other change waits for the reopened stream's snapshot. if (!streamOpen) { + followHeldTabs() return } if (paneTreeKeys.size > 0) { @@ -227,8 +234,10 @@ export function openWorkspaceLayoutFrameSync( fullBuilt, workspaceBuilt, rowFieldsTaken, + held: holds.held, selectTakenMovedTabs: (state) => withMovedTabsSelected(state, selectionsTaken) }) + followHeldTabs() } const scheduleFlush = (): void => { diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index f15cfc4b897..4a0afa8a575 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -15,6 +15,7 @@ import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' import { landOnEmptiedWorktree } from '../tabs/tabs-close-actions' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { forgetDesktopCreatedServerTab } from '@/lib/desktop-created-server-tabs' import { closeBrowserTabThroughLayoutOwner, withDrawnBrowserPages, @@ -27,6 +28,7 @@ export function createBrowserCloseActions( ): Pick { return { closeBrowserTab: (tabId, options) => { + forgetDesktopCreatedServerTab(tabId) // Why: a cleanup close unwinds a tab that never finished being created — it owns no host // page to close and must not enter the reopen stack as if the user had closed something. const isCleanup = options?.reason === 'cleanup' diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 0269a1db8c1..81293b60228 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -30,6 +30,7 @@ import type { AppState } from '../../types' import type { TuiAgent } from '../../../../../shared/tui-agent' import { isTuiAgent } from '../../../../../shared/tui-agent-config' import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host' +import { forgetDesktopCreatedServerTab } from '@/lib/desktop-created-server-tabs' export function createTabsCloseActions( set: TabsSliceSet, @@ -43,6 +44,8 @@ export function createTabsCloseActions( return null } const { tab, worktreeId } = found + forgetDesktopCreatedServerTab(tab.id) + forgetDesktopCreatedServerTab(tab.entityId) const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) // Why: a tab whose group record vanished must still close; its order is the worktree's tabs. const groupOrder = diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 4f603f8dc8d..c49a867f4b7 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -16,6 +16,7 @@ import { flushPurgedWorkspaceEdits, type ConfirmedUnsavedDiscards } from './removed-workspace-unsaved-files' +import { forgetDesktopCreatedServerTabsOf } from '@/lib/desktop-created-server-tabs' import type { WorktreeStateBeforeRemoval } from './worktree-state-before-removal' /** @@ -53,6 +54,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { // Why the captured keys: closing a structured chat keeps its conversation's draft, and a listing // refresh may already have dropped these tabs. deleteWorkspaceChatDrafts(beforeRemoval.chatDraftKeys) + forgetDesktopCreatedServerTabsOf(worktreeId) for (const tab of get().unifiedTabsByWorktree[worktreeId] ?? []) { if (tab.contentType === 'agent-session') { get().closeUnifiedTab(tab.id, { diff --git a/src/renderer/src/store/workspace-layout-frame-held-tab-groups.ts b/src/renderer/src/store/workspace-layout-frame-held-tab-groups.ts new file mode 100644 index 00000000000..8bbcfd4a8c3 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-held-tab-groups.ts @@ -0,0 +1,104 @@ +// A held tab that shows only in main's groups: drawn in a group main's frame lacks (opened to the +// side, or main closed its group), it moves into the nearest group main has, so the workspace's +// group tree stays exactly main's. + +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' +import { collectTabGroupLayoutGroupIds } from '../../../shared/workspace-layout/tab-group-moves' + +/** One workspace as the window draws it. */ +export type DrawnWorkspace = { + tabs: readonly Tab[] + groups: readonly TabGroup[] + tree: TabGroupLayoutNode | undefined + activeGroupId: string | undefined +} + +/** Where held tabs go: their new group, the window's groups after the move, and the tab the user + * was on when it moved (shown in its new group). */ +export type HeldTabPlacement = { + tabGroupIdById: ReadonlyMap + groups: TabGroup[] + shownTabId: string | undefined +} + +/** The subtree beside `groupId`'s leaf. */ +function siblingOf(root: TabGroupLayoutNode, groupId: string): TabGroupLayoutNode | null { + if (root.type === 'leaf') { + return null + } + const isLeaf = (node: TabGroupLayoutNode): boolean => + node.type === 'leaf' && node.groupId === groupId + if (isLeaf(root.first)) { + return root.second + } + if (isLeaf(root.second)) { + return root.first + } + return siblingOf(root.first, groupId) ?? siblingOf(root.second, groupId) +} + +/** Main's first group under the window's sibling of `groupId`, else the active one, else main's first. */ +function nearestMainGroup( + drawn: DrawnWorkspace, + groupId: string, + mainIds: ReadonlySet, + fallback: string +): string { + const sibling = drawn.tree ? siblingOf(drawn.tree, groupId) : null + const active = drawn.activeGroupId + return ( + [...collectTabGroupLayoutGroupIds(sibling)].find((id) => mainIds.has(id)) ?? + (active && mainIds.has(active) ? active : fallback) + ) +} + +/** Each tab `inMain` names, drawn outside `mainGroups`, at the end of the nearest; null when none. */ +export function heldTabsInMainGroups( + drawn: DrawnWorkspace, + mainGroups: readonly TabGroup[], + inMain: (tab: Tab) => boolean +): HeldTabPlacement | null { + const first = mainGroups[0] + if (!first) { + return null + } + const mainIds = new Set(mainGroups.map((group) => group.id)) + const strays = new Map( + drawn.tabs + .filter((tab) => inMain(tab) && !mainIds.has(tab.groupId)) + .map((tab) => [tab.id, nearestMainGroup(drawn, tab.groupId, mainIds, first.id)]) + ) + if (strays.size === 0) { + return null + } + const into = (groupId: string): string[] => + [...strays].filter(([, to]) => to === groupId).map(([id]) => id) + // The tab the user is on moves with them: shown in its new group, which becomes theirs. A tab + // shown in another group moves unshown, so it never replaces what the user is looking at. + const activeGroup = drawn.groups.find((group) => group.id === drawn.activeGroupId) + const shownId = + activeGroup?.activeTabId && strays.has(activeGroup.activeTabId) + ? activeGroup.activeTabId + : undefined + const withMoved = (group: TabGroup, tabOrder: readonly string[]): TabGroup => ({ + ...group, + tabOrder: [...tabOrder, ...into(group.id)] + }) + return { + tabGroupIdById: strays, + groups: [ + ...drawn.groups.map((group) => + withMoved( + group, + group.tabOrder.filter((id) => !strays.has(id)) + ) + ), + // A main group the window does not draw yet: drawn as main has it, plus the tabs moved in. + ...mainGroups + .filter((group) => !drawn.groups.some((candidate) => candidate.id === group.id)) + .filter((group) => into(group.id).length > 0) + .map((group) => withMoved(group, group.tabOrder)) + ], + shownTabId: shownId + } +} diff --git a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts index 5c78dd9b3ff..4983fe4ffd8 100644 --- a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts +++ b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts @@ -6,6 +6,7 @@ import type { Tab, TabGroup } from '../../../shared/tab-types' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type' +import { heldTabsInMainGroups } from './workspace-layout-frame-held-tab-groups' import type { LayoutFrameSlices } from './workspace-layout-frame-slices' import type { LayoutFrameStoreState } from './workspace-layout-frame-store-patch' @@ -13,6 +14,8 @@ export type HeldDrawnTabs = { /** By tab-bar or entity id. */ isTabHeld: (workspace: string, id: string) => boolean isGroupLayoutHeld: (workspace: string) => boolean + /** A held tab that shows only in main's groups (heldTabsInMainGroups). */ + keepsInMainGroups?: (workspace: string, id: string) => boolean } export const NO_HELD_TABS: HeldDrawnTabs = { @@ -20,6 +23,21 @@ export const NO_HELD_TABS: HeldDrawnTabs = { isGroupLayoutHeld: () => false } +/** Holds what either holds. */ +export function eitherHeld(a: HeldDrawnTabs, b: HeldDrawnTabs | undefined): HeldDrawnTabs { + if (!b) { + return a + } + return { + isTabHeld: (workspace, id) => a.isTabHeld(workspace, id) || b.isTabHeld(workspace, id), + isGroupLayoutHeld: (workspace) => + a.isGroupLayoutHeld(workspace) || b.isGroupLayoutHeld(workspace), + keepsInMainGroups: (workspace, id) => + (a.keepsInMainGroups?.(workspace, id) ?? false) || + (b.keepsInMainGroups?.(workspace, id) ?? false) + } +} + /** `built`'s records with the held ones as `existing` has them: kept, dropped or added. */ function asDrawn( existing: readonly T[] | undefined, @@ -68,7 +86,9 @@ function heldOrder( function heldGroups( existing: readonly TabGroup[], built: readonly TabGroup[], - heldIds: ReadonlySet + heldIds: ReadonlySet, + /** A held tab the user was on, moved into this group: shown there. */ + shownTabId?: string ): TabGroup[] { const touches = (group: TabGroup | undefined): boolean => group?.tabOrder.some((id) => heldIds.has(id)) ?? false @@ -85,7 +105,9 @@ function heldGroups( } const base = (drawn ?? main)! const activeTabId = - [drawn?.activeTabId, main?.activeTabId].find((id) => id && tabOrder.includes(id)) ?? + [shownTabId, drawn?.activeTabId, main?.activeTabId].find( + (id) => id && tabOrder.includes(id) + ) ?? tabOrder[0] ?? null return [{ ...base, tabOrder, activeTabId }] @@ -129,12 +151,32 @@ export function withHeldTabsAsDrawn( ): LayoutFrameSlices { let next = built for (const key of keys) { + const keepsInMain = held.keepsInMainGroups + const placement = keepsInMain + ? heldTabsInMainGroups( + { + tabs: state.unifiedTabsByWorktree[key] ?? [], + groups: state.groupsByWorktree[key] ?? [], + tree: state.layoutByWorktree[key], + activeGroupId: state.activeGroupIdByWorktree[key] + }, + next.groupsByWorktree[key] ?? [], + (tab) => keepsInMain(key, tab.id) || keepsInMain(key, tab.entityId) + ) + : null + // The window's tabs and groups with held tabs placed in main's groups. + const drawnTabs = placement + ? (state.unifiedTabsByWorktree[key] ?? []).map((tab) => { + const groupId = placement.tabGroupIdById.get(tab.id) + return groupId ? { ...tab, groupId } : tab + }) + : state.unifiedTabsByWorktree[key] + const drawnGroups = placement?.groups ?? state.groupsByWorktree[key] ?? [] const isHeld = (tab: Tab): boolean => held.isTabHeld(key, tab.id) || held.isTabHeld(key, tab.entityId) - const heldTabs = [ - ...(state.unifiedTabsByWorktree[key] ?? []), - ...(built.unifiedTabsByWorktree[key] ?? []) - ].filter(isHeld) + const heldTabs = [...(drawnTabs ?? []), ...(built.unifiedTabsByWorktree[key] ?? [])].filter( + isHeld + ) const layoutHeld = held.isGroupLayoutHeld(key) const slots = launchSlotIds(state, built, key) if (heldTabs.length === 0 && slots.length === 0 && !layoutHeld) { @@ -142,9 +184,10 @@ export function withHeldTabsAsDrawn( } const heldIds = new Set([...heldTabs.map((tab) => tab.id), ...slots]) const groups = heldGroups( - state.groupsByWorktree[key] ?? [], + drawnGroups, next.groupsByWorktree[key] ?? [], - heldIds + heldIds, + placement?.shownTabId ) const groupIds = (list: readonly TabGroup[]): string => list @@ -181,7 +224,7 @@ export function withHeldTabsAsDrawn( unifiedTabsByWorktree: withKey( next.unifiedTabsByWorktree, key, - asDrawn(state.unifiedTabsByWorktree[key], next.unifiedTabsByWorktree[key], heldIds, idOf) + asDrawn(drawnTabs, next.unifiedTabsByWorktree[key], heldIds, idOf) ), groupsByWorktree: withKey(next.groupsByWorktree, key, groups), layoutByWorktree: withKey(next.layoutByWorktree, key, layout), diff --git a/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts b/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts index 72c124edd70..30481d9596e 100644 --- a/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts +++ b/src/renderer/src/store/workspace-layout-frame-store-patch.test.ts @@ -15,7 +15,19 @@ import { createTestStore } from './slices/store-test-helpers' import type { AppState } from './types' import { buildLayoutFrameSlices } from './workspace-layout-frame-slices' import { buildLayoutFrameStorePatch } from './workspace-layout-frame-store-patch' +import { + DESKTOP_CREATED_SERVER_TAB_HOLDS, + _resetDesktopCreatedServerTabsForTests, + noteDesktopCreatedServerTab +} from '@/lib/desktop-created-server-tabs' +// Off as the build ships; the desktop-created record exists only with it on. +const layoutOwner = vi.hoisted(() => ({ on: false })) +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.on + } +})) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -143,4 +155,56 @@ describe('laying frame slices over the store', () => { }) expect(closed.browserPagesByWorkspace).not.toHaveProperty('browser-1') }) + + it('keeps a client-hosted browser tab this desktop created, with its page and handle, at its place (L274)', async () => { + const { state, session, frame } = await restored() + const page = state.browserPagesByWorkspace['browser-1']![0]! + const handle = { + environmentId: 'web-env-1', + remotePageId: 'server-page-1', + placement: { + kind: 'client' as const, + browserHostClientId: 'this-desktop', + browserHostGeneration: 1, + pageHostGeneration: 1 + } + } + const hosting = { + ...state, + remoteBrowserPageHandlesByPageId: { + ...state.remoteBrowserPageHandlesByPageId, + [page.id]: handle + } + } + const groupOf = (tabs: AppState['groupsByWorktree']) => + tabs[GIT_KEY]!.find((group) => group.tabOrder.includes('browser-1')) + const order = groupOf(state.groupsByWorktree)!.tabOrder + // The server's frame lists every tab but the one this desktop hosts. + const serverFrame = { ...withoutTab(frame, 'browser-1'), browserTabs: [] } + layoutOwner.on = true + noteDesktopCreatedServerTab('web-env-1', GIT_KEY, 'browser-1') + try { + const patch = patchFor( + hosting, + session, + serverFrame, + undefined, + undefined, + DESKTOP_CREATED_SERVER_TAB_HOLDS + ) + const after = { ...hosting, ...patch } + expect(after.unifiedTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain('browser-1') + expect(groupOf(after.groupsByWorktree)?.tabOrder).toEqual(order) + expect(after.browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain('browser-1') + expect(after.browserPagesByWorkspace['browser-1']).toEqual([page]) + expect(after.remoteBrowserPageHandlesByPageId[page.id]).toEqual(handle) + } finally { + _resetDesktopCreatedServerTabsForTests() + layoutOwner.on = false + } + // Presence: without the hold, the same frame drops all three. + const dropped = patchFor(hosting, session, serverFrame) + expect(dropped.browserPagesByWorkspace).not.toHaveProperty('browser-1') + expect(dropped.remoteBrowserPageHandlesByPageId).not.toHaveProperty(page.id) + }) }) diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 62ae10c7c42..aeb5af2f401 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -32,6 +32,10 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record