mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user