mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user