A paired desktop keeps the tabs it created in a server workspace drawn from the server's layout (switch-on builds only) (#31853)

* fix(L273-core): tabs this desktop created in a paired server's frame-drawn workspace survive the server's frames (switch-on)

A tab the server's layout never lists (a client-hosted browser tab today; any kind recorded the
same way) used to vanish on the server's next frame rebuild. This desktop now records such tabs by
server and workspace, in its localStorage only (never a session field nor sent), and the server's
frame builds keep them as drawn, inside the server's own groups:
- A held tab drawn in a group the server lacks moves to the nearest server group; it shows there
  only if the user was on it, and the user's group follows it. The group tree stays the server's.
- The record forgets an id once the server's frame lists it, on close, and on worktree removal,
  and prunes ids the restored session no longer has before the first frame after a reload.
- A held tab's drag moves it here only (no session.tabs.move); it offers no new split, and a
  server tab sharing its group counts only the server's tabs for split no-ops.
- Client-hosted staging records its tab (both ids) in layout-owner builds; restage takes the
  worktree from its caller instead of scanning the store.

* refactor(L273-core): the desktop-created record is inert with the switch off; clearer names

- With the switch off the record is never read, kept or written (a single gate in the record).
- The frame-drawn check is isDesktopCreatedTabInFrameDrawnServerWorkspace in
  frame-drawn-desktop-created-tabs.ts: "held" stays the frame hold's word.
- The held-tab placement drops its unread activeGroupId; the group follow derives it.

* fix(L273-core): a simulator tab opened in a paired server's frame-drawn workspace survives its frames (switch-on)

The server never has this desktop's emulator tab, so its frame rebuilds dropped it. The simulator
open now records the tab as created here when a server's frames draw the workspace, and opens it
in the server group it would have split beside, since that workspace has no group of this
desktop's own. L290 (the host runs and streams the simulator, #6701) removes the note.

* test(L273-core): the mirrored-workspace simulator test draws the tab and can fail; the side-open test starts from another group

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(L273-core): list the created-here tab record in the WSL-move storage census

It is keyed by a paired server's environment; a local or WSL project never has one, so a move has nothing to carry.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-22 06:37:13 -04:00
committed by GitHub
co-authored by Claude
parent a3fa44ad07
commit 2c937546dd
28 changed files with 1328 additions and 22 deletions
@@ -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
}
@@ -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
@@ -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]
})
) {
@@ -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]
})
) {
@@ -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()
})
})
@@ -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<typeof AgentStatusModule>()
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<string, string>()
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])
})
})
@@ -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<string> }
/** By `environmentId\0worktreeId`; loaded on first use. */
let records: Map<string, Entry> | undefined
function recordMap(): Map<string, Entry> {
// 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<string, Entry>): 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<string>() }
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<string>): 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<string>): 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<AppState, 'unifiedTabsByWorktree'>
): 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<Tab, 'id' | 'entityId'>[] | 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 {}
}
+17 -2
View File
@@ -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): {
@@ -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
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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
@@ -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<string, string>
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
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<typeof AgentStatusModule>()
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<typeof ResolveOwnerModule>()),
runtimeEnvironmentIdUnlessTwin: (_state: unknown, key: string) => hoisted.owners.get(key) ?? null
}))
vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeRuntimeOwnerModule>()),
runtimeEnvironmentIdOrLocalUnlessTwin: (_state: unknown, key: string) =>
hoisted.owners.get(key) ?? null
}))
vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({
...(await importOriginal<typeof FrameDrawnModule>()),
resyncMirroredServerWorkspace: hoisted.resync
}))
// Every RPC this window sends, whichever client sends it.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
const actual = await importOriginal<typeof RuntimeRpcClientModule>()
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<string, string>()
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<typeof installLayoutFrameApplyCheck>
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()
})
})
@@ -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<string, { identity: string; frames: ServerFrames }>()
let inputs: unknown[] = []
const reconcile = (state: AppState): void => {
@@ -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
})
@@ -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
})
@@ -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)
}
}
}
/**
@@ -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
@@ -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<string, Partial<TerminalTab>>
/** 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
@@ -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.
@@ -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<string, Partial<TerminalTab>>()
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 => {
@@ -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<BrowserSlice, 'closeBrowserTab' | 'shutdownWorktreeBrowsers'> {
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'
@@ -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 =
@@ -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, {
@@ -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<string, string>
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<string>,
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
}
}
@@ -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<T>(
existing: readonly T[] | undefined,
@@ -68,7 +86,9 @@ function heldOrder(
function heldGroups(
existing: readonly TabGroup[],
built: readonly TabGroup[],
heldIds: ReadonlySet<string>
heldIds: ReadonlySet<string>,
/** 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),
@@ -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<typeof AgentStatusModule>()
@@ -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)
})
})
@@ -32,6 +32,10 @@ export const LOCAL_STORAGE_MODULE_DISPOSITIONS: Record<string, WslMoveFieldDispo
notApplicableDisposition(45, 'Keyed by session id.'),
'src/renderer/src/components/native-chat/structured-agent-session-legacy-outbox.ts':
notApplicableDisposition(45, 'Keyed by session id.'),
'src/renderer/src/lib/desktop-created-server-tabs.ts': notApplicableDisposition(
60,
'Tabs this desktop created in a paired server workspace, keyed by that server; a local or WSL project has none.'
),
'src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts':
notApplicableDisposition(
32,