diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 8e62d98cfef..289d22ced39 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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) => 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 = { diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 54f58502e3f..b33ed967905 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -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 { 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') { diff --git a/src/main/runtime/serve-browser-tab-layout.ts b/src/main/runtime/serve-browser-tab-layout.ts index 57f920aff49..3e7aac27ada 100644 --- a/src/main/runtime/serve-browser-tab-layout.ts +++ b/src/main/runtime/serve-browser-tab-layout.ts @@ -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. diff --git a/src/main/runtime/serve-browser-tabs-layout-owner.test.ts b/src/main/runtime/serve-browser-tabs-layout-owner.test.ts new file mode 100644 index 00000000000..071737d9261 --- /dev/null +++ b/src/main/runtime/serve-browser-tabs-layout-owner.test.ts @@ -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 => { + 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() + }) +}) diff --git a/src/main/runtime/serve-browser-tabs.ts b/src/main/runtime/serve-browser-tabs.ts index 52dcc533049..4fe994fbbd1 100644 --- a/src/main/runtime/serve-browser-tabs.ts +++ b/src/main/runtime/serve-browser-tabs.ts @@ -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 + 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() + 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 { + 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: ( diff --git a/src/main/runtime/terminal/driver/terminal-driver-resources.ts b/src/main/runtime/terminal/driver/terminal-driver-resources.ts index 25a11edf23f..47324ed20a5 100644 --- a/src/main/runtime/terminal/driver/terminal-driver-resources.ts +++ b/src/main/runtime/terminal/driver/terminal-driver-resources.ts @@ -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 { diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 4af4312a034..10ffab0edd3 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -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[0], + worktreeId: string, + command: WindowLayoutCommand +): void { + sendLayoutApply(state, worktreeId, command, { apply: keepWindowCopy, asToday: keepWindowCopy }) +} + function mainLacksTarget(code: LayoutRefusalCode): boolean { return ( code === 'workspace_not_found' || diff --git a/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts b/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts new file mode 100644 index 00000000000..7a215eba314 --- /dev/null +++ b/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts @@ -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() })) + +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() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreCascadesMockApi() + +const WT = 'repo1::/path/wt1' + +function seeded(): ReturnType { + 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, 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() + }) +}) diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 6f8a891ba72..016a217f2ac 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -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, diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index 6fbb09eb516..22e50b29db6 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -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 + }) + } + } } } } diff --git a/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts b/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts new file mode 100644 index 00000000000..d59dc8f7562 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts @@ -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[0] & + Pick + +/** 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 + ) +} diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts index 11a78f5f17b..89a9927b753 100644 --- a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -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() diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts index 5abbba1b8cb..03ec28ddf11 100644 --- a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -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) diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts index bde1099ecd8..1174e5838cb 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -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 }, diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index f9c17706f18..7316d2edaf8 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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 ]) }) diff --git a/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts new file mode 100644 index 00000000000..0e9b01b991c --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts @@ -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 }) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-browser-commands.ts b/src/shared/workspace-layout/workspace-layout-browser-commands.ts new file mode 100644 index 00000000000..223f25f0dc2 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-browser-commands.ts @@ -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 } + ) +} diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 9dcb298ff75..3354f9771c6 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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[] } diff --git a/src/shared/workspace-layout/workspace-layout-commands.ts b/src/shared/workspace-layout/workspace-layout-commands.ts index 5ca6d213d61..cd10e641c94 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.ts @@ -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': diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index 5931a74566e..d9cab80aa57 100644 --- a/src/shared/workspace-layout/workspace-layout-content-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -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'>, diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 1590fc62b06..cbd7d6d3c6d 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -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 } }