From 609f3425ad64e7b7125ace71099bed0d5da6486b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:48:46 -0700 Subject: [PATCH] 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 --- .../methods/workspace-layout-apply.test.ts | 15 + src/renderer/src/lib/ensure-simulator-tab.ts | 20 ++ .../lib/simulator-tab-layout-owner.test.ts | 300 ++++++++++++++++++ .../src/lib/simulator-tab-layout-owner.ts | 42 +++ src/renderer/src/runtime/layout-apply-send.ts | 9 + .../store/slices/tabs/tabs-close-actions.ts | 4 +- .../rpc-contract/workspace-layout-params.ts | 58 +--- .../workspace-layout-sleep-wake-params.ts | 46 +++ .../workspace-layout-command-types.ts | 12 + .../workspace-layout-commands.ts | 6 +- .../workspace-layout-content-commands.ts | 4 +- .../workspace-layout-group-commands.ts | 6 +- .../workspace-layout-simulator-tab.test.ts | 86 +++++ .../workspace-layout-simulator-tab.ts | 41 +++ 14 files changed, 602 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/lib/simulator-tab-layout-owner.test.ts create mode 100644 src/renderer/src/lib/simulator-tab-layout-owner.ts create mode 100644 src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts create mode 100644 src/shared/workspace-layout/workspace-layout-simulator-tab.test.ts create mode 100644 src/shared/workspace-layout/workspace-layout-simulator-tab.ts diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 57217e2f206..74bdec47948 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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) => 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' diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index e44533b4a9a..685a2849e01 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -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) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.test.ts b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts new file mode 100644 index 00000000000..f6ff5302f60 --- /dev/null +++ b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts @@ -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 + +type Mocks = { + layoutOwnerIsMain: boolean + store: ReturnType | 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 { + 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 { + 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([]) + }) +}) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.ts b/src/renderer/src/lib/simulator-tab-layout-owner.ts new file mode 100644 index 00000000000..782b49e1260 --- /dev/null +++ b/src/renderer/src/lib/simulator-tab-layout-owner.ts @@ -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[0] & + Pick + +/** 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 +): void { + if (tab.contentType === 'simulator') { + recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] }) + } +} diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 676d1c645d5..b198022308f 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -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 diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index b26447a10ed..b61632cfb67 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -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) { diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 01fcca18532..9bc942c3987 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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, diff --git a/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts b/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts new file mode 100644 index 00000000000..cdd085554e4 --- /dev/null +++ b/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts @@ -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) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 8455c49d895..0ec779a620e 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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. */ diff --git a/src/shared/workspace-layout/workspace-layout-commands.ts b/src/shared/workspace-layout/workspace-layout-commands.ts index d31fbb7bbca..209635064f6 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.ts @@ -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([ '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': diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index cd0b3a259f5..93489e4325a 100644 --- a/src/shared/workspace-layout/workspace-layout-content-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -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 diff --git a/src/shared/workspace-layout/workspace-layout-group-commands.ts b/src/shared/workspace-layout/workspace-layout-group-commands.ts index 6dcf24fc9fb..e04aaf92998 100644 --- a/src/shared/workspace-layout/workspace-layout-group-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-group-commands.ts @@ -135,7 +135,11 @@ export function setGroupRatios( } type NewGroupOpen = CommandOf< - 'createTerminalTab' | 'openEditorTab' | 'openBrowserTab' | 'openAgentSessionTab' + | 'createTerminalTab' + | 'openEditorTab' + | 'openBrowserTab' + | 'openAgentSessionTab' + | 'openSimulatorTab' > /** diff --git a/src/shared/workspace-layout/workspace-layout-simulator-tab.test.ts b/src/shared/workspace-layout/workspace-layout-simulator-tab.test.ts new file mode 100644 index 00000000000..3b846401f29 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-simulator-tab.test.ts @@ -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'> => ({ + 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) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-simulator-tab.ts b/src/shared/workspace-layout/workspace-layout-simulator-tab.ts new file mode 100644 index 00000000000..42d4c4a9431 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-simulator-tab.ts @@ -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 }) +}