Open and close browser tabs and pages through main behind the layout-owner build switch (off by default) (#29353)

* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER

A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the
window's layout command to the stored partition through the shared applyLayoutCommand,
minting new ids. A user split then waits for main's answer and mounts the pane id main
minted. Every other build substitutes 'window', so neither branch is compiled in.

* fix(layout): write only the records a layout.apply command touched

A load-then-save rewrite also normalizes records the command never touched. The command's
effect is now the diff between the save before and after it, written onto the stored
partition record by record. A split whose main reply says a paired server holds the tab
splits as today.

* fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result

* feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER

In a layout-owner build the window's pane close, title change and equalize go through
layout.apply and the window shows main's answer. A window pane close keeps the legacy
close's rules: the tab's last pane is refused, a pane restarted since main read the close is
left alone, every partition holding the tab closes it, and the tab's remote session id follows
the remaining pane. The rules sit behind a command field only layout.apply sets.

* fix(layout): close the tab when a racing pane close leaves the window one pane

* fix(layout): leave an unmounted tab alone when main refuses its last pane

* feat(layout): move panes and save divider ratios through main behind ORCA_LAYOUT_OWNER

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): a taken pane drop whose pane closed does nothing

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): pin the window's pane move to main's saved tree, and the drops main cannot take

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* fix(layout): close a user's pane even if its terminal restarted, as today

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): pin the restarted-pane close directly, not through a made-up profile state

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* fix(layout): leave a split to today's path only when the workspace's row names a paired server

The split switch point asked which host to route to with a helper that falls back to the focused
server, so a focused server could claim a workspace whose row names no owner. Use the same owner
test the tab mirror uses.

* test(layout): type the split owner mock without a cast

* test(layout): type the layout-owner command mocks with the real signature

* test(layout): type the gesture command mock with the real signature

* fix(layout): close the dark path's gaps: unmounted split panes, refused or failed splits and moves, ratios during a move, release guard

- A pane main saved but the window could not mount (source pane or manager gone during the await)
  is closed again in main, so it does not come back on restart.
- A split main refuses because its copy lacks the workspace, tab or pane, or a failed call, splits
  as today; a refused or failed move moves as today.
- Divider ratios committed while a move is in flight wait for the window's move, then go to main.
- The orcad bundle gets the ORCA_LAYOUT_OWNER define when the switch is on.
- Packaging and the orcad template refuse ORCA_LAYOUT_OWNER=main and any output a dark build marked.
- workspace-layout-record-patch.ts: no non-null assertions; plain comment for the map depth.

* test(layout): count a split pane as mounted from the split mock's own results

* fix(layout): act as today whenever main lacks the target or the call throws, not just for splits

- sendPaneCommandToLayoutOwner runs asToday for workspace/tab/pane_not_found and a thrown call, so
  close, rename and equalize fall back like split and move; a handler that throws no longer runs the
  fallback too.
- A close main refuses as its last pane, while this window still has others, closes as today.
- A move whose local step throws still releases held divider ratios.
- Drop the orcad output marker: nothing read it; the template build refuses the switch itself.

* test(layout): plain names for the last-pane fallback test and the marker comment

* docs(layout): say plainly what an indeterminate write can leave behind

* fix(layout): finish the main merge with this branch's versions of the layout-owner files

* test(build): copy the layout-owner build guard into the compiler-cache checkout fixture

* feat(layout): send tab rename, color and pin through main behind the layout-owner build switch

In an ORCA_LAYOUT_OWNER=main build, the tab bar's rename, color and pin ask main's layout.apply
first; the window then runs today's action. When main lacks the tab or the call fails the
window acts as today; a paired server's workspace keeps today's path. Pinning a tab now also
ends its preview in main's copy, as the tab bar's pin does. Other builds compile the switch out.

* fix(layout): leave a pinned tab's preview mark to the window until main opens files

Changing the shared pin command would change what a paired client's pin does in every build.

* fix(layout): write a folded legacy row in its loaded form when a layout.apply command touches its claim

A legacy terminal row the load folds into a tab-bar tab stays stored as is. Closing the tab it
repeats, or giving it a tab-bar entry by moving it, let the next load resolve the claim the other
way: the row came back or took the other tab's terminal. Such a command now writes the row as
the load had it; every other record still stays as stored.

* feat(layout): accept tab moves, group splits and group sizes on layout.apply

Group sizes have no tab, so they go to the workspace's routed partition.

* refactor(layout): move the window's layout.apply sender out of the terminal pane folder

Tab groups send through it too; it now also acts as today when main lacks the group.

* feat(layout): reorder, move and split tab groups, and save group sizes, through main behind ORCA_LAYOUT_OWNER

A tab drop and Move Tab to Split wait for main's answer and then show main's placement, with main's
new group id; group divider sizes are drawn while dragging and saved by main when the drag ends.
Closing an empty group needs no command: main never holds an empty group.

* docs(layout): wrap the layout-owner sender's doc comment

* fix(layout): review fixes for tab moves through main

- Read a folded row's terminals with the loader's own claim rule, so rows sharing only an SSH
  session are covered too, and drop the row's remote session entry with it.
- The record-patch test allows a folded row only in its loaded form.
- A tab drag ends at once; main's answer shows the move after.
- A lone group main moved whole is placed so only when this window's group is lone too.

* refactor(layout): adopt only a folded row's pane layout beside its row

A row's remote session entry is carried through load and save unchanged, so adopting it from the
load changed nothing (the earlier commit's message overstated it).

* feat(layout): record a window's terminal tab close through layout.apply

closeTabs can now record each terminal tab it closes, or does not find, as closed for the given
reason (pruned as a partition's records are), so a start that commits later is refused, as today's
close records it. layout.apply takes one tab's close with the window's reason: main forces it in
every holder partition, marks a user close's launches, stops no terminal (the window already did,
sparing any another tab shares), and leaves a workspace it lacks to today's close. A sibling the
load supplied that the close only renumbered is no longer written.

* feat(layout): send the window's terminal tab close to main behind ORCA_LAYOUT_OWNER

The window still closes the tab and stops its terminals itself; main records the close through
layout.apply instead of the close intent, which stays for paired servers and when main lacks the
workspace.

* fix(layout): review fixes for the tab close through main

- The window's close sends through a store-free sender, so the store no longer imports itself.
- Main marks a user close's launches by the terminal's own id.
- A recorded close prunes the whole partition's close records, as today, not one workspace's.

* feat(layout): open and close browser tabs and pages through main behind the layout-owner build switch

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

* refactor(layout): name the browser sender's no-op so the window bundle keeps its names

* feat(layout): move a window's dragged-out pane into a new tab through layout.apply behind ORCA_LAYOUT_OWNER

* refactor(layout): keep the topology commit under its line limit

* fix(layout): keep a served browser tab's open, address and close in order while main writes it

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

* fix(layout): review fixes for the window's pane drag-out through main

* docs(layout): note what a refused served-tab close leaves

* refactor(layout): re-key a moved pane from the move alone

* refactor(layout): share one record-only layout.apply sender and inline the id schema

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

* Restore the browser layout-apply hook dropped by the main merge

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-16 02:44:14 -07:00
committed by GitHub
co-authored by Claude
parent 1b79a3af7f
commit 115c26e1b8
21 changed files with 1149 additions and 22 deletions
@@ -199,6 +199,55 @@ describe('layout.apply', () => {
).toBe(false)
})
it('takes a browser tab under the window’s ids, and its page and profile changes', async () => {
const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply')
const parse = (command: Record<string, unknown>) => apply?.params?.safeParse({ command })
const open = {
type: 'openBrowserTab',
workspace: WORKTREE,
tabId: 'entry-1',
browserTabId: 'browser-1',
pageId: 'page-1',
profileId: null,
sessionPartition: null,
groupId: 'g1',
afterTabId: 'tab-1'
}
const page = { workspace: WORKTREE, browserTabId: 'browser-1', pageId: 'page-1' }
expect(parse(open)?.success).toBe(true)
expect(parse({ ...open, pageId: undefined })?.success).toBe(false)
expect(parse({ ...open, browserTabId: '' })?.success).toBe(false)
expect(parse({ ...open, index: -1 })?.success).toBe(false)
expect(parse({ type: 'openBrowserPage', ...page })?.success).toBe(true)
expect(parse({ type: 'closeBrowserPage', ...page })?.success).toBe(true)
expect(parse({ type: 'replaceBrowserPage', ...page, newPageId: 'page-2' })?.success).toBe(true)
expect(parse({ type: 'replaceBrowserPage', ...page })?.success).toBe(false)
expect(
parse({
type: 'setBrowserTabProfile',
workspace: WORKTREE,
browserTabId: 'browser-1',
profileId: 'p1',
sessionPartition: null
})?.success
).toBe(true)
})
it('applies a browser tab’s record change to the workspace’s routed partition', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
const page = {
type: 'openBrowserPage',
workspace: WORKTREE,
browserTabId: 'browser-1',
pageId: 'page-2'
} as const
await runtime.applyLayoutCommand(page)
expect(applyLayoutCommand).toHaveBeenCalledWith(page, ['local'])
})
it('applies group sizes to the workspace’s routed partition', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
const ratios = {
+16 -3
View File
@@ -7,9 +7,18 @@ import type { TerminalLeafMoveRequest } from '../../shared/terminal-leaf-move'
import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment'
import type { RuntimeStore } from './runtime-store-contract'
/** A window's layout command: one on a tab, which names its partitions, or on the group tree. */
/**
* A window's layout command: one on a tab, which names its partitions, or on the group tree or a
* browser tab's record, which go to the workspace's routed partition.
*/
export type WindowLayoutCommand = LayoutCommand &
({ tabId: string } | { type: 'setGroupRatios' } | { type: 'closeTabs'; tabIds: [string] })
(
| { tabId: string }
| { type: 'setGroupRatios' }
| { type: 'closeTabs'; tabIds: [string] }
// A browser tab's record lives in its workspace's partition, so it routes by workspace.
| { browserTabId: string }
)
/**
* `layout.apply`: the window's command on the partitions holding its tab, routed partition first;
@@ -24,7 +33,11 @@ export async function applyWindowLayoutCommand(
onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {}
): Promise<LayoutApplyReply> {
const routedTabId =
'tabId' in command ? command.tabId : command.type === 'closeTabs' ? command.tabIds[0] : null
'tabId' in command
? (command.tabId ?? null)
: command.type === 'closeTabs'
? command.tabIds[0]
: null
const hostIds = hostIdsFor(command.workspace, routedTabId)
// Why: a paired server's workspaces keep today's mirrored layout until that server owns it.
if (!hostIds[0] || parseExecutionHostId(hostIds[0])?.kind === 'runtime') {
+66 -7
View File
@@ -61,13 +61,7 @@ export function withOpenedServeBrowserTab(
}
const { facts } = loaded
const state = { ...BLANK_BROWSER_TAB_STATE, url: page.url, title: page.title }
const savedPage: BrowserPage = {
...state,
id: page.pageId,
workspaceId: page.pageId,
worktreeId: page.worktreeId,
createdAt: now
}
const savedPage = servePageRecord(page, now)
return saveWorkspaceLayout({
...loaded,
layout: applied.model,
@@ -82,6 +76,71 @@ export function withOpenedServeBrowserTab(
})
}
function servePageRecord(page: ServeBrowserPageState, createdAt: number): BrowserPage {
return {
...BLANK_BROWSER_TAB_STATE,
url: page.url,
title: page.title,
id: page.pageId,
workspaceId: page.pageId,
worktreeId: page.worktreeId,
createdAt
}
}
/**
* The partition with the page record of a tab `openBrowserTab` just wrote under the page's id, and
* the tab's address; null when that tab is gone or its page is saved already.
*/
export function withServeBrowserPageRecord(
session: WorkspaceSessionState,
page: ServeBrowserPageState
): WorkspaceSessionState | null {
const tabs = session.browserTabsByWorktree?.[page.worktreeId] ?? []
const tab = tabs.find((entry) => entry.id === page.pageId)
if (!tab || findSavedBrowserPage(session, page.worktreeId, page.pageId)) {
return null
}
return {
...session,
browserTabsByWorktree: {
...session.browserTabsByWorktree,
[page.worktreeId]: tabs.map((entry) =>
entry === tab ? { ...entry, url: page.url, title: page.title } : entry
)
},
browserPagesByWorkspace: {
...session.browserPagesByWorkspace,
[page.pageId]: [servePageRecord(page, tab.createdAt)]
}
}
}
/** The partition without a closed tab's page records; null when it holds none. */
export function withoutServeBrowserPages(
session: WorkspaceSessionState,
browserTabId: string
): WorkspaceSessionState | null {
if (!session.browserPagesByWorkspace?.[browserTabId]) {
return null
}
const { [browserTabId]: _closed, ...pages } = session.browserPagesByWorkspace
return { ...session, browserPagesByWorkspace: pages }
}
/** The tab-bar entry showing a saved browser tab: a window's has its own id, a host's the tab's. */
export function savedBrowserTabEntryId(
session: WorkspaceSessionState,
worktreeId: string,
browserTabId: string
): string {
return (
session.unifiedTabs?.[worktreeId]?.find(
(entry) => entry.contentType === 'browser' && entry.entityId === browserTabId
)?.id ?? browserTabId
)
}
/**
* The partition with the page's last address, or null when no saved tab shows the page or it is
* unchanged. Only address fields change, so the layout is not reloaded.
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
LayoutApplyOutcome,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
import { ServeBrowserTabs } from './serve-browser-tabs'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const WT = 'repo-1::/repo'
const PAGE = { worktreeId: WT, pageId: 'page-1', url: 'https://example.com/', title: 'Example' }
/** A host with no window whose layout writes go through `layout.apply`'s record patch. */
function servedHost(initial: WorkspaceSessionState = getDefaultWorkspaceSession()) {
let session = initial
const save = vi.fn((_worktreeId: string, next: WorkspaceSessionState) => {
session = next
})
const applyLayout = vi.fn(
async (command: LayoutCommand, hostId: ExecutionHostId): Promise<LayoutApplyOutcome> => {
const change = applyCommandToSession(hostId, session, command)
session = change.session ?? session
return change.value
}
)
const tabs = new ServeBrowserTabs({
backend: () => ({ createTab: vi.fn(), closeTab: vi.fn() }),
livePageIds: () => new Set(),
savedWorktreeIds: () => [WT],
session: () => session,
hostId: () => 'local',
save,
applyLayout
})
return { tabs, applyLayout, save, session: () => session }
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('served browser tabs in a layout-owner build', () => {
it('opens a tab through layout.apply, then saves its page so a restart lists it', async () => {
const host = servedHost()
host.tabs.opened({ ...PAGE, profileId: 'p1' })
await settle()
expect(host.applyLayout).toHaveBeenCalledWith(
{ type: 'openBrowserTab', workspace: WT, pageId: 'page-1', profileId: 'p1' },
'local'
)
expect(host.session().browserTabsByWorktree?.[WT]).toEqual([
expect.objectContaining({
id: 'page-1',
pageIds: ['page-1'],
sessionProfileId: 'p1',
url: PAGE.url,
title: PAGE.title
})
])
expect(host.session().browserPagesByWorkspace?.['page-1']).toEqual([
expect.objectContaining({ id: 'page-1', workspaceId: 'page-1', url: PAGE.url })
])
expect(host.tabs.listUnstarted(WT).map((tab) => tab.browserPageId)).toEqual(['page-1'])
})
it('does not open a saved page again', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
await settle()
host.tabs.opened(PAGE)
await settle()
expect(host.applyLayout).toHaveBeenCalledTimes(1)
expect(host.session().browserTabsByWorktree?.[WT]).toHaveLength(1)
})
it('closes a tab through layout.apply and drops its page records', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
await settle()
expect(host.tabs.closed(WT, 'page-1')).toBe(true)
await settle()
expect(host.session().browserTabsByWorktree?.[WT] ?? []).toEqual([])
expect(host.session().unifiedTabs?.[WT] ?? []).toEqual([])
expect(host.session().browserPagesByWorkspace?.['page-1']).toBeUndefined()
expect(host.tabs.closed(WT, 'page-1')).toBe(false)
})
it('closes a window-saved tab by its own tab-bar entry', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
await settle()
// As a window saves it: the tab-bar entry has its own id.
const saved = host.session()
host.save(WT, {
...saved,
unifiedTabs: {
[WT]: saved.unifiedTabs![WT]!.map((entry) => ({ ...entry, id: 'unified-1' }))
},
tabGroups: {
[WT]: saved.tabGroups![WT]!.map((group) => ({
...group,
activeTabId: null,
tabOrder: ['unified-1']
}))
}
})
host.tabs.closed(WT, 'page-1')
await settle()
expect(host.applyLayout).toHaveBeenLastCalledWith(
{ type: 'closeTabs', workspace: WT, tabIds: ['unified-1'], force: true },
'local'
)
expect(host.session().browserTabsByWorktree?.[WT] ?? []).toEqual([])
expect(host.session().unifiedTabs?.[WT] ?? []).toEqual([])
})
it('saves the address a page reached while main was writing its tab', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
host.tabs.navigated({ ...PAGE, url: 'https://example.com/next', title: 'Next' })
await settle()
expect(host.session().browserPagesByWorkspace?.['page-1']).toEqual([
expect.objectContaining({ url: 'https://example.com/next', title: 'Next' })
])
expect(host.session().browserTabsByWorktree?.[WT]?.[0]).toMatchObject({ title: 'Next' })
})
it('closes a tab closed while main was writing it, and saves no page for it', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
expect(host.tabs.closed(WT, 'page-1')).toBe(true)
await settle()
expect(host.session().browserTabsByWorktree?.[WT] ?? []).toEqual([])
expect(host.session().browserPagesByWorkspace?.['page-1']).toBeUndefined()
expect(host.tabs.listUnstarted(WT)).toEqual([])
})
it('stops listing a closed tab before main answers', async () => {
const host = servedHost()
host.tabs.opened(PAGE)
await settle()
host.tabs.closed(WT, 'page-1')
expect(host.tabs.listUnstarted(WT)).toEqual([])
})
it('saves no page when main refuses the open', async () => {
const host = servedHost()
host.applyLayout.mockResolvedValueOnce({ ok: false, code: 'invalid_params' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
host.tabs.opened(PAGE)
await settle()
expect(host.save).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
+113
View File
@@ -6,14 +6,24 @@ import type { BrowserBackend } from '../browser/browser-backend'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserTabInfo } from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type {
LayoutApplyOutcome,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
import {
findSavedBrowserPage,
savedBrowserTabEntryId,
withClosedServeBrowserTab,
withOpenedServeBrowserTab,
withoutServeBrowserPages,
withServeBrowserPageRecord,
withServeBrowserPageState,
type ServeBrowserPageState
} from './serve-browser-tab-layout'
type LayoutApply = (command: LayoutCommand, hostId: ExecutionHostId) => Promise<LayoutApplyOutcome>
type RestorableBrowserPage = {
/** The saved tab; a host's own tab is named by its page, a window's by its own id. */
tabId: string
@@ -35,10 +45,20 @@ export class ServeBrowserTabs {
session: (worktreeId: string) => WorkspaceSessionState | null
hostId: (worktreeId: string) => ExecutionHostId
save: (worktreeId: string, session: WorkspaceSessionState) => void
/** `layout.apply` on one partition; set only in a layout-owner build. */
applyLayout?: LayoutApply
}
) {}
/** Opens main has not answered yet, by page id: the page's latest address, which the open saves. */
private readonly pendingOpens = new Map<string, ServeBrowserPageState>()
opened(page: ServeBrowserPageState & { profileId?: string; groupId?: string }): void {
// Switch point: main's layout command writes the tab; the page's address is a fact saved after.
if (ORCA_LAYOUT_OWNER_IS_MAIN && this.deps.applyLayout) {
this.openThroughLayout(page, this.deps.applyLayout)
return
}
this.write(page.worktreeId, (hostId, session) =>
withOpenedServeBrowserTab(hostId, session, page)
)
@@ -46,11 +66,20 @@ export class ServeBrowserTabs {
/** A saved tab follows its page; a page whose tab was closed is not saved again. */
navigated(page: ServeBrowserPageState): void {
// Switch point: an open main has not answered yet saves the page's latest address.
if (ORCA_LAYOUT_OWNER_IS_MAIN && this.pendingOpens.has(page.pageId)) {
this.pendingOpens.set(page.pageId, page)
return
}
this.write(page.worktreeId, (_hostId, session) => withServeBrowserPageState(session, page))
}
/** A closed page's tab, true when one was saved; a page destroyed with the process is not a close. */
closed(worktreeId: string, pageId: string): boolean {
// Switch point: main's layout command closes the tab; its page records are facts dropped first.
if (ORCA_LAYOUT_OWNER_IS_MAIN && this.deps.applyLayout) {
return this.closeThroughLayout(worktreeId, pageId, this.deps.applyLayout)
}
return this.write(worktreeId, (hostId, session) =>
withClosedServeBrowserTab(hostId, session, worktreeId, pageId)
)
@@ -150,6 +179,90 @@ export class ServeBrowserTabs {
return true
}
private openThroughLayout(
page: ServeBrowserPageState & { profileId?: string; groupId?: string },
applyLayout: LayoutApply
): void {
const { worktreeId, pageId } = page
const session = this.deps.backend() ? this.deps.session(worktreeId) : null
if (
!session ||
findSavedBrowserPage(session, worktreeId, pageId) ||
this.pendingOpens.has(pageId)
) {
return
}
this.pendingOpens.set(pageId, page)
const command: LayoutCommand = {
type: 'openBrowserTab',
workspace: worktreeId,
pageId,
...(page.profileId ? { profileId: page.profileId } : {}),
...(page.groupId ? { groupId: page.groupId } : {})
}
void this.applyLogged(applyLayout, worktreeId, command).then((ok) => {
const latest = this.pendingOpens.get(pageId)
this.pendingOpens.delete(pageId)
if (ok && latest) {
this.write(worktreeId, (_hostId, next) => withServeBrowserPageRecord(next, latest))
} else if (ok) {
// Closed while main wrote it: a host's tab is named by its page.
void this.applyLogged(applyLayout, worktreeId, {
type: 'closeTabs',
workspace: worktreeId,
tabIds: [pageId],
force: true
})
}
})
}
private closeThroughLayout(
worktreeId: string,
pageId: string,
applyLayout: LayoutApply
): boolean {
// Its open closes the tab once main answers.
if (this.pendingOpens.delete(pageId)) {
return true
}
const session = this.deps.backend() ? this.deps.session(worktreeId) : null
const saved = session ? findSavedBrowserPage(session, worktreeId, pageId) : null
if (!session || !saved) {
return false
}
// Pages first: a tab with no saved page is listed nowhere, so the close shows at once (and a
// refused close leaves only an unlisted tab record).
this.write(worktreeId, (_hostId, next) => withoutServeBrowserPages(next, saved.tabId))
void this.applyLogged(applyLayout, worktreeId, {
type: 'closeTabs',
workspace: worktreeId,
tabIds: [savedBrowserTabEntryId(session, worktreeId, saved.tabId)],
force: true
})
return true
}
/** True when main applied the command; a refusal or failure is logged, never thrown. */
private applyLogged(
applyLayout: LayoutApply,
worktreeId: string,
command: LayoutCommand
): Promise<boolean> {
return applyLayout(command, this.deps.hostId(worktreeId)).then(
(outcome) => {
if (!outcome.ok) {
console.warn(`[serve-browser-tabs] ${command.type} refused:`, outcome.code)
}
return outcome.ok
},
(error: unknown) => {
console.warn(`[serve-browser-tabs] could not ${command.type}:`, error)
return false
}
)
}
private write(
worktreeId: string,
change: (
@@ -38,6 +38,8 @@ import { RuntimeWorktreeBaseReconciliation } from '../../runtime-worktree-base-r
import { RuntimeWorktreeRemovalInFlight } from '../../runtime-worktree-removal-in-flight'
import { ServeBrowserTabs } from '../../serve-browser-tabs'
import type { ServeBrowserPageState } from '../../serve-browser-tab-layout'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import type { LayoutCommand } from '../../../../shared/workspace-layout/workspace-layout-command-types'
export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitOverrideListeners {
declare registerSubscriptionCleanup: RuntimeServiceCommandSurface['registerSubscriptionCleanup']
@@ -142,7 +144,19 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO
),
session: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
hostId: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId),
save: (worktreeId, session) => this.setWorkspaceSessionForWorktree(worktreeId, session)
save: (worktreeId, session) => this.setWorkspaceSessionForWorktree(worktreeId, session),
// Switch point: a layout-owner build writes served tabs through `layout.apply`.
...(ORCA_LAYOUT_OWNER_IS_MAIN
? {
applyLayout: async (command: LayoutCommand, hostId: ExecutionHostId) => {
const store = this.requireStore()
if (!store.applyLayoutCommand) {
throw new Error('layout_store_unavailable')
}
return store.applyLayoutCommand(command, [hostId])
}
}
: {})
})
serveBrowserPageNavigated(page: ServeBrowserPageState): void {
+31 -1
View File
@@ -11,7 +11,7 @@ import type {
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
/** The commands a window sends `layout.apply`; main mints any new id. */
/** The commands a window sends `layout.apply`; main mints any new id but a browser tab's. */
export type WindowLayoutCommand =
| { type: 'splitPane'; tabId: string; leafId: string; direction: 'vertical' | 'horizontal' }
| { type: 'closePane'; tabId: string; leafId: string }
@@ -33,6 +33,25 @@ export type WindowLayoutCommand =
index?: number
windowDetach: { ptyId: string | null }
}
| {
type: 'openBrowserTab'
tabId: string
browserTabId: string
pageId: string
profileId: string | null
sessionPartition: string | null
groupId?: string
afterTabId?: string
index?: number
}
| { type: 'openBrowserPage' | 'closeBrowserPage'; browserTabId: string; pageId: string }
| { type: 'replaceBrowserPage'; browserTabId: string; pageId: string; newPageId: string }
| {
type: 'setBrowserTabProfile'
browserTabId: string
profileId: string | null
sessionPartition: string | null
}
export type LayoutApplyHandlers = {
apply: (result: LayoutCommandResult) => void
@@ -87,6 +106,17 @@ export function sendLayoutApply(
return true
}
const keepWindowCopy = (): void => {}
/** Records a change the window already made; any answer leaves it, since the window's save carries it. */
export function recordLayoutApply(
state: Parameters<typeof sendLayoutApply>[0],
worktreeId: string,
command: WindowLayoutCommand
): void {
sendLayoutApply(state, worktreeId, command, { apply: keepWindowCopy, asToday: keepWindowCopy })
}
function mainLacksTarget(code: LayoutRefusalCode): boolean {
return (
code === 'workspace_not_found' ||
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { recordLayoutApply } from '@/runtime/layout-apply-send'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
const mocks = vi.hoisted(() => ({ send: vi.fn<typeof recordLayoutApply>() }))
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send }))
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
createStoreCascadesMockApi()
const WT = 'repo1::/path/wt1'
function seeded(): ReturnType<typeof createTestStore> {
const store = createTestStore()
seedStore(store, {
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
activeWorktreeId: WT
})
return store
}
const sent = () => mocks.send.mock.calls.map(([, worktreeId, command]) => ({ worktreeId, command }))
function entryOf(store: ReturnType<typeof createTestStore>, browserTabId: string) {
return store
.getState()
.unifiedTabsByWorktree[WT]!.find((entry) => entry.entityId === browserTabId)!
}
beforeEach(() => {
mocks.send.mockReset()
})
describe('browser tabs in a layout-owner build', () => {
it('records an open under the ids and place the window drew it with', () => {
const store = seeded()
const first = store.getState().createBrowserTab(WT, 'about:blank', { sessionProfileId: 'p1' })
const second = store.getState().createBrowserTab(WT, 'https://example.com/')
const firstEntry = entryOf(store, first.id)
expect(sent()).toEqual([
{
worktreeId: WT,
command: {
type: 'openBrowserTab',
tabId: firstEntry.id,
browserTabId: first.id,
pageId: first.pageIds![0],
profileId: 'p1',
sessionPartition: null,
groupId: firstEntry.groupId,
index: 0
}
},
{
worktreeId: WT,
command: expect.objectContaining({
type: 'openBrowserTab',
tabId: entryOf(store, second.id).id,
browserTabId: second.id,
afterTabId: firstEntry.id
})
}
])
})
it('records a close with the window’s reason while the tab-bar entry still names it', () => {
const store = seeded()
const user = store.getState().createBrowserTab(WT, 'about:blank')
const cleanup = store.getState().createBrowserTab(WT, 'about:blank')
const userEntry = entryOf(store, user.id).id
const cleanupEntry = entryOf(store, cleanup.id).id
mocks.send.mockClear()
store.getState().closeBrowserTab(user.id)
store.getState().closeBrowserTab(cleanup.id, { reason: 'cleanup' })
expect(sent().map(({ command }) => command)).toEqual([
{ type: 'closeTabs', tabIds: [userEntry], recordClose: 'user' },
{ type: 'closeTabs', tabIds: [cleanupEntry], recordClose: 'cleanup' }
])
})
it('records page create, convert and close, and a profile switch', () => {
const store = seeded()
const tab = store.getState().createBrowserTab(WT, 'about:blank')
mocks.send.mockClear()
// A conversion changes a page's kind: this document page becomes a web page.
const page = store.getState().createBrowserPage(tab.id, 'about:blank', {
docLocation: { kind: 'workspace-doc', worktreeId: WT, filePath: '/path/wt1/index.html' }
})!
const converted = store.getState().convertBrowserPage(page.id, {
kind: 'web',
url: 'https://other.example/'
})!
store.getState().closeBrowserPage(converted.id)
store.getState().switchBrowserTabProfile(tab.id, 'p2', 'persist:p2')
expect(sent().map(({ command }) => command)).toEqual([
{ type: 'openBrowserPage', browserTabId: tab.id, pageId: page.id },
{
type: 'replaceBrowserPage',
browserTabId: tab.id,
pageId: page.id,
newPageId: converted.id
},
{ type: 'closeBrowserPage', browserTabId: tab.id, pageId: converted.id },
{
type: 'setBrowserTabProfile',
browserTabId: tab.id,
profileId: 'p2',
sessionPartition: 'persist:p2'
}
])
expect(sent().every(({ worktreeId }) => worktreeId === WT)).toBe(true)
})
it('sends nothing for a page or tab the window does not hold', () => {
const store = seeded()
expect(store.getState().createBrowserPage('missing', 'about:blank')).toBeNull()
store.getState().closeBrowserPage('missing')
store.getState().closeBrowserTab('missing')
store.getState().switchBrowserTabProfile('missing', null)
expect(mocks.send).not.toHaveBeenCalled()
})
})
@@ -13,6 +13,8 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordBrowserTabClose } from './browser-layout-owner-commands'
export function createBrowserCloseActions(
set: BrowserSliceSet,
@@ -188,6 +190,15 @@ export function createBrowserCloseActions(
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'browser' && entry.entityId === tabId
)
// Switch point: main records the close while the tab-bar entry still names the tab.
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceItem) {
recordBrowserTabClose(
get(),
workspaceItem.worktreeId,
tabId,
isCleanup ? 'cleanup' : 'user'
)
}
if (workspaceItem) {
get().closeUnifiedTab(
workspaceItem.id,
@@ -6,6 +6,9 @@ import { buildValidWorktreeIdsForSessionHydration } from '../degraded-repo-workt
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
import { projectHydratedBrowserSession } from './browser-hydration-state'
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
import { findWorkspace } from '../browser-page-records'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordLayoutApply } from '@/runtime/layout-apply-send'
export function createBrowserHydrationActions(
set: BrowserSliceSet,
@@ -92,6 +95,18 @@ export function createBrowserHydrationActions(
}
return s
})
// Switch point: main records the tab's new profile.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
const tab = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (tab) {
recordLayoutApply(get(), tab.worktreeId, {
type: 'setBrowserTabProfile',
browserTabId: workspaceId,
profileId,
sessionPartition: sessionPartition ?? null
})
}
}
}
}
}
@@ -0,0 +1,58 @@
// Browser tab and page changes in a layout-owner build (phase C5). The window still draws first
// and saves its whole copy; main records each change under the window's own ids, so a later
// command naming the tab or page finds it. Nothing to undo on any answer: the window's next save
// carries the change when main cannot take it.
import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import { recordLayoutApply, type sendLayoutApply } from '@/runtime/layout-apply-send'
import type { AppState } from '../../types'
type BrowserCommandState = Parameters<typeof sendLayoutApply>[0] &
Pick<AppState, 'unifiedTabsByWorktree' | 'groupsByWorktree'>
/** The open of a browser tab the window just drew, placed where the window drew it. */
export function recordBrowserTabOpen(state: BrowserCommandState, tab: BrowserWorkspace): void {
const entry = browserTabEntry(state, tab.worktreeId, tab.id)
const pageId = tab.pageIds?.[0]
if (!entry || !pageId) {
return
}
const order = state.groupsByWorktree[tab.worktreeId]?.find(
(group) => group.id === entry.groupId
)?.tabOrder
const position = order?.indexOf(entry.id) ?? -1
const afterTabId = position > 0 ? order?.[position - 1] : undefined
recordLayoutApply(state, tab.worktreeId, {
type: 'openBrowserTab',
tabId: entry.id,
browserTabId: tab.id,
pageId,
profileId: tab.sessionProfileId ?? null,
sessionPartition: tab.sessionPartition ?? null,
groupId: entry.groupId,
...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {})
})
}
/** The close of a browser tab whose tab-bar entry the window is about to remove. */
export function recordBrowserTabClose(
state: BrowserCommandState,
worktreeId: string,
browserTabId: string,
reason: 'user' | 'cleanup'
): void {
const entry = browserTabEntry(state, worktreeId, browserTabId)
if (entry) {
recordLayoutApply(state, worktreeId, {
type: 'closeTabs',
tabIds: [entry.id],
recordClose: reason
})
}
}
function browserTabEntry(state: BrowserCommandState, worktreeId: string, browserTabId: string) {
return state.unifiedTabsByWorktree[worktreeId]?.find(
(entry) => entry.contentType === 'browser' && entry.entityId === browserTabId
)
}
@@ -12,6 +12,8 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { isLocalBrowserPageOwner } from './browser-host-state'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordLayoutApply } from '@/runtime/layout-apply-send'
export function createBrowserPageConversionActions(
set: BrowserSliceSet,
@@ -114,6 +116,15 @@ export function createBrowserPageConversionActions(
releaseDocPreviewGrant(docPageIdToRelease)
}
const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId)
// Switch point: main puts the new page in the old one's place.
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceAfter) {
recordLayoutApply(get(), workspaceAfter.worktreeId, {
type: 'replaceBrowserPage',
browserTabId: workspaceAfter.id,
pageId,
newPageId: newPage.id
})
}
if (workspaceAfter?.activePageId === newPage.id) {
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
@@ -14,6 +14,8 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordLayoutApply } from '@/runtime/layout-apply-send'
export function createBrowserPageCreateActions(
set: BrowserSliceSet,
@@ -85,6 +87,14 @@ export function createBrowserPageCreateActions(
}
})
// Switch point: main records the page under its id before any later command names it.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordLayoutApply(get(), workspace.worktreeId, {
type: 'openBrowserPage',
browserTabId: workspaceId,
pageId: page.id
})
}
const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (nextWorkspace?.activePageId === page.id) {
const item = Object.values(get().unifiedTabsByWorktree)
@@ -198,6 +208,14 @@ export function createBrowserPageCreateActions(
return
}
const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId)
// Switch point: main drops the page from its tab.
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspace) {
recordLayoutApply(get(), workspace.worktreeId, {
type: 'closeBrowserPage',
browserTabId: closedWorkspaceId,
pageId
})
}
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId)
@@ -24,6 +24,8 @@ import {
loadPairedBrowserTabCreator
} from './paired-browser-tab-creator'
import { findGroupAndWorktree } from '../tab-group-state'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordBrowserTabOpen } from './browser-layout-owner-commands'
export function createBrowserTabActions(
set: BrowserSliceSet,
@@ -173,6 +175,10 @@ export function createBrowserTabActions(
get().focusGroup(worktreeId, created.groupId)
}
}
// Switch point: main records the tab under these ids before any later command names it.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
recordBrowserTabOpen(get(), browserTab)
}
return browserTab
},
@@ -144,9 +144,53 @@ const SetTabPropsCommand = z.object({
isPinned: z.boolean().optional()
})
/**
* A browser tab under the ids the window drew it with: its pages and the tab's own callers need
* them at once. Main refuses an id another tab or page holds; the same open again changes nothing.
*/
const OpenBrowserTabCommand = z.object({
type: z.literal('openBrowserTab'),
workspace: OnPane.workspace,
tabId: z.string().min(1),
browserTabId: z.string().min(1),
pageId: z.string().min(1),
profileId: z.string().min(1).nullable().optional(),
sessionPartition: z.string().min(1).nullable().optional(),
groupId: z.string().min(1).optional(),
afterTabId: z.string().min(1).optional(),
index: z.number().int().nonnegative().optional()
})
const OnBrowserPage = {
workspace: OnPane.workspace,
browserTabId: z.string().min(1),
pageId: z.string().min(1)
}
/** A page the window added to a browser tab, under its own id. */
const OpenBrowserPageCommand = z.object({ type: z.literal('openBrowserPage'), ...OnBrowserPage })
const CloseBrowserPageCommand = z.object({ type: z.literal('closeBrowserPage'), ...OnBrowserPage })
/** A converted page: the window's new page takes the old one's place. */
const ReplaceBrowserPageCommand = z.object({
type: z.literal('replaceBrowserPage'),
...OnBrowserPage,
newPageId: z.string().min(1)
})
const SetBrowserTabProfileCommand = z.object({
type: z.literal('setBrowserTabProfile'),
workspace: OnPane.workspace,
browserTabId: z.string().min(1),
profileId: z.string().min(1).nullable(),
sessionPartition: z.string().min(1).nullable()
})
/**
* 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 tab's (see CreateTerminalTabCommand).
* names what it acts on; main mints every new id but a created tab's (see CreateTerminalTabCommand)
* and a browser tab's and page's, which the window already shows (see OpenBrowserTabCommand).
*/
export const LayoutApplyParams = z.object({
command: z.discriminatedUnion('type', [
@@ -163,6 +207,11 @@ export const LayoutApplyParams = z.object({
CloseTabsCommand,
RenameTabCommand,
SetTabPropsCommand,
MovePaneToNewTabCommand
MovePaneToNewTabCommand,
OpenBrowserTabCommand,
OpenBrowserPageCommand,
CloseBrowserPageCommand,
ReplaceBrowserPageCommand,
SetBrowserTabProfileCommand
])
})
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import { applyLayoutCommand } from './workspace-layout-commands'
import type { CommandOf, LayoutCommand } from './workspace-layout-command-types'
import { asLoaded, build, testContext, WS } from './workspace-layout-command.test-fixture'
import type { WorkspaceLayoutModel } from './workspace-layout-model'
import { saveWorkspaceLayout } from './workspace-layout-save'
const WINDOW_OPEN: CommandOf<'openBrowserTab'> = {
type: 'openBrowserTab',
workspace: WS,
tabId: 'entry-1',
browserTabId: 'browser-1',
pageId: 'page-1',
profileId: 'p1',
sessionPartition: 'persist:p1'
}
/** A host's browser tab, then a window's. */
function setup() {
const ctx = testContext()
const { model, results } = build(ctx, [{ type: 'openBrowserTab', workspace: WS }, WINDOW_OPEN])
return { ctx, model, hostTab: results[0]!.tabId! }
}
function outcome(model: WorkspaceLayoutModel, command: LayoutCommand) {
const result = applyLayoutCommand(model, command, testContext())
return result.ok ? result : result.code
}
const records = (model: WorkspaceLayoutModel) => model.workspaces[WS]!.browserTabs
describe('openBrowserTab under the window’s ids', () => {
it('writes the tab-bar entry, the record and its page under those ids', () => {
const { model } = setup()
const workspace = model.workspaces[WS]!
expect(workspace.tabs.at(-1)).toMatchObject({
id: 'entry-1',
kind: 'browser',
entityId: 'browser-1'
})
expect(records(model)?.at(-1)).toEqual({
id: 'browser-1',
sessionProfileId: 'p1',
sessionPartition: 'persist:p1',
pageIds: ['page-1'],
createdAt: expect.any(Number)
})
const saved = saveWorkspaceLayout(asLoaded(model))
expect(saved.unifiedTabs?.[WS]?.at(-1)).toMatchObject({ id: 'entry-1', entityId: 'browser-1' })
expect(saved.browserTabsByWorktree?.[WS]?.at(-1)).toMatchObject({ pageIds: ['page-1'] })
})
it('places the tab where the window drew it', () => {
const { ctx, model, hostTab } = setup()
const placed = build(
ctx,
[
{ ...WINDOW_OPEN, tabId: 'entry-2', browserTabId: 'browser-2', pageId: 'p-2', index: 0 },
{
...WINDOW_OPEN,
tabId: 'entry-3',
browserTabId: 'browser-3',
pageId: 'p-3',
afterTabId: hostTab
}
],
model
).model
expect(placed.workspaces[WS]!.groups[0]!.tabOrder).toEqual([
'entry-2',
hostTab,
'entry-3',
'entry-1'
])
})
it('places a tab whose group main lacks beside its anchor', () => {
const { ctx, model } = setup()
const split = build(
ctx,
[
{
type: 'splitGroup',
workspace: WS,
tabId: 'entry-1',
besideGroupId: model.workspaces[WS]!.groups[0]!.id,
direction: 'right'
},
{
...WINDOW_OPEN,
tabId: 'entry-2',
browserTabId: 'browser-2',
pageId: 'p-2',
groupId: 'unknown-group',
afterTabId: 'entry-1'
}
],
model
).model
expect(split.workspaces[WS]!.groups.map((group) => group.tabOrder)).toEqual([
[expect.any(String)],
['entry-1', 'entry-2']
])
})
it('changes nothing when sent again, and refuses an id another tab or page holds', () => {
const { model, hostTab } = setup()
const again = outcome(model, WINDOW_OPEN)
expect(again).toMatchObject({ result: { tabId: 'entry-1', pageId: 'page-1' } })
expect(typeof again !== 'string' && again.model).toEqual(model)
expect(outcome(model, { ...WINDOW_OPEN, browserTabId: 'browser-9', pageId: 'p-9' })).toBe(
'tab_exists'
)
expect(outcome(model, { ...WINDOW_OPEN, tabId: 'entry-9', pageId: 'p-9' })).toBe('tab_exists')
expect(outcome(model, { ...WINDOW_OPEN, tabId: hostTab, browserTabId: 'b-9' })).toBe(
'tab_exists'
)
expect(outcome(model, { ...WINDOW_OPEN, tabId: 'entry-9', browserTabId: 'browser-9' })).toBe(
'page_exists'
)
})
})
describe('browser tab record commands', () => {
it('opens, replaces and closes pages in place', () => {
const { ctx, model } = setup()
const on = { workspace: WS, browserTabId: 'browser-1' }
const next = build(
ctx,
[
{ type: 'openBrowserPage', ...on, pageId: 'page-2' },
{ type: 'openBrowserPage', ...on, pageId: 'page-2' },
{ type: 'replaceBrowserPage', ...on, pageId: 'page-1', newPageId: 'page-3' },
{ type: 'replaceBrowserPage', ...on, pageId: 'page-1', newPageId: 'page-3' }
],
model
).model
expect(records(next)?.at(-1)?.pageIds).toEqual(['page-3', 'page-2'])
const closed = build(
ctx,
[
{ type: 'closeBrowserPage', ...on, pageId: 'page-3' },
{ type: 'closeBrowserPage', ...on, pageId: 'page-3' }
],
next
).model
expect(records(closed)?.at(-1)?.pageIds).toEqual(['page-2'])
})
it('refuses a page another tab holds, and a record the workspace lacks', () => {
const { model, hostTab } = setup()
const hostPage = records(model)![0]!.pageIds![0]!
expect(hostTab).toBe(records(model)![0]!.id)
const on = { workspace: WS, browserTabId: 'browser-1' }
expect(outcome(model, { type: 'openBrowserPage', ...on, pageId: hostPage })).toBe('page_exists')
expect(
outcome(model, { type: 'replaceBrowserPage', ...on, pageId: 'page-1', newPageId: hostPage })
).toBe('page_exists')
expect(
outcome(model, { type: 'closeBrowserPage', workspace: WS, browserTabId: 'gone', pageId: 'x' })
).toBe('tab_not_found')
})
it('switches the tab’s profile', () => {
const { ctx, model } = setup()
const next = build(
ctx,
[
{
type: 'setBrowserTabProfile',
workspace: WS,
browserTabId: 'browser-1',
profileId: null,
sessionPartition: null
}
],
model
).model
expect(records(next)?.at(-1)).toMatchObject({ sessionProfileId: null, sessionPartition: null })
})
})
@@ -0,0 +1,94 @@
// A browser tab's own record: its pages and its session profile. The pages' addresses and titles
// are facts its host view reports, not layout.
import { applied, refuse, type Applied } from './workspace-layout-command-steps'
import type { CommandOf } from './workspace-layout-command-types'
import { holdsBrowserPage } from './workspace-layout-content-commands'
import type { LayoutBrowserTab, WorkspaceLayoutModel } from './workspace-layout-model'
import { withWorkspace } from './workspace-layout-removal'
/** Appends a page; the same page again changes nothing, one another tab holds is refused. */
export function openBrowserPage(
model: WorkspaceLayoutModel,
command: CommandOf<'openBrowserPage'>
): Applied {
return withPageIds(model, command, (pageIds) =>
pageIds.includes(command.pageId)
? pageIds
: holdsBrowserPage(model, command.pageId)
? 'page_exists'
: [...pageIds, command.pageId]
)
}
/** Closing a page the record no longer lists is not an error. */
export function closeBrowserPage(
model: WorkspaceLayoutModel,
command: CommandOf<'closeBrowserPage'>
): Applied {
return withPageIds(model, command, (pageIds) => pageIds.filter((id) => id !== command.pageId))
}
/** The new page takes the old one's place; one already replaced changes nothing. */
export function replaceBrowserPage(
model: WorkspaceLayoutModel,
command: CommandOf<'replaceBrowserPage'>
): Applied {
return withPageIds(model, command, (pageIds) => {
if (pageIds.includes(command.newPageId)) {
return pageIds
}
if (holdsBrowserPage(model, command.newPageId)) {
return 'page_exists'
}
return pageIds.includes(command.pageId)
? pageIds.map((id) => (id === command.pageId ? command.newPageId : id))
: [...pageIds, command.newPageId]
})
}
export function setBrowserTabProfile(
model: WorkspaceLayoutModel,
command: CommandOf<'setBrowserTabProfile'>
): Applied {
return withRecord(model, command, (record) => ({
...record,
sessionProfileId: command.profileId,
sessionPartition: command.sessionPartition
}))
}
function withPageIds(
model: WorkspaceLayoutModel,
command: { workspace: string; browserTabId: string },
change: (pageIds: string[]) => string[] | 'page_exists'
): Applied {
return withRecord(model, command, (record) => {
const pageIds = change(record.pageIds ?? [])
return pageIds === 'page_exists' ? pageIds : { ...record, pageIds }
})
}
function withRecord(
model: WorkspaceLayoutModel,
command: { workspace: string; browserTabId: string },
change: (record: LayoutBrowserTab) => LayoutBrowserTab | 'page_exists'
): Applied {
const workspace = model.workspaces[command.workspace]!
const records = workspace.browserTabs ?? []
const record = records.find((entry) => entry.id === command.browserTabId)
if (!record) {
return refuse('tab_not_found')
}
const next = change(record)
if (next === 'page_exists') {
return refuse(next)
}
return applied(
withWorkspace(model, command.workspace, {
...workspace,
browserTabs: records.map((entry) => (entry === record ? next : entry))
}),
{ tabId: command.browserTabId }
)
}
@@ -117,9 +117,26 @@ export type LayoutCommand =
| (On & {
type: 'openBrowserTab'
profileId?: string | null
sessionPartition?: string | null
groupId?: string
/** Placement in the group: after this tab, else at this index, else at its end. */
afterTabId?: string
index?: number
/** The id of a page that already runs; it names the tab too, as a host's page names its row. */
pageId?: string
/** The window's own ids for a tab it drew: its tab-bar entry and its browser record. */
tabId?: string
browserTabId?: string
})
| (On & { type: 'openBrowserPage'; browserTabId: string; pageId: string })
| (On & { type: 'closeBrowserPage'; browserTabId: string; pageId: string })
/** A page the address bar converted: the new page takes the old one's place. */
| (On & { type: 'replaceBrowserPage'; browserTabId: string; pageId: string; newPageId: string })
| (On & {
type: 'setBrowserTabProfile'
browserTabId: string
profileId: string | null
sessionPartition: string | null
})
| (On & {
type: 'openAgentSessionTab'
@@ -166,6 +183,7 @@ export type LayoutRefusalCode =
/** A window's drag-out: another tab holds the pane, or no copy binds the window's terminal. */
| 'pane_in_other_tab'
| 'pty_mismatch'
| 'page_exists'
/** What the runtime does after applying: stop or start terminals. The reply never waits on them. */
export type LayoutEffects = { stopPtyIds: string[]; startPaneKeys: string[] }
@@ -1,6 +1,12 @@
// applyLayoutCommand: every layout change a client can ask for, applied to the model in one step.
// Pure and synchronous, so the runtime applies commands in arrival order on one thread.
import {
closeBrowserPage,
openBrowserPage,
replaceBrowserPage,
setBrowserTabProfile
} from './workspace-layout-browser-commands'
import { applied, refuse, type Applied } from './workspace-layout-command-steps'
import type { LayoutCommand, LayoutContext } from './workspace-layout-command-types'
import {
@@ -97,6 +103,14 @@ function applyCommand(
return promotePreviewTab(model, command)
case 'openBrowserTab':
return openBrowserTab(model, command, context)
case 'openBrowserPage':
return openBrowserPage(model, command)
case 'closeBrowserPage':
return closeBrowserPage(model, command)
case 'replaceBrowserPage':
return replaceBrowserPage(model, command)
case 'setBrowserTabProfile':
return setBrowserTabProfile(model, command)
case 'openAgentSessionTab':
return openAgentSessionTab(model, command, context)
case 'startPane':
@@ -1,7 +1,7 @@
// Opening editor, browser and agent-session tabs: the tab and its content record in one apply.
import type { EditorTabView } from '../editor-tab-view-types'
import { applied, type Applied } from './workspace-layout-command-steps'
import { applied, refuse, type Applied } from './workspace-layout-command-steps'
import type { CommandOf, LayoutContext } from './workspace-layout-command-types'
import { admitsInitialSurface } from './workspace-layout-initial-surface'
import type { LayoutContentTab, WorkspaceLayoutModel } from './workspace-layout-model'
@@ -75,12 +75,20 @@ export function openBrowserTab(
context: LayoutContext
): Applied {
const key = command.workspace
// The tab-bar id and the browser tab record share one id, as the window mints them.
const browserTabId = command.pageId ?? context.mintId()
// A host's tab is named by its page; a window names its tab-bar entry and record itself.
const browserTabId = command.browserTabId ?? command.pageId ?? context.mintId()
const tabId = command.tabId ?? browserTabId
const pageId = command.pageId ?? context.mintId()
// Only a window's open names its record; a host's is checked against its saved pages first.
const taken = command.browserTabId
? takenBrowserTabIds(model, key, { tabId, browserTabId, pageId })
: null
if (taken) {
return taken
}
const tab = contentTab(context, {
kind: 'browser',
id: browserTabId,
id: tabId,
entityId: browserTabId
})
const next = placeContentTab(model, key, tab, command, context).model
@@ -88,6 +96,7 @@ export function openBrowserTab(
const browserTab = {
id: browserTabId,
sessionProfileId: command.profileId ?? null,
...(command.sessionPartition ? { sessionPartition: command.sessionPartition } : {}),
pageIds: [pageId],
createdAt: tab.createdAt
}
@@ -100,6 +109,42 @@ export function openBrowserTab(
)
}
/**
* The same open again (a window's create resent) changes nothing; an id another tab or page holds
* refuses it. Null when every id is free.
*/
function takenBrowserTabIds(
model: WorkspaceLayoutModel,
key: string,
ids: { tabId: string; browserTabId: string; pageId: string }
): Applied | null {
const own = model.workspaces[key]
const record = own?.browserTabs?.find((entry) => entry.id === ids.browserTabId)
if (
record?.pageIds?.includes(ids.pageId) &&
own?.tabs.some((tab) => tab.id === ids.tabId && tab.entityId === ids.browserTabId)
) {
return applied(model, { tabId: ids.tabId, pageId: ids.pageId })
}
const workspaces = Object.values(model.workspaces)
if (
workspaces.some((workspace) =>
workspace.tabs.some((tab) =>
[tab.id, tab.entityId].some((id) => id === ids.tabId || id === ids.browserTabId)
)
)
) {
return refuse('tab_exists')
}
return holdsBrowserPage(model, ids.pageId) ? refuse('page_exists') : null
}
export function holdsBrowserPage(model: WorkspaceLayoutModel, pageId: string): boolean {
return Object.values(model.workspaces).some((workspace) =>
workspace.browserTabs?.some((entry) => entry.pageIds?.includes(pageId))
)
}
export function openAgentSessionTab(
model: WorkspaceLayoutModel,
command: CommandOf<'openAgentSessionTab'>,
@@ -12,7 +12,8 @@ import {
placeNewTab,
refuse,
updateTab,
type Applied
type Applied,
type TabPlacement
} from './workspace-layout-command-steps'
import type {
CommandOf,
@@ -243,12 +244,16 @@ export function placeContentTab(
model: WorkspaceLayoutModel,
key: string,
tab: LayoutContentTab,
placement: { groupId?: string; dirtyTabIds?: string[] },
placement: TabPlacement & { dirtyTabIds?: string[] },
context: LayoutContext
): { model: WorkspaceLayoutModel; refused: RefusedClose[] } {
let workspace = workspaceOrEmpty(model, key)
const anchor = placement.afterTabId
// A named group this copy lacks falls back to the anchor's.
const group =
workspace.groups.find((entry) => entry.id === placement.groupId) ?? workspace.groups[0]
workspace.groups.find((entry) => entry.id === placement.groupId) ??
(anchor ? workspace.groups.find((entry) => entry.tabOrder.includes(anchor)) : undefined) ??
workspace.groups[0]
const preview = tab.isPreview
? workspace.tabs.find(
(entry) =>
@@ -271,7 +276,16 @@ export function placeContentTab(
}
}
return {
model: withWorkspace(model, key, placeNewTab(workspace, tab, { groupId: group?.id }, context)),
model: withWorkspace(
model,
key,
placeNewTab(
workspace,
tab,
{ groupId: group?.id, afterTabId: placement.afterTabId, index: placement.index },
context
)
),
refused
}
}