Record simulator tab opens and closes in main (dark, layout-owner build only) (#30034)

* feat(layout): record simulator tab opens and closes in main (dark, layout-owner build only)

* chore(layout): note why sleep/wake params copy OnTab
This commit is contained in:
Jinwoo Hong
2026-10-17 02:48:46 -07:00
committed by GitHub
parent c3f52428dd
commit 609f3425ad
14 changed files with 602 additions and 47 deletions
@@ -319,6 +319,21 @@ describe('layout.apply', () => {
).toEqual({ command: { type: 'closeTabs', workspace: WORKTREE, tabIds: ['e1'] } })
})
it('accepts a simulator tab open under the window’s id, into its group or a new split', async () => {
const apply = eraseRpcMethods(LAYOUT_APPLY_METHODS).find(
(method) => method.name === 'layout.apply'
)
const parse = (command: Record<string, unknown>) => apply?.params?.safeParse({ command })
const open = { type: 'openSimulatorTab', workspace: WORKTREE, tabId: 'sim-1', groupId: 'g1' }
expect(parse({ ...open, afterTabId: 'tab-1', createdAt: 5 })?.success).toBe(true)
expect(parse({ ...open, split: { besideGroupId: 'g0', direction: 'right' } })?.success).toBe(
true
)
expect(parse({ ...open, tabId: undefined })?.success).toBe(false)
expect(parse({ ...open, index: -1 })?.success).toBe(false)
})
it('accepts an editor tab move to the workspace the window resolved, naming its new ids', async () => {
const apply = eraseRpcMethods(LAYOUT_APPLY_METHODS).find(
(method) => method.name === 'layout.apply'
@@ -12,6 +12,8 @@ import {
} from './unified-tab-host-ownership'
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 { recordOpenedSimulatorTabInLayoutOwner } from './simulator-tab-layout-owner'
type EnsureSimulatorTabOptions = {
targetGroupId?: string
@@ -135,6 +137,9 @@ export function ensureSimulatorTab(
activate: true,
...(executionHostId ? { executionHostId } : {})
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordOpenedSimulatorTab(tabId)
}
surfaceCreatedSimulatorTab(worktreeId, tabId)
return tabId
}
@@ -155,6 +160,9 @@ export function ensureSimulatorTab(
}
)
if (splitTab) {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordOpenedSimulatorTab(splitTab.id)
}
return splitTab.id
}
}
@@ -167,12 +175,24 @@ export function ensureSimulatorTab(
activate: shouldSurface,
...(executionHostId ? { executionHostId } : {})
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordOpenedSimulatorTab(tabId)
}
if (shouldSurface) {
surfaceCreatedSimulatorTab(worktreeId, tabId)
}
return tabId
}
/** Switch point: main records the simulator tab the window just drew. */
function recordOpenedSimulatorTab(tabId: string): void {
const state = useAppStore.getState()
const tab = state.getTab(tabId)
if (tab) {
recordOpenedSimulatorTabInLayoutOwner(state, tab)
}
}
function surfaceCreatedSimulatorTab(worktreeId: string, tabId: string): void {
const store = useAppStore.getState()
store.activateTab(tabId)
@@ -0,0 +1,300 @@
// @vitest-environment happy-dom
// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands, so the
// window's next save changes nothing in main's model (what the save-divergence detector checks).
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
createTestStore,
makeRuntimeOwnedWorktree,
makeWorktree,
runtimeOwnedRepoState,
seedStore
} from '@/store/slices/store-test-helpers'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
type LayoutLoad = ReturnType<typeof loadWorkspaceLayoutWithoutReport>
type Mocks = {
layoutOwnerIsMain: boolean
store: ReturnType<typeof createTestStore> | null
main: LayoutLoad | null
sent: LayoutCommand[]
}
const mocks = vi.hoisted((): Mocks => ({
layoutOwnerIsMain: true,
store: null,
main: null,
sent: []
}))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('@/store', () => ({
get useAppStore() {
return mocks.store
}
}))
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn(
async (_target: unknown, method: string, params?: { command?: LayoutCommand }) => {
if (method !== 'layout.apply' || !params?.command) {
return {}
}
mocks.sent.push(params.command)
const main = mainLoad()
// As runtime-layout-apply.ts: the window already confirmed its close, so main forces it.
const command: LayoutCommand =
params.command.type === 'closeTabs' ? { ...params.command, force: true } : params.command
const applied = applyLayoutCommand(main.layout, command, {
mintId: createBrowserUuid,
mintLeafId: createBrowserUuid,
now: Date.now
})
if (!applied.ok) {
return { ok: false, code: applied.code }
}
mocks.main = { ...main, layout: applied.model }
return { ok: true, result: applied.result }
}
)
}))
createStoreCascadesMockApi()
import { ensureSimulatorTab } from './ensure-simulator-tab'
const WORKTREE = 'repo1::/path/wt'
const TERMINAL = 'terminal-1'
const GROUP = 'group-1'
function store(): ReturnType<typeof createTestStore> {
if (!mocks.store) {
throw new Error('store not seeded')
}
return mocks.store
}
function mainLoad(): LayoutLoad {
if (!mocks.main) {
throw new Error('main not loaded')
}
return mocks.main
}
function loadWindowSave(): LayoutLoad {
return loadWorkspaceLayoutWithoutReport(
LOCAL_EXECUTION_HOST_ID,
buildWorkspaceSessionPayload(store().getState()),
nameBasedLoadContext()
)
}
/** The workspace as the window's save would give it to main, beside main's stored copy. */
function expectSaveTeachesMainNothing(worktreeId = WORKTREE): void {
const stored = saveWorkspaceLayout(mainLoad())
const main = loadWorkspaceLayoutWithoutReport(
LOCAL_EXECUTION_HOST_ID,
stored,
nameBasedLoadContext()
).layout
expect(loadWindowSave().layout.workspaces[worktreeId]).toEqual(main.workspaces[worktreeId])
}
async function settled(): Promise<void> {
await vi.waitFor(() => Promise.resolve())
await new Promise((resolve) => setTimeout(resolve, 0))
}
function terminalTab(id: string, groupId: string, worktreeId = WORKTREE): Tab {
return {
id,
entityId: id,
groupId,
worktreeId,
contentType: 'terminal',
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function seed(groups: TabGroup[], layout: TabGroupLayoutNode): void {
const terminals = groups.flatMap((group) => group.tabOrder)
seedStore(store(), {
worktreesByRepo: { repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })] },
activeWorktreeId: WORKTREE,
tabsByWorktree: {
[WORKTREE]: terminals.map((id, index) => ({
id,
ptyId: null,
worktreeId: WORKTREE,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: index,
createdAt: 1
}))
},
unifiedTabsByWorktree: {
[WORKTREE]: groups.flatMap((group) => group.tabOrder.map((id) => terminalTab(id, group.id)))
},
groupsByWorktree: { [WORKTREE]: groups },
activeGroupIdByWorktree: { [WORKTREE]: groups[0]!.id },
layoutByWorktree: { [WORKTREE]: layout }
})
// Main starts from the window's last save, as it does today.
mocks.main = loadWindowSave()
}
beforeEach(() => {
mocks.layoutOwnerIsMain = true
mocks.sent = []
vi.spyOn(console, 'warn').mockImplementation(() => {})
mocks.store = createTestStore()
seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], {
type: 'leaf',
groupId: GROUP
})
})
function sentTypes(): string[] {
return mocks.sent.map((command) => command.type)
}
describe('simulator tabs in a layout-owner build', () => {
it('starts with main holding the window’s layout', () => {
expectSaveTeachesMainNothing()
})
it('records a simulator tab opened in the active group, and its close', async () => {
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' })
expect(tabId).toBeTruthy()
await settled()
expectSaveTeachesMainNothing()
store().getState().closeUnifiedTab(tabId!)
await settled()
expectSaveTeachesMainNothing()
expect(sentTypes()).toEqual(['openSimulatorTab', 'closeTabs'])
})
it('records the split group a right-side open makes, and its collapse on close', async () => {
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' })
await settled()
expect(store().getState().groupsByWorktree[WORKTREE]).toHaveLength(2)
expectSaveTeachesMainNothing()
expect(mainLoad().layout.workspaces[WORKTREE]?.groupLayout).toMatchObject({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: GROUP }
})
store().getState().closeUnifiedTab(tabId!)
await settled()
expect(store().getState().groupsByWorktree[WORKTREE]).toHaveLength(1)
expectSaveTeachesMainNothing()
})
it('records a right-side open into the right group the workspace already has', async () => {
const right = 'group-2'
seed(
[
{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] },
{ id: right, worktreeId: WORKTREE, activeTabId: 'terminal-2', tabOrder: ['terminal-2'] }
],
{
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: GROUP },
second: { type: 'leaf', groupId: right }
}
)
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' })
await settled()
expect(store().getState().getTab(tabId!)?.groupId).toBe(right)
expectSaveTeachesMainNothing()
})
it('records an open into the window’s emptied root group', async () => {
seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], {
type: 'leaf',
groupId: GROUP
})
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' })
await settled()
expect(mocks.sent).toMatchObject([
{ type: 'openSimulatorTab', tabId, groupId: GROUP, index: 0 }
])
expectSaveTeachesMainNothing()
})
it('sends nothing when the workspace already has its simulator tab', async () => {
const tabId = ensureSimulatorTab(WORKTREE)
await settled()
mocks.sent = []
expect(ensureSimulatorTab(WORKTREE)).toBe(tabId)
await settled()
expect(mocks.sent).toEqual([])
})
it('leaves a paired server’s simulator tab to that server', async () => {
const remote = 'repo2::/path/remote'
const remoteGroup = 'group-remote'
seedStore(
store(),
runtimeOwnedRepoState('env-1', 'repo2', '/path/remote', {
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })],
repo2: [makeRuntimeOwnedWorktree({ id: remote, repoId: 'repo2' }, 'env-1')]
},
activeWorktreeId: remote,
unifiedTabsByWorktree: { [remote]: [terminalTab('terminal-r', remoteGroup, remote)] },
groupsByWorktree: {
[remote]: [
{
id: remoteGroup,
worktreeId: remote,
activeTabId: 'terminal-r',
tabOrder: ['terminal-r']
}
]
},
activeGroupIdByWorktree: { [remote]: remoteGroup },
layoutByWorktree: { [remote]: { type: 'leaf', groupId: remoteGroup } }
})
)
const tabId = ensureSimulatorTab(remote, { placement: 'rightSplit' })
expect(tabId).toBeTruthy()
store().getState().closeUnifiedTab(tabId!)
await settled()
expect(mocks.sent).toEqual([])
})
it('sends nothing with the switch off', async () => {
mocks.layoutOwnerIsMain = false
const inGroup = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' })
store().getState().closeUnifiedTab(inGroup!)
const split = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' })
store().getState().closeUnifiedTab(split!)
await settled()
expect(mocks.sent).toEqual([])
})
})
@@ -0,0 +1,42 @@
// Simulator tabs in a layout-owner build. The window still draws the tab first (the emulator pane
// attaches to it by id); main records each open and close under the window's ids, so a window save
// no longer teaches main a simulator tab. Nothing to undo on any answer.
import type { Tab } from '../../../shared/tab-types'
import type { AppState } from '@/store/types'
import { recordLayoutApply } from '@/runtime/layout-apply-send'
import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split'
type SimulatorTabState = Parameters<typeof recordLayoutApply>[0] &
Pick<AppState, 'groupsByWorktree' | 'layoutByWorktree'>
/** A simulator tab the window just drew, placed where the window drew it. */
export function recordOpenedSimulatorTabInLayoutOwner(state: SimulatorTabState, tab: Tab): void {
if (tab.contentType !== 'simulator') {
return
}
const order = state.groupsByWorktree[tab.worktreeId]?.find(
(group) => group.id === tab.groupId
)?.tabOrder
const position = order?.indexOf(tab.id) ?? -1
const afterTabId = position > 0 ? order?.[position - 1] : undefined
recordLayoutApply(state, tab.worktreeId, {
type: 'openSimulatorTab',
tabId: tab.id,
groupId: tab.groupId,
...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {}),
createdAt: tab.createdAt,
// Opened to the right: main makes the window's new split with the tab.
...newGroupSplitOf(state, tab.worktreeId, tab.groupId, tab.id)
})
}
/** A simulator tab the window just closed. */
export function recordClosedSimulatorTabInLayoutOwner(
state: SimulatorTabState,
tab: Pick<Tab, 'id' | 'worktreeId' | 'contentType'>
): void {
if (tab.contentType === 'simulator') {
recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] })
}
}
@@ -101,6 +101,15 @@ export type WindowLayoutCommand =
split?: NewGroupSplit
}
| { type: 'promotePreviewTab'; tabId: string }
| {
type: 'openSimulatorTab'
tabId: string
groupId: string
afterTabId?: string
index?: number
createdAt: number
split?: NewGroupSplit
}
| {
type: 'moveTabToWorkspace'
toWorkspace: string
@@ -26,6 +26,7 @@ import { settleAttentionRemovedBy } from '../workspace-attention/attention-remov
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordClosedEditorTabInLayoutOwner } from '../editor/tabs/editor-tab-layout-owner'
import { recordClosedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner'
import { recordClosedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner'
export function createTabsCloseActions(
set: TabsSliceSet,
@@ -208,10 +209,11 @@ export function createTabsCloseActions(
}
})
)
// Switch point: main records an editor or chat tab the window just closed.
// Switch point: main records an editor, chat or simulator tab the window just closed.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordClosedEditorTabInLayoutOwner(get(), tab)
recordClosedAgentSessionTabInLayoutOwner(get(), tab)
recordClosedSimulatorTabInLayoutOwner(get(), tab)
}
if (opts?.recordInteraction !== false) {
@@ -1,11 +1,11 @@
import { z } from 'zod'
import { isTerminalLeafId, parsePaneKey } from '../stable-pane-id'
import { isTerminalLeafId } from '../stable-pane-id'
import { isValidHostTerminalTabId } from '../terminal-tab-id'
import type { TuiAgent } from '../tui-agent'
import { isTuiAgent } from '../tui-agent-config'
import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params'
import { TerminalOrphanGroupLayout } from './terminal-orphan-params'
import { sleepingAgentSessionRecordSchema } from '../workspace-session-sleeping-agents'
import { SleepCommand, WakeCommand } from './workspace-layout-sleep-wake-params'
export const LayoutSubscribeParams = z
.object({
@@ -281,6 +281,17 @@ const SetBrowserTabProfileCommand = z.object({
const PromotePreviewTabCommand = z.object({ type: z.literal('promotePreviewTab'), ...OnTab })
/** A simulator tab under the id the window drew it with, which is also its entity. */
const OpenSimulatorTabCommand = z.object({
type: z.literal('openSimulatorTab'),
...OnTab,
groupId: z.string().min(1).optional(),
afterTabId: z.string().min(1).optional(),
index: z.number().int().nonnegative().optional(),
createdAt: z.number().int().nonnegative().optional(),
split: NewGroupSplit
})
/**
* Restored editor tabs the window moved to the workspace that owns their file (F42): the window
* resolved that owner. Main refuses it when the two workspaces are in different partitions.
@@ -320,51 +331,13 @@ const RenameEditorFilesCommand = z.object({
.min(1)
})
/** Pane keys of the command's own tab only, so a sleep or wake never reaches past it. */
function paneKeysNameTab(command: { tabId: string; paneKeys: string[] }): boolean {
return command.paneKeys.every((paneKey) => parsePaneKey(paneKey)?.tabId === command.tabId)
}
/**
* The window's sleep of panes of one tab it already stopped; main records how each resumes.
* `recordOnly`: a resume record of a running agent (a live or quit capture); nothing stops.
*/
const SleepCommand = z
.object({
type: z.literal('sleep'),
...OnTab,
paneKeys: z.array(z.string().min(1)).min(1),
records: z.array(sleepingAgentSessionRecordSchema),
recordOnly: z.literal(true).optional()
})
.refine(paneKeysNameTab)
.refine((command) =>
command.records.every(
(record) =>
record.worktreeId === command.workspace && command.paneKeys.includes(record.paneKey)
)
)
/**
* The window's wake of the one pane it shows; never more, so agents are not resumed in bulk.
* `recordOnly`: the window dropped the panes' resume records; nothing starts, so any count.
*/
const WakeCommand = z
.object({
type: z.literal('wake'),
...OnTab,
paneKeys: z.array(z.string().min(1)).min(1),
recordOnly: z.literal(true).optional()
})
.refine((command) => command.recordOnly === true || command.paneKeys.length === 1)
.refine(paneKeysNameTab)
/**
* A window's layout command (`layout.apply`, registered only in a layout-owner build). The window
* names what it acts on; main mints every new id but a created terminal tab's
* (see CreateTerminalTabCommand), a browser tab's and page's, which the window already shows
* (see OpenBrowserTabCommand), an editor tab's (see OpenEditorTabCommand), a chat tab's (see
* OpenAgentSessionTabCommand), and the group an open into a new split makes (see NewGroupSplit).
* OpenAgentSessionTabCommand), a simulator tab's (see OpenSimulatorTabCommand), and the group an
* open into a new split makes (see NewGroupSplit).
*/
export const LayoutApplyParams = z.object({
command: z.discriminatedUnion('type', [
@@ -391,6 +364,7 @@ export const LayoutApplyParams = z.object({
OpenEditorTabCommand,
OpenAgentSessionTabCommand,
PromotePreviewTabCommand,
OpenSimulatorTabCommand,
MoveTabToWorkspaceCommand,
RenameEditorFilesCommand,
SleepCommand,
@@ -0,0 +1,46 @@
// `layout.apply`'s sleep and wake commands (see LayoutApplyParams).
import { z } from 'zod'
import { parsePaneKey } from '../stable-pane-id'
import { sleepingAgentSessionRecordSchema } from '../workspace-session-sleeping-agents'
// copied, not imported: import cycle
const OnTab = { workspace: z.string().min(1), tabId: z.string().min(1) }
/** Pane keys of the command's own tab only, so a sleep or wake never reaches past it. */
function paneKeysNameTab(command: { tabId: string; paneKeys: string[] }): boolean {
return command.paneKeys.every((paneKey) => parsePaneKey(paneKey)?.tabId === command.tabId)
}
/**
* The window's sleep of panes of one tab it already stopped; main records how each resumes.
* `recordOnly`: a resume record of a running agent (a live or quit capture); nothing stops.
*/
export const SleepCommand = z
.object({
type: z.literal('sleep'),
...OnTab,
paneKeys: z.array(z.string().min(1)).min(1),
records: z.array(sleepingAgentSessionRecordSchema),
recordOnly: z.literal(true).optional()
})
.refine(paneKeysNameTab)
.refine((command) =>
command.records.every(
(record) =>
record.worktreeId === command.workspace && command.paneKeys.includes(record.paneKey)
)
)
/**
* The window's wake of the one pane it shows; never more, so agents are not resumed in bulk.
* `recordOnly`: the window dropped the panes' resume records; nothing starts, so any count.
*/
export const WakeCommand = z
.object({
type: z.literal('wake'),
...OnTab,
paneKeys: z.array(z.string().min(1)).min(1),
recordOnly: z.literal(true).optional()
})
.refine((command) => command.recordOnly === true || command.paneKeys.length === 1)
.refine(paneKeysNameTab)
@@ -124,6 +124,18 @@ export type LayoutCommand =
split?: NewGroupSplit
})
| (On & { type: 'promotePreviewTab'; tabId: string })
| (On & {
type: 'openSimulatorTab'
/** The window's id for the tab it drew; also the tab's entity. */
tabId: string
groupId?: string
/** Placement in the group: after this tab, else at this index, else at its end. */
afterTabId?: string
index?: number
/** When the window drew the tab, so a later save of it matches main's copy. */
createdAt?: number
split?: NewGroupSplit
})
| (On & {
type: 'moveTabToWorkspace'
/** The workspace that owns the tabs' file, as the window resolved it; same partition. */
@@ -21,6 +21,7 @@ import {
splitGroup
} from './workspace-layout-group-commands'
import { renameEditorFiles } from './workspace-layout-editor-rekey'
import { openSimulatorTab } from './workspace-layout-simulator-tab'
import { updateLegacyPersistence } from './workspace-layout-legacy-persistence'
import type { WorkspaceLayoutModel } from './workspace-layout-model'
import {
@@ -47,7 +48,8 @@ const CREATES_WORKSPACE = new Set<LayoutCommand['type']>([
'createTerminalTab',
'openEditorTab',
'openBrowserTab',
'openAgentSessionTab'
'openAgentSessionTab',
'openSimulatorTab'
])
export function applyLayoutCommand(
@@ -106,6 +108,8 @@ function applyCommand(
return setChatPane(model, command)
case 'openEditorTab':
return openInNewGroup(model, command, (next) => openEditorTab(next, command, context))
case 'openSimulatorTab':
return openInNewGroup(model, command, (next) => openSimulatorTab(next, command, context))
case 'promotePreviewTab':
return promotePreviewTab(model, command)
case 'moveTabToWorkspace':
@@ -218,7 +218,7 @@ export function openAgentSessionTab(
* first) changes nothing; with `refuseTaken`, an id another tab holds refuses it. Null when the
* open goes ahead.
*/
function drawnTabOpen(
export function drawnTabOpen(
model: WorkspaceLayoutModel,
key: string,
tabId: string | undefined,
@@ -239,7 +239,7 @@ function drawnTabOpen(
}
/** A workspace with no group takes the sender's, as the window keeps its emptied root group. */
function withSendersGroup(
export function withSendersGroup(
model: WorkspaceLayoutModel,
key: string,
groupId: string | undefined
@@ -135,7 +135,11 @@ export function setGroupRatios(
}
type NewGroupOpen = CommandOf<
'createTerminalTab' | 'openEditorTab' | 'openBrowserTab' | 'openAgentSessionTab'
| 'createTerminalTab'
| 'openEditorTab'
| 'openBrowserTab'
| 'openAgentSessionTab'
| 'openSimulatorTab'
>
/**
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { applyLayoutCommand } from './workspace-layout-commands'
import type { CommandOf } from './workspace-layout-command-types'
import { build, emptyModel, testContext, WS } from './workspace-layout-command.test-fixture'
const drawn = (
fields: Partial<CommandOf<'openSimulatorTab'>> = {}
): CommandOf<'openSimulatorTab'> => ({
type: 'openSimulatorTab',
workspace: WS,
tabId: 'sim-1',
...fields
})
describe('a simulator tab a window drew', () => {
it('keeps the window’s id, group and creation time in a workspace main holds no group of', () => {
const { model, results } = build(testContext(), [drawn({ groupId: 'g1', createdAt: 7 })])
expect(results[0]?.tabId).toBe('sim-1')
expect(model.workspaces[WS]?.groups).toEqual([{ id: 'g1', tabOrder: ['sim-1'] }])
expect(model.workspaces[WS]?.tabs).toEqual([
{
id: 'sim-1',
entityId: 'sim-1',
kind: 'simulator',
createdAt: 7,
customTitle: null,
color: null
}
])
})
it('changes nothing when sent again, and refuses an id another tab holds', () => {
const ctx = testContext()
const { model, results } = build(ctx, [{ type: 'createTerminalTab', workspace: WS }, drawn()])
const again = applyLayoutCommand(model, drawn(), ctx)
expect(again.ok && again.model).toEqual(model)
const taken = applyLayoutCommand(model, drawn({ tabId: results[0]!.tabId! }), ctx)
expect(taken.ok ? 'applied' : taken.code).toBe('tab_exists')
})
it('refuses a group main does not hold beside the groups it does', () => {
const ctx = testContext()
const { model } = build(ctx, [{ type: 'createTerminalTab', workspace: WS }])
const placed = applyLayoutCommand(model, drawn({ groupId: 'elsewhere' }), ctx)
expect(placed.ok ? 'applied' : placed.code).toBe('group_not_found')
})
it('takes its place after the tab the window drew it beside', () => {
const ctx = testContext()
const { model, results } = build(ctx, [
{ type: 'createTerminalTab', workspace: WS },
{ type: 'createTerminalTab', workspace: WS }
])
const first = results[0]!.tabId!
const placed = build(ctx, [drawn({ afterTabId: first })], model).model
expect(placed.workspaces[WS]?.groups[0]?.tabOrder).toEqual([first, 'sim-1', results[1]!.tabId!])
})
it('opens into the new split group the window made for it, and a close collapses it', () => {
const ctx = testContext()
const { model } = build(ctx, [{ type: 'createTerminalTab', workspace: WS }])
const besideGroupId = model.workspaces[WS]!.groups[0]!.id
const split = build(
ctx,
[drawn({ groupId: 'g2', split: { besideGroupId, direction: 'right' } })],
model
).model
expect(split.workspaces[WS]?.groupLayout).toMatchObject({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: besideGroupId },
second: { type: 'leaf', groupId: 'g2' }
})
const closed = build(
ctx,
[{ type: 'closeTabs', workspace: WS, tabIds: ['sim-1'], force: true }],
split
).model
expect(closed.workspaces[WS]?.groups.map((group) => group.id)).toEqual([besideGroupId])
})
it('makes the workspace when main holds none', () => {
const { model } = build(testContext(), [drawn()], emptyModel())
expect(model.workspaces[WS]?.groups).toHaveLength(1)
})
})
@@ -0,0 +1,41 @@
// A simulator (mobile emulator) tab a window drew: main records it under the window's ids. The
// emulator itself is the window's; the layout holds only the tab.
import { applied, refuse, type Applied } from './workspace-layout-command-steps'
import type { CommandOf, LayoutContext } from './workspace-layout-command-types'
import { drawnTabOpen, withSendersGroup } from './workspace-layout-content-commands'
import type { WorkspaceLayoutModel } from './workspace-layout-model'
import { placeContentTab } from './workspace-layout-tab-commands'
/** The same open again changes nothing; an id another tab holds, or a group main lacks, refuses. */
export function openSimulatorTab(
model: WorkspaceLayoutModel,
command: CommandOf<'openSimulatorTab'>,
context: LayoutContext
): Applied {
const key = command.workspace
// Only a layout-owner window sends this command, so a taken id is always refused.
const drawn = drawnTabOpen(model, key, command.tabId, (tab) => tab.kind === 'simulator', true)
if (drawn) {
return drawn
}
const workspace = model.workspaces[key]
if (
command.groupId &&
workspace?.groups.length &&
!workspace.groups.some((entry) => entry.id === command.groupId)
) {
// Another group would put the tab where the window did not.
return refuse('group_not_found')
}
const tab = {
id: command.tabId,
entityId: command.tabId,
kind: 'simulator' as const,
createdAt: command.createdAt ?? context.now(),
customTitle: null,
color: null
}
const target = withSendersGroup(model, key, command.groupId)
return applied(placeContentTab(target, key, tab, command, context).model, { tabId: tab.id })
}