diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 238a55114e5..58874ff7a4d 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -25,7 +25,9 @@ const supportBridges = new Set([ 'src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts', 'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts', // Autosave suites' store rig; it takes the editor harness's tab-layout maps. - 'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts' + 'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts', + // Browser suites' store with main drawing their tabs; it takes the browser harness's store. + 'src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts' ]) function productConsumers(file, graph, unitFiles) { diff --git a/src/main/runtime/agent-session-tab-layout-command.test.ts b/src/main/runtime/agent-session-tab-layout-command.test.ts new file mode 100644 index 00000000000..e13028fee71 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { chatTabLayoutCommand, loadChatTabLayout } from './agent-session-tab-layout' + +const WT = 'repo-1::/tmp/worktree-a' + +function chatTab(sessionId: string, id: string): Tab { + return { + id, + entityId: sessionId, + groupId: 'group-1', + worktreeId: WT, + contentType: 'agent-session', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(tabs: Tab[], activeGroupId?: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { [WT]: tabs }, + tabGroups: { + [WT]: [{ id: 'group-1', worktreeId: WT, activeTabId: null, tabOrder: tabs.map((t) => t.id) }] + }, + ...(activeGroupId ? { activeGroupIdByWorktree: { [WT]: activeGroupId } } : {}) + } +} + +/** The command for `change` against `stored` as main loads it. */ +function commandOn( + stored: WorkspaceSessionState, + change: Parameters[3] +) { + const { loaded, activeGroupId } = loadChatTabLayout('local', stored, WT) + return chatTabLayoutCommand(loaded.layout.workspaces[WT], activeGroupId, WT, change) +} + +const open = (sessionId: string) => ({ + open: { sessionId, agent: 'codex' as const, tabId: `agent-session:${sessionId}` } +}) + +describe("a host's chat tab change as main's layout command", () => { + it("opens a listed chat in the workspace's remembered active group", () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('b'))).toEqual({ + type: 'openAgentSessionTab', + workspace: WT, + tabId: 'agent-session:b', + sessionId: 'b', + agent: 'codex', + groupId: 'group-1' + }) + }) + + it('names no group the workspace no longer holds, nor one with no layout loaded', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'gone'), open('b'))).toEqual( + expect.not.objectContaining({ groupId: expect.anything() }) + ) + expect(chatTabLayoutCommand(undefined, 'group-1', WT, open('b'))).toEqual( + expect.not.objectContaining({ groupId: expect.anything() }) + ) + }) + + it('opens nothing for a chat a tab already shows', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('a'))).toBeNull() + }) + + it('closes the tab that shows the chat, under its saved id, forced', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'a' })).toEqual({ + type: 'closeTabs', + workspace: WT, + tabIds: ['saved-tab'], + force: true + }) + }) + + it('closes nothing for a chat no tab shows', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'b' })).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-tab-layout-queue.test.ts b/src/main/runtime/agent-session-tab-layout-queue.test.ts new file mode 100644 index 00000000000..56d975d1cd4 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-queue.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import { createChatTabLayoutQueue } from './agent-session-tab-layout-queue' +import type { ChatTabLayoutCommand } from './agent-session-tab-layout' + +const open = (sessionId: string) => ({ + worktreeId: 'wt', + change: { open: { sessionId, agent: 'codex' as const, tabId: `tab-${sessionId}` } } +}) + +it('asks no host of a worktree with no saved session, and goes on past a change that fails', async () => { + const applied: string[] = [] + const hostIdOf = vi.fn((): never => { + throw new Error('folder_workspace_not_found') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const queue = createChatTabLayoutQueue({ + sessionOf: () => null, + hostIdOf, + apply: async (command: ChatTabLayoutCommand) => { + if (command.type === 'openAgentSessionTab' && command.sessionId === 'a') { + throw new Error('layout_store_unavailable') + } + applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close') + return { ok: true, result: {} } + } + }) + + queue(() => [open('a')]) + queue(() => [open('b')]) + await vi.waitFor(() => expect(applied).toEqual(['b'])) + + expect(hostIdOf).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() +}) + +it('goes on past a batch whose changes cannot be read', async () => { + const applied: string[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const queue = createChatTabLayoutQueue({ + sessionOf: () => null, + hostIdOf: () => 'local', + apply: async (command: ChatTabLayoutCommand) => { + applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close') + return { ok: true, result: {} } + } + }) + + queue(() => { + throw new Error('session store unreadable') + }) + queue(() => [open('b')]) + await vi.waitFor(() => expect(applied).toEqual(['b'])) + + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() +}) diff --git a/src/main/runtime/agent-session-tab-layout-queue.ts b/src/main/runtime/agent-session-tab-layout-queue.ts new file mode 100644 index 00000000000..add84705c23 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-queue.ts @@ -0,0 +1,64 @@ +// A layout-owner build's chat tab changes from this host (a chat it publishes, retires or +// restores): main's own layout commands, with or without a window. + +import type { ExecutionHostId } from '../../shared/execution-host' +import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + chatTabLayoutCommand, + loadChatTabLayout, + type ChatTabChange, + type ChatTabLayoutCommand +} from './agent-session-tab-layout' + +export type QueuedChatTabChange = { worktreeId: string; change: ChatTabChange } + +/** + * Runs each batch in order, every change read against the layout as it stands when the queue + * reaches it, so a close follows the open it undoes. A refusal is logged, never thrown. + */ +export function createChatTabLayoutQueue(deps: { + sessionOf: (worktreeId: string) => WorkspaceSessionState | null + /** Read only for a worktree with a saved session; it throws for one the host cannot place. */ + hostIdOf: (worktreeId: string) => ExecutionHostId + apply: (command: ChatTabLayoutCommand) => Promise +}): (changesNow: () => readonly QueuedChatTabChange[]) => void { + let queue: Promise = Promise.resolve() + const commandNow = ({ worktreeId, change }: QueuedChatTabChange) => { + const session = deps.sessionOf(worktreeId) + if (!session) { + return chatTabLayoutCommand(undefined, undefined, worktreeId, change) + } + const hostId = deps.hostIdOf(worktreeId) + const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, worktreeId) + return chatTabLayoutCommand( + loaded.layout.workspaces[worktreeId], + activeGroupId, + worktreeId, + change + ) + } + return (changesNow) => { + queue = queue.then(async () => { + // A batch or change that fails is logged and the queue goes on: a later one must still land. + let changes: readonly QueuedChatTabChange[] + try { + changes = changesNow() + } catch (error) { + console.warn('[agent-session-tabs] reading chat tab changes failed:', error) + return + } + for (const queued of changes) { + try { + const command = commandNow(queued) + const reply = command ? await deps.apply(command) : null + if (command && reply && !reply.ok) { + console.warn(`[agent-session-tabs] ${command.type} refused:`, reply.code) + } + } catch (error) { + console.warn('[agent-session-tabs] a chat tab change failed:', error) + } + } + }) + } +} diff --git a/src/main/runtime/agent-session-tab-layout.ts b/src/main/runtime/agent-session-tab-layout.ts index 404a801b522..6cd7a23a72d 100644 --- a/src/main/runtime/agent-session-tab-layout.ts +++ b/src/main/runtime/agent-session-tab-layout.ts @@ -6,7 +6,8 @@ import type { ExecutionHostId } from '../../shared/execution-host' import { applyLayoutCommand } from '../../shared/workspace-layout/workspace-layout-commands' import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' -import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-model' import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { randomUUID } from 'node:crypto' @@ -17,36 +18,66 @@ export type ChatTabChange = | { open: { sessionId: string; agent: AgentType; tabId: string } } | { close: string } +/** A chat tab change as main's layout command: a window's, which routes by the chat's tab. */ +export type ChatTabLayoutCommand = + | (CommandOf<'openAgentSessionTab'> & { tabId: string }) + | (CommandOf<'closeTabs'> & { tabIds: [string] }) + /** - * The partition with the worktree's chat tabs changed in one layout load: a chat opens in the active - * group unless a saved tab shows it, and a close drops the chat's tab. Null when nothing changed. + * One change against the workspace's layout as loaded now: a chat opens in the active group (while + * the workspace still holds it) unless a tab shows it, and a close drops the chat's tab. Null when + * nothing changes. Main's queue and the windowless save both decide through it. */ +export function chatTabLayoutCommand( + workspace: WorkspaceLayout | undefined, + activeGroupId: string | undefined, + worktreeId: string, + change: ChatTabChange +): ChatTabLayoutCommand | null { + // A remembered group the workspace no longer holds names nothing: the open mints its own. + const groupId = workspace?.groups.some((group) => group.id === activeGroupId) + ? activeGroupId + : undefined + const sessionId = 'close' in change ? change.close : change.open.sessionId + const tabId = + workspace?.tabs.find((tab) => tab.kind === 'agent-session' && tab.entityId === sessionId)?.id ?? + null + if ('close' in change) { + return tabId ? { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true } : null + } + if (tabId) { + return null + } + const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const + return groupId ? { ...open, groupId } : open +} + +/** The layout loaded from a partition, with the worktree's remembered active group. */ +export function loadChatTabLayout( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + worktreeId: string +) { + const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) + return { loaded, activeGroupId: loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] } +} + +/** The partition with the worktree's chat tabs changed in one layout load; null when unchanged. */ export function withChatTabChanges( hostId: ExecutionHostId, session: WorkspaceSessionState, worktreeId: string, changes: readonly ChatTabChange[] ): WorkspaceSessionState | null { - const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) - const activeGroupId = loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] + const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, worktreeId) let layout = loaded.layout for (const change of changes) { - const workspace = layout.workspaces[worktreeId] - // A remembered group the workspace no longer holds names nothing: the open mints its own. - const groupId = workspace?.groups.some((group) => group.id === activeGroupId) - ? activeGroupId - : undefined - const sessionId = 'close' in change ? change.close : change.open.sessionId - const tabId = - workspace?.tabs.find((tab) => tab.kind === 'agent-session' && tab.entityId === sessionId) - ?.id ?? null - let command: LayoutCommand | null = null - if ('close' in change && tabId) { - command = { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true } - } else if ('open' in change && !tabId) { - const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const - command = groupId ? { ...open, groupId } : open - } + const command = chatTabLayoutCommand( + layout.workspaces[worktreeId], + activeGroupId, + worktreeId, + change + ) const applied = command ? applyLayoutCommand(layout, command, { mintId: randomUUID, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts index bd7b14b4401..4a477ad60ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts @@ -157,6 +157,15 @@ describe('a close that reaches main before its create', () => { expect(rig.host.hasSession(SESSION)).toBe(false) }) + it('a close main fails for want of its chat host still refuses the create that follows', async () => { + setStructuredAgentSessionHost(null) + expect(await close()).toMatchObject({ ok: false }) + setStructuredAgentSessionHost(rig.host) + + expect(await create()).toMatchObject(refusedAsClosed) + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + }) + it('switch off: a close before the create is not remembered', async () => { mocks.layoutOwnerIsMain = false await close() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 351feef5d32..1c15a9f26ba 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -118,6 +118,7 @@ export async function handleStructuredAgentSessionCreate( runtime: ctx.runtime, caller: callerFor(ctx), prepared, - activate: true + activate: true, + placedByWindow: ctx.caller?.kind === 'desktop' }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts new file mode 100644 index 00000000000..125efcd8adc --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts @@ -0,0 +1,68 @@ +// A chat this machine's window creates is placed by the window's own open, where it reserved the +// chat's place; main's publish of it places nothing. Any other caller's chat main places itself. +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { DESKTOP_RPC_CALLER, type RpcCallerIdentity } from '../rpc-caller-identity' +import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +// The transport's caller stamp, which the fixture forwards with the rest of the client. +const create = (client: typeof CLIENT & { caller?: RpcCallerIdentity }) => + call( + 'agentSession.create', + structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '00000000-0000-0000-0000-0000000000fb', + now: NOW + }), + client + ) + +it.each([ + ["this machine's window", { ...CLIENT, caller: DESKTOP_RPC_CALLER }, true], + ['a paired device', { ...CLIENT, caller: { kind: 'paired-device', deviceId: 'phone-1' } }, false], + ['a caller the transport did not name', CLIENT, false] +] as const)( + 'a create from %s leaves its chat to the window only when the window made it', + async (_, client, byWindow) => { + expect(await create(client)).toMatchObject({ ok: true, result: { ok: true } }) + + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() + const published = runtimeCalls.publishStructuredAgentSessionTab?.mock.calls[0]?.[0] + expect(published).toMatchObject({ sessionId: SESSION, activate: true }) + expect(published?.placedByWindow).toBe(byWindow ? true : undefined) + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index d5eba180f3c..7c7cd24a2c5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -195,9 +195,12 @@ export async function commitStructuredAgentSessionCreate(args: { caller: StructuredAgentSessionCaller prepared: PreparedStructuredAgentSessionCreate activate: boolean + /** This machine's window made the create and opens the chat's tab where it reserved it. */ + placedByWindow?: boolean }): Promise> { const { prepared } = args const tab = prepared.tab + const placedByWindow = args.placedByWindow ? { placedByWindow: true } : {} if (prepared.atRest) { return prepared.host.create(args.caller, prepared.attachParams, { ...prepared.atRest, @@ -217,6 +220,7 @@ export async function commitStructuredAgentSessionCreate(args: { sessionId: prepared.attachParams.envelope.sessionId, agent: tab.agent, activate: args.activate, + ...placedByWindow, ...(prepared.attachParams.surfaceTabId ? { tabId: prepared.attachParams.surfaceTabId } : {}) @@ -246,6 +250,7 @@ export async function commitStructuredAgentSessionCreate(args: { sessionId: result.value.sessionId, agent: prepared.tab.agent, activate: args.activate, + ...placedByWindow, ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index f1982ab6dd1..3e6c736f976 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -153,13 +153,15 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) + requireStructuredCapability(ctx) + // Noted before the host is required: a close that fails still refuses the create it raced. if (ORCA_LAYOUT_OWNER_IS_MAIN) { noteStructuredAgentSessionClose( params.sessionId, - host.deps.store.getRecord(params.sessionId) !== null + (getStructuredAgentSessionHost()?.deps.store.getRecord(params.sessionId) ?? null) !== null ) } + const host = requireStructuredCleanupHost(ctx) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 6894a8f5513..c9bba8572f8 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -101,7 +101,7 @@ export async function applyWindowLayoutCommand( ) { markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId: terminalTabId }) } - // Why: a workspace main lacks has no partition record to write; the window already removed the tab. + // Why: a workspace main lacks has no tab to close; the window drops the tab as for tab_not_found. return reply.ok && reply.result.alreadyClosed ? { ok: false, code: 'workspace_not_found' } : reply diff --git a/src/main/runtime/session-layout-apply.test-fixture.ts b/src/main/runtime/session-layout-apply.test-fixture.ts new file mode 100644 index 00000000000..145a1775e57 --- /dev/null +++ b/src/main/runtime/session-layout-apply.test-fixture.ts @@ -0,0 +1,28 @@ +// Main's `layout.apply` on a stored partition, as the Store applies it, for runtime tests whose +// store is a plain session map (a layout-owner build saves a chat's tab through it). +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission' +import { resolveHostId } from '../persistence/loading-store/session-host-partitions' + +export function layoutApplyOn( + get: (hostId: string) => WorkspaceSessionState | null, + set: (next: WorkspaceSessionState, hostId: string) => void +) { + return async (command: LayoutCommand, hostIds?: readonly string[]) => { + const hostId = resolveHostId(hostIds?.[0]) + const session = get(hostId) + if (!session) { + return { ok: false as const, code: 'workspace_not_found' as const } + } + const change = applyCommandToSession(hostId, session, command) + if (change.session) { + set(change.session, hostId) + } + return change.value + } +} + +/** Main queues a host's chat tab commands; this lets them land. */ +export const chatTabCommandsSettled = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts index cc4ed43bb0a..2625f7d9899 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts @@ -84,7 +84,7 @@ function mainProjection( return rest } -describe('session.tabs rows from main (layout-owner build, before D3b)', () => { +describe('session.tabs rows from main (layout-owner build)', () => { it("takes main's terminal rows and name, with the window's theme, status and draft", () => { const theme = { mode: 'dark' as const, theme: {} } const agentStatus = null @@ -125,6 +125,18 @@ describe('session.tabs rows from main (layout-owner build, before D3b)', () => { ]) }) + // Main's diff row is never dirty (its drafts are keyed by path); the window's edit is. + it("shows a diff the window lists with the window's dirty state, over main's row", () => { + const edited = { ...file('d1', 'diff'), isDirty: true } + const merged = withMainSessionTabRows( + windowSnapshot([edited], ['d1'], 'd1'), + mainProjection([file('d1', 'diff')], ['d1'], 'd1'), + NONE_EXITED + ) + + expect(merged.tabs).toEqual([{ ...edited, isActive: true }]) + }) + it('keeps the rows main does not hold (browser, diff) in the window’s place', () => { const merged = withMainSessionTabRows( windowSnapshot( diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index a90aa8d7060..6957346686e 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -1,10 +1,8 @@ -// Layout-owner builds, before main holds browser and chat records (D3b): a workspace main owns -// publishes its terminal and editor rows, tab order and groups from main's layout -// (projectSessionTabs). The window's live publication supplies what the user sees: selection, the -// focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced -// save), the rows main's model does not hold (browser, chat, diff and other view-only editor rows) -// and the facts only the window has (terminal theme, agent status, launch drafts, a markdown -// view's mode, file id and draft version). +// Layout-owner builds: a workspace main owns publishes its rows, tab order and groups from main's +// layout (projectSessionTabs). The window's live publication supplies what the user sees: selection, +// the focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced +// save), the rows main's layout does not list yet, and the facts only the window has (terminal +// theme, agent status, launch drafts, a markdown view's mode, file id and draft version). import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement' @@ -22,8 +20,9 @@ export type MainSessionTabRows = Omit< > /** - * Rows main's layout answers for: terminals and edit-mode files (the model holds no diff or - * preview-only tab). Any other row is the window's until D3b. + * Rows main's layout answers for, with the window's facts: terminals and edit-mode files. Main also + * lists browser, chat, diff and markdown-preview rows, but the window's row of the same id stands + * whole while the window lists it (its draft is what makes a diff dirty). */ function isMainRow(row: Row): boolean { return ( diff --git a/src/main/runtime/session-tabs/structured-session-tab-close.ts b/src/main/runtime/session-tabs/structured-session-tab-close.ts index 445eba16238..03de169f2a5 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-close.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-close.ts @@ -18,6 +18,10 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from '../structured-agent-session-tab-retirement' import { chatTabId, withChatTabChanges, type ChatTabChange } from '../agent-session-tab-layout' +import { createChatTabLayoutQueue } from '../agent-session-tab-layout-queue' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' import { savedSessionId } from '../saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { structuredSessionTabRetired } from '../structured-worker-custody' @@ -33,6 +37,8 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca ...args: Parameters ): ReturnType + abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + protected abstract persistHeadlessTabGroups( ...args: Parameters ): ReturnType @@ -286,16 +292,36 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca if (!host || !structuredSessionTabRetired(host, sessionId)) { return } - const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {} - for (const [worktreeId, tabs] of Object.entries(saved)) { - if (tabs.some((tab) => savedSessionId(tab) === sessionId)) { - this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]) - } + // Switch point: main reads which worktrees show the chat when its queue reaches the close, so + // an open still queued ahead of it is closed too. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + this.queueChatTabChanges(() => + this.worktreesShowingSavedChat(sessionId).map((worktreeId) => ({ + worktreeId, + change: { close: sessionId } + })) + ) + return } + for (const worktreeId of this.worktreesShowingSavedChat(sessionId)) { + this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]) + } + } + + private worktreesShowingSavedChat(sessionId: string): string[] { + const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {} + return Object.entries(saved).flatMap(([worktreeId, tabs]) => + tabs.some((tab) => savedSessionId(tab) === sessionId) ? [worktreeId] : [] + ) } // A window saves its own layout whole, chat tabs included; only a host with none saves them here. protected changeWindowlessChatTabs(worktreeId: string, changes: readonly ChatTabChange[]): void { + // Switch point: main places a chat's tab in its own layout, with or without a window. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + this.queueChatTabChanges(() => changes.map((change) => ({ worktreeId, change }))) + return + } const session = this.getAvailableAuthoritativeWindow() ? null : this.getWorkspaceSessionForWorktree(worktreeId) @@ -311,4 +337,10 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca this.setWorkspaceSessionForWorktree(worktreeId, next) } } + + private readonly queueChatTabChanges = createChatTabLayoutQueue({ + sessionOf: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), + hostIdOf: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId), + apply: (command) => this.applyLayoutCommand(command) + }) } diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index 6cfc57fb71e..4829bfceff0 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -6,6 +6,7 @@ import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { seedStructuredAgentSessionTabIndex } from '../structured-agent-session-tab-index-seed' import { restoredChatTabChanges } from './restored-chat-tab-changes' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsSnapshot, @@ -102,6 +103,8 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten notify?: boolean /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string + /** This machine's window made the create; its own open places the tab. */ + placedByWindow?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { @@ -117,7 +120,10 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten }) }) } - this.saveWindowlessAgentSessionTab(input.workspaceId, input) + // Switch point: main leaves a window's chat to the window's open, the one placement decision. + if (!(ORCA_LAYOUT_OWNER_IS_MAIN && input.placedByWindow)) { + this.saveWindowlessAgentSessionTab(input.workspaceId, input) + } this.projectStructuredAgentSessionTab(input) } diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index 6780344c216..a6c94c61249 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -15,6 +15,10 @@ import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-sess import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session-tab-layout' +import { + chatTabCommandsSettled as settled, + layoutApplyOn +} from './session-layout-apply.test-fixture' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -85,7 +89,11 @@ async function runtimeWithSavedChats( } else { partitions.set(hostId, next) } - } + }, + applyLayoutCommand: layoutApplyOn( + (hostId) => (hostId === 'local' ? state.session : (partitions.get(hostId) ?? null)), + (next, hostId) => store.setWorkspaceSession(next, hostId) + ) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. const runtime = new OrcaRuntimeService(store as never) @@ -97,6 +105,7 @@ async function runtimeWithSavedChats( activate: false }) } + await settled() return { runtime, state } } @@ -152,6 +161,7 @@ describe('a windowless chat tab against the chat host', () => { expect(host.deps.store.getRecord(CHAT_A)).not.toBeNull() await host.setSessionTabVisibility(CHAT_A, false) + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -166,6 +176,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -189,6 +200,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -203,6 +215,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual(['short', CHAT_B]) }) @@ -225,6 +238,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B, CHAT_SSH]) }) @@ -252,6 +266,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(partitions.get('runtime:env-y')?.unifiedTabs?.[WT]?.map((tab) => tab.entityId)).toEqual([ CHAT_REMOTE @@ -264,6 +279,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_GONE, CHAT_B]) }) diff --git a/src/main/runtime/structured-agent-session-windowless-layout.test.ts b/src/main/runtime/structured-agent-session-windowless-layout.test.ts index c9837363ad5..0faf0d3ca8c 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout.test.ts @@ -6,6 +6,8 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session-tab-layout' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { chatTabCommandsSettled, layoutApplyOn } from './session-layout-apply.test-fixture' const WT = 'repo-1::/tmp/worktree-a' @@ -34,10 +36,18 @@ function runtimeStore(state: { session: WorkspaceSessionState }) { getWorkspaceSessionHostIds: () => ['local' as const], setWorkspaceSession: (next: WorkspaceSessionState) => { state.session = next - } + }, + applyLayoutCommand: layoutApplyOn( + () => state.session, + (next) => { + state.session = next + } + ) } } +const settled = chatTabCommandsSettled + /** A chat host with only the members a test's path reads. */ function useChatHost(fake: Record): void { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each test supplies every host member its path reads. @@ -72,6 +82,7 @@ describe('a chat tab on a host with no window', () => { await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true }) await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true }) + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) @@ -84,6 +95,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() useChatHost({ getSessionTabId: (sessionId: string) => `reserved-${sessionId}` }) await runtime.publishStructuredAgentSessionTab({ workspaceId: WT, @@ -91,6 +103,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() expect(state.session.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual([ 'structured-agent-session-chat-1', @@ -106,6 +119,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() // A window opening the same chat reuses the tab with this id, content type and entity. expect(state.session.unifiedTabs?.[WT]).toEqual([ @@ -125,6 +139,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) // A fresh runtime lists no chats, as when the close RPC or a worker stop runs before a restore. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. @@ -132,11 +147,12 @@ describe('a chat tab on a host with no window', () => { useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) }) fresh.onStructuredSessionTabHidden('chat-1') + await settled() expect(savedChatIds(state)).toEqual([]) }) - it("closes this host's chat tab in the local partition and leaves every other partition alone", () => { + it("closes this host's chat tab in the local partition and leaves every other partition alone", async () => { const empty: WorkspaceSessionState = { activeRepoId: 'repo-1', activeWorktreeId: WT, @@ -169,7 +185,11 @@ describe('a chat tab on a host with no window', () => { getWorkspaceSession: (hostId = 'local') => sessions.get(hostId) ?? null, setWorkspaceSession: (next: WorkspaceSessionState, hostId = 'local') => { sessions.set(hostId, next) - } + }, + applyLayoutCommand: layoutApplyOn( + (hostId) => sessions.get(hostId) ?? null, + (next, hostId) => sessions.set(hostId, next) + ) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. const runtime = new OrcaRuntimeService(store as never) @@ -179,6 +199,7 @@ describe('a chat tab on a host with no window', () => { runtime.onStructuredSessionTabHidden('chat-x') runtime.onStructuredSessionTabHidden('chat-ssh') runtime.onStructuredSessionTabHidden('chat-y') + await settled() expect(sessions.get('local')?.unifiedTabs?.[WT] ?? []).toEqual([]) expect(sessions.get('runtime:env-x')).toBe(remote) @@ -198,7 +219,9 @@ describe('a chat tab on a host with no window', () => { getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['chat-1'] }) }) + await settled() runtime.onStructuredSessionTabHidden('chat-1') + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) @@ -222,11 +245,13 @@ describe('a chat tab on a host with no window', () => { }) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) - it('is left to the window when one saves the layout', async () => { + // A layout-owner build's main places the chat's tab itself, window or not. + it('is left to the window when one saves the layout, unless main owns the layout', async () => { const { runtime, state } = createRuntime() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected member; any non-null value is an attached window. const withWindow = runtime as unknown as { getAvailableAuthoritativeWindow(): unknown } @@ -238,6 +263,77 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() + + expect(savedChatIds(state)).toEqual(ORCA_LAYOUT_OWNER_IS_MAIN ? ['chat-1'] : []) + }) + + // Switch-on, a window's own launch reserves the chat's place and opens it there once the create + // answers; main's publish leaves the placing to that open, in either order. + it.each(['publish first', 'window open first'])( + "puts a window's chat where the window opens it: %s", + async (order) => { + const { runtime, state } = createRuntime() + for (const sessionId of ['chat-a', 'chat-b']) { + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId, + agent: 'codex', + activate: false + }) + } + await settled() + const publish = () => + runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'chat-w', + agent: 'codex', + activate: true, + placedByWindow: true + }) + const windowOpen = () => + runtime.applyLayoutCommand({ + type: 'openAgentSessionTab', + workspace: WT, + tabId: structuredAgentSessionTabId('chat-w'), + sessionId: 'chat-w', + agent: 'codex', + afterTabId: structuredAgentSessionTabId('chat-a') + }) + + if (order === 'publish first') { + await publish() + await settled() + await windowOpen() + } else { + await windowOpen() + await publish() + } + await settled() + + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } + expect(state.session.tabGroups?.[WT]?.map((group) => group.tabOrder)).toEqual([ + ['chat-a', 'chat-w', 'chat-b'].map(structuredAgentSessionTabId) + ]) + } + ) + + it('closes a chat whose open is still queued when its close arrives', async () => { + const { runtime, state } = createRuntime() + useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) }) + + // Not awaited between: the close reaches main while the open is still in its queue. + void runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'chat-1', + agent: 'codex', + activate: true + }) + runtime.onStructuredSessionTabHidden('chat-1') + await settled() + await settled() expect(savedChatIds(state)).toEqual([]) }) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts index a409ff071b6..7fdede736fe 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts @@ -5,6 +5,8 @@ import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat- import { OrchestrationError } from '../../orchestration/orchestration-error' import { PROJECT_RUNTIME_REPAIR_REQUIRED_CODE } from '../../../../shared/project-runtime-repair-message' import '../../orca-runtime-test-lifecycle.spec' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' vi.mock('../../../ai-vault/structured-chat-cli-fork', () => ({ prepareStructuredChatCliFork: vi.fn(), isStructuredChatCliForkSourceCurrent: vi.fn(() => true) @@ -200,6 +202,11 @@ it.each([true, false])( expect(fixture.getSession().unifiedTabs?.[wt]).toEqual( expect.arrayContaining([expect.objectContaining({ id: canonical, entityId: source })]) ) + } else if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main placed the published chat's tab itself, so the fork opens beside it. + expect(admit.mock.calls[0]?.[0]).toMatchObject({ + afterTabId: structuredAgentSessionTabId(source) + }) } else { expect(admit.mock.calls[0]?.[0]).not.toHaveProperty('afterTabId') expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('afterTabId') diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts index db1d09f809a..7788b422300 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts @@ -1,15 +1,29 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' import { BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX } from '../../../../../shared/browser-annotation-viewport-bridge' -import { createBrowserMockApi, createTestStore } from '@/store/slices/browser-slice-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createBrowserMockApi } from '@/store/slices/browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from '@/store/slices/browser-slice-drawn-from-main.test-fixture' import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge' import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers' import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers' +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + afterEach(() => vi.unstubAllGlobals()) -function createHarness() { +async function createHarness() { const setAnnotationViewportBridge = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { @@ -17,11 +31,19 @@ function createHarness() { browser: { ...createBrowserMockApi(vi.fn()).browser, setAnnotationViewportBridge } } }) - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() const pageId = store.getState().createBrowserTab('wt-1', 'https://example.com').activePageId + await main.settle() if (!pageId) { throw new Error('Expected browser page') } + // A layout-owner build's main opened the page's tab, and its frame drew the page. + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId })] : [] + ) + expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toEqual([ + expect.objectContaining({ id: pageId }) + ]) store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) const ref = (current: T) => ({ current }) const cancelCapture = vi.fn() @@ -106,8 +128,8 @@ function navigationEvent( } describe('browser annotation document boundaries', () => { - it('still accepts token-authenticated live target reports', () => { - const h = createHarness() + it('still accepts token-authenticated live target reports', async () => { + const h = await createHarness() const report = JSON.stringify({ scrollX: 0, scrollY: 0, @@ -134,8 +156,8 @@ describe('browser annotation document boundaries', () => { }) }) - it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', () => { - const h = createHarness() + it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', async () => { + const h = await createHarness() const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] h.loading.handleDidStartLoading() expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) @@ -147,8 +169,8 @@ describe('browser annotation document boundaries', () => { ) }) - it('retires geometry for full and same-document main-frame navigation but not subframes', () => { - const h = createHarness() + it('retires geometry for full and same-document main-frame navigation but not subframes', async () => { + const h = await createHarness() h.navigation.handleDidStartNavigation( navigationEvent('https://example.com/frame', false, false) ) @@ -162,8 +184,8 @@ describe('browser annotation document boundaries', () => { expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() }) - it('keeps geometry retired after redirects and away/back commits, including missed start events', () => { - const h = createHarness() + it('keeps geometry retired after redirects and away/back commits, including missed start events', async () => { + const h = await createHarness() const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] h.loading.handleDidStartLoading() h.navigation.handleDidRedirectNavigation(navigationEvent('https://example.org/redirect')) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index ad011173bb2..0b7d0745882 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -79,6 +79,7 @@ vi.mock('@/lib/launch-structured-agent-session', () => { }) import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' import { @@ -361,7 +362,15 @@ describe('a chat whose start was never confirmed', () => { hostIsReachableAgain() await flush() expect(recreated(CLAUDE_SESSION)).toBe(0) - expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Switch-on, main's host is told to close the chat instead of the window keeping a tombstone. + expect(mocks.history).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.close', { + sessionId: CLAUDE_SESSION + }) + expect(lifecycle(CLAUDE_SESSION)).toBeNull() + } else { + expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled') + } }) it('re-checks once per return of its own host: one still unknown waits for the next', async () => { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx index 91fe76c5fbd..3f56d21fe71 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx @@ -8,6 +8,8 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau import type { AgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { structuredAgentSessionPaneKey, structuredAgentSessionTabId @@ -78,7 +80,22 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) => structuredSession: { sessionId: string; workspaceId: string } }) => { const tabs = useAppStore.getState().unifiedTabsByWorktree[s.workspaceId] ?? [] - if (!tabs.some((tab) => tab.entityId === s.sessionId)) { + if (tabs.some((tab) => tab.entityId === s.sessionId)) { + return true + } + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Switch-on, the host's republished chat reaches the window as main's frame. + await layoutApplyTestTransport.call({ + command: { + type: 'openAgentSessionTab', + workspace: s.workspaceId, + tabId: s.sessionId, + sessionId: s.sessionId, + agent: 'claude' + } + }) + await layoutApplyTestTransport.settle() + } else { useAppStore.getState().createUnifiedTab(s.workspaceId, 'agent-session', { id: s.sessionId, entityId: s.sessionId, @@ -91,7 +108,23 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) => ) })) +// Switch-on, main opens and closes the chat tabs and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' class NoopResizeObserver { observe(): void {} @@ -222,7 +255,11 @@ beforeEach(() => { }) }) +let main: TabsDrawnFromMain | undefined + afterEach(() => { + main?.close() + main = undefined cleanup() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -270,6 +307,7 @@ describe('Session History keyboard selection', () => { agentSessionAgent: 'claude' }) } + main = await drawTabsFromMain(useAppStore) const order = () => store().groupsByWorktree[workspaceId]?.[0]?.tabOrder const resumed = structuredAgentSessionTabId('new') const confirm = vi.fn<(options: unknown) => Promise>() @@ -278,6 +316,7 @@ describe('Session History keyboard selection', () => { const settle = async () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) + await main?.settle() }) const pick = async (settlement: StructuredAgentLaunchSettlement, enter = press) => { launch.settlement = settlement @@ -339,6 +378,7 @@ describe('Session History keyboard selection', () => { agentSessionAgent: 'claude' }) } + main = await drawTabsFromMain(useAppStore) const order = () => useAppStore.getState().groupsByWorktree[workspaceId]?.[0]?.tabOrder const dismiss = vi.fn() const { press } = renderList({ @@ -357,6 +397,7 @@ describe('Session History keyboard selection', () => { press('Enter') await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) + await main?.settle() }) } await refusedBy('old') diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 4b6e04dd2d8..1a59f359ccc 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -36,6 +36,17 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: mocks.activate })) +// Switch-on, main opens the chat's tab and its frame draws it. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' @@ -62,6 +73,8 @@ import { buildHydratedTabState } from '@/store/slices/tabs-hydration' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { useAppStore } from '@/store' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { runCliChatResume } from './cli-chat-resume-run' import { resetStructuredAgentLaunchRegistryForTests, @@ -75,6 +88,10 @@ import { } from '@/runtime/web-session-focus-intent' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' import { registerSessionTabIpcBridge } from '@/hooks/ipc-events/session-tab-ipc-bridge' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const SOURCE: CliChatResumeSource = { tabId: 'tab-1', @@ -85,7 +102,12 @@ const SOURCE: CliChatResumeSource = { executionHostId: 'local' } const tabs = () => useAppStore.getState().unifiedTabsByWorktree['wt-1'] ?? [] -afterEach(() => vi.unstubAllGlobals()) +let main: TabsDrawnFromMain | undefined +afterEach(() => { + vi.unstubAllGlobals() + main?.close() + main = undefined +}) function deferred() { let resolve: (value: T) => void = () => {} const promise = new Promise((done) => { @@ -102,7 +124,7 @@ function refuse(reason: 'transcriptNotFound' | 'transcriptUnreadable'): AgentSes } } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks() resetLocalStructuredSessionVersionForTests() resetWebSessionTabsSnapshotFreshnessForTests() @@ -122,6 +144,7 @@ beforeEach(() => { return { ok: true, value: { sessionId: params.envelope.sessionId, fence: 1 } } } ) + main = await drawTabsFromMain(useAppStore) }) function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { @@ -147,10 +170,18 @@ function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { } } -function seedTabs() { - return ['E', 'S', 'B'].map((id) => +/** Main takes the store's current layout, as after a restart; seeding after it is invisible to main. */ +async function redrawFromMain(): Promise { + main?.close() + main = await drawTabsFromMain(useAppStore) +} + +async function seedTabs() { + const seeded = ['E', 'S', 'B'].map((id) => useAppStore.getState().createUnifiedTab('wt-1', 'editor', { id, entityId: id }) ) + await redrawFromMain() + return seeded } describe('CLI identity resume through the existing launch owner', () => { @@ -325,7 +356,7 @@ describe('CLI identity resume through the existing launch owner', () => { ])( 'keeps canonical membership through registered IPC (%s first, move %s to %s)', async (first, mover, requested, expected) => { - const [e, source, browser] = seedTabs() + const [e, source, browser] = await seedTabs() let move: Parameters[0] = () => {} vi.stubGlobal('api', { ...window.api, @@ -352,6 +383,7 @@ describe('CLI identity resume through the existing launch owner', () => { const later = useAppStore .getState() .createUnifiedTab('wt-1', 'editor', { id: 'R', entityId: 'R' }) + await redrawFromMain() const beforeNoop = useAppStore.getState() move({ kind: 'reorder', @@ -379,6 +411,7 @@ describe('CLI identity resume through the existing launch owner', () => { entityId: 'other', split: { splitDirection: 'right', sourceGroupId: e.groupId } }) + await redrawFromMain() clearWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1') if (first === 'inventory') { applyStructuredSessionTabSnapshots([snapshot(sessionId)]) @@ -398,10 +431,26 @@ describe('CLI identity resume through the existing launch owner', () => { } ) + // Main's publish of a window's create places nothing switch-on, so the resume's own open must. + it('opens a resumed chat once, through main, after the tab before its hidden slot', async () => { + const [, , browser] = await seedTabs() + await runCliChatResume(SOURCE) + await main?.settle() + const sessionId: string = mocks.call.mock.calls[0][2].envelope.sessionId + const slotId = `structured-agent-session-${sessionId}` + expect(tabs().find((tab) => tab.entityId === sessionId)?.id).toBe(slotId) + expect(useAppStore.getState().groupsByWorktree['wt-1'][0].tabOrder.at(-1)).toBe(slotId) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([ + expect.objectContaining({ tabId: slotId, sessionId, afterTabId: browser.id }) + ]) + } + }) + it.each([false, true])( 'keeps overlapping requests in their canonical order (second finishes first: %s)', async (reverse) => { - const initial = seedTabs() + const initial = await seedTabs() const replies = [deferred(), deferred()] mocks.call .mockImplementationOnce(() => replies[0].promise) @@ -424,7 +473,7 @@ describe('CLI identity resume through the existing launch owner', () => { } ) - it('restores a pending-only group as unknown without sending create or restoring focus', () => { + it('restores a pending-only group as unknown without sending create or restoring focus', async () => { const record = { sessionId: 'reload-pending', executionHostId: 'local' as const, @@ -461,9 +510,11 @@ describe('CLI identity resume through the existing launch owner', () => { ...buildHydratedTabState(session, new Set(['wt-1'])), worktreesByRepo: { repo: [makeWorktree({ id: 'wt-1', repoId: 'repo', hostId: 'local' })] } }) + await redrawFromMain() restoreStructuredLaunchSurfaces() restoreStructuredLaunchSurfaces() applyStructuredSessionTabSnapshots([snapshot()]) + await main?.settle() expect(mocks.call).not.toHaveBeenCalled() expect( peekWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1') diff --git a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx index 7486a21de42..a6d66b27756 100644 --- a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -21,7 +21,11 @@ vi.mock('@/store', () => ({ tabsByWorktree: {}, closedTerminalTabTombstonesByTabId: {}, unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, activeGroupIdByWorktree: {}, + // A switch-on chat open places the tab in the workspace's root group. + ensureWorktreeRootGroup: () => 'group-1', createUnifiedTab: mocks.createUnifiedTab, setActiveTabType: vi.fn() }) @@ -47,11 +51,20 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ : new Promise(() => undefined) })) +// Main answers the window's chat-tab open (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import type { Tab } from '../../../../shared/tab-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) let root: Root | undefined @@ -61,8 +74,40 @@ afterEach(async () => { root = undefined mocks.answers.length = 0 vi.clearAllMocks() + layoutApplyTestTransport.reset() }) +/** No shell created here or (switch-on) asked of main. */ +function expectNoShell(): void { + expect(mocks.createTab).not.toHaveBeenCalled() + expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([]) +} + +/** The watcher's shell create: here, or (switch-on) sent to main, whose frame draws it. */ +async function expectShellCreatedOnce(): Promise { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await layoutApplyTestTransport.settle() + expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([ + expect.objectContaining({ workspace: 'wt-1' }) + ]) + return + } + expect(mocks.createTab).toHaveBeenCalledOnce() +} + +/** The chat opened: drawn here, or (switch-on) its open sent to main, whose frame draws it. */ +async function expectChatOpenedOnce(): Promise { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await layoutApplyTestTransport.settle() + expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([ + expect.objectContaining({ workspace: 'wt-1', sessionId: 'claude_1' }) + ]) + expect(mocks.createUnifiedTab).not.toHaveBeenCalled() + return + } + expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() +} + function Watcher(): null { useTerminalWatcherEffects({ activeWorktreeId: 'wt-1', @@ -142,12 +187,12 @@ describe('a local chat launch waiting on its host', () => { mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty') root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => root?.unmount()) root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) - expect(mocks.createTab).toHaveBeenCalledOnce() + await expectShellCreatedOnce() }) it('keeps a watched create free of a stray shell until its chat opens', async () => { @@ -163,11 +208,11 @@ describe('a local chat launch waiting on its host', () => { root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) await act(async () => finish()) - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => mocks.answers[0]?.({ supported: true })) - expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() - expect(mocks.createTab).not.toHaveBeenCalled() + await expectChatOpenedOnce() + expectNoShell() }) it("keeps an empty workspace's default chat free of a shell seeded by the shared gate", async () => { @@ -181,11 +226,11 @@ describe('a local chat launch waiting on its host', () => { await act(async () => root?.render(createElement(Watcher))) await act(async () => finish()) expect(mocks.begin).not.toHaveBeenCalled() - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => mocks.answers[0]?.({ supported: true })) - expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() - expect(mocks.createTab).not.toHaveBeenCalled() + await expectChatOpenedOnce() + expectNoShell() }) it('stops owning the surface once the host has answered and its surface opened', async () => { diff --git a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx index 24b267bf8a8..c3a94e8d442 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx @@ -21,11 +21,27 @@ import { makeWorktree } from './worktree-jump-palette-test-fixtures' import type { QuickActionPaletteItem } from './worktree-jump-palette-model' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ useSettingsNavigationMetadata: () => [] })) +// Main answers the window's layout commands; its frames draw the browser tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const { openWorkspaceBrowserTab } = vi.hoisted(() => ({ openWorkspaceBrowserTab: vi.fn() })) vi.mock(import('@/lib/workspace-browser-tab-open'), async (importOriginal) => ({ ...(await importOriginal()), @@ -111,6 +127,8 @@ function usePalette() { } describe('Cmd+J website selection', () => { + let main: TabsDrawnFromMain | undefined + beforeEach(() => { openWorkspaceBrowserTab.mockReset().mockResolvedValue(undefined) useAppStore.setState( @@ -130,6 +148,8 @@ describe('Cmd+J website selection', () => { afterEach(() => { cleanup() + main?.close() + main = undefined vi.restoreAllMocks() useAppStore.setState(initialAppState, true) }) @@ -220,9 +240,11 @@ describe('Cmd+J website selection', () => { } }) } else { + main = await drawTabsFromMain(useAppStore) useAppStore.getState().createBrowserTab('wt-beta', 'https://example.com/', { title: name }) + await main.settle() } const { result } = renderHook(usePalette) diff --git a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts index 5ca8c137b1b..821bbe3ef21 100644 --- a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts +++ b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts @@ -1,12 +1,177 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AppState } from '@/store/types' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers' +import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' + +// Switch on, the open goes to main as `layout.apply` and main's frame draws the tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +type DrawnStore = { getState: () => AppState } +const drawn = vi.hoisted((): { store: DrawnStore | null; main: TabsDrawnFromMain | null } => ({ + store: null, + main: null +})) + +/** Switch on: a real store seeded like the mock (whose tabs it keeps), drawn from main's frames. */ +async function drawFromMain(): Promise<{ + store: DrawnStore + sent: () => readonly { type: string }[] +}> { + createStoreCascadesMockApi() + const store = createTestStore() + const unifiedTabsByWorktree = Object.fromEntries( + Object.entries(mockStoreState.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab): Tab => ({ + entityId: tab.id, + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + worktreeId, + ...tab, + contentType: 'simulator' + })) + ]) + ) + const groupsByWorktree = Object.fromEntries( + Object.entries(mockStoreState.groupsByWorktree).map(([worktreeId, groups]) => [ + worktreeId, + groups.map(({ id }): TabGroup => ({ + id, + worktreeId, + activeTabId: null, + tabOrder: (unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.groupId === id) + .map((tab) => tab.id) + })) + ]) + ) + store.setState({ + activeWorktreeId: mockStoreState.activeWorktreeId, + activeWorkspaceExecutionHostId: mockStoreState.activeWorkspaceExecutionHostId, + // One repo per row, so same-id rows on two hosts stay apart. + worktreesByRepo: Object.fromEntries( + mockStoreState + .allWorktrees() + .map((row, index) => [ + `repo-${index}`, + [makeWorktree({ ...row, repoId: `repo-${index}`, path: `/path/${index}` })] + ]) + ), + folderWorkspaces: [], + groupsByWorktree, + layoutByWorktree: Object.fromEntries( + Object.entries(groupsByWorktree).map(([worktreeId, [first, second]]) => [ + worktreeId, + second + ? { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: first!.id }, + second: { type: 'leaf', groupId: second.id } + } + : { type: 'leaf', groupId: first!.id } + ]) + ), + activeGroupIdByWorktree: { ...mockStoreState.activeGroupIdByWorktree }, + unifiedTabsByWorktree + }) + drawn.main = await drawTabsFromMain(store) + drawn.store = store + return { store, sent: () => layoutApplyTestTransport.sent } +} + +/** Switch on: opens through main, draws main's answer, and returns the drawn tab and the open + * main got (over this machine's `layout.apply`, the only target the transport answers). */ +async function openThroughMain(open: () => string | null): Promise<{ + tabId: string | null + tab: Tab | undefined + sent: { type: string; groupId?: string; split?: unknown }[] + store: DrawnStore +}> { + const { store, sent } = await drawFromMain() + const tabId = open() + await drawn.main!.settle() + return { + tabId, + tab: store.getState().unifiedTabsByWorktree['wt-1']?.find((entry) => entry.id === tabId), + sent: sent().filter((command) => command.type === 'openSimulatorTab'), + store + } +} + +/** + * A background open for this machine's host, in group-1, drawn local and never selected. + * `viaMain` false: a paired server owns the active twin, so the window draws it as today. + */ +async function expectBackgroundLocalOpen(viaMain: boolean): Promise { + const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' }) + ) + expect(sent).toEqual(viaMain ? [expect.objectContaining({ tabId, groupId: 'group-1' })] : []) + expect(tab).toMatchObject({ + contentType: 'simulator', + label: 'Mobile Emulator', + groupId: 'group-1', + executionHostId: 'local' + }) + expectNotSurfaced(store, tab!) +} + +/** The window selected the tab, its group and the simulator view, as a surfaced open does. */ +function expectSurfaced(store: DrawnStore, tab: Tab): void { + const state = store.getState() + const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId) + expect(group?.activeTabId).toBe(tab.id) + expect(group?.recentTabIds).toContain(tab.id) + expect(state.activeGroupIdByWorktree['wt-1']).toBe(tab.groupId) + expect(state.activeTabType).toBe('simulator') +} + +/** No focus: not in the group's recents, no focus time, the view type unchanged. */ +function expectNotSurfaced(store: DrawnStore, tab: Tab): void { + const state = store.getState() + const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId) + expect(group?.recentTabIds ?? []).not.toContain(tab.id) + expect(tab.lastFocusedAt).toBeUndefined() + expect(state.activeTabType).not.toBe('simulator') +} + +type MockWorktreeRow = { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +type MockTabRow = { + id: string + groupId: string + worktreeId?: string + executionHostId?: ExecutionHostId + contentType: string + label?: string +} const mockStoreState = vi.hoisted(() => ({ activeGroupIdByWorktree: {} as Record, - activeWorkspaceExecutionHostId: null as string | null, + activeWorkspaceExecutionHostId: ((): ExecutionHostId | null => null)(), activeWorktreeId: 'wt-1', - allWorktrees: vi.fn( - () => [] as { id: string; hostId?: string; runtimeOwnerEnvironmentId?: string }[] - ), + allWorktrees: vi.fn((): MockWorktreeRow[] => []), activateTab: vi.fn(), createEmptySplitGroup: vi.fn(), createUnifiedTab: vi.fn(), @@ -30,17 +195,7 @@ const mockStoreState = vi.hoisted(() => ({ settings: { mobileEmulatorEnabled: true }, setActiveTab: vi.fn(), setActiveTabType: vi.fn(), - unifiedTabsByWorktree: {} as Record< - string, - { - id: string - groupId: string - worktreeId?: string - executionHostId?: string - contentType: string - label?: string - }[] - > + unifiedTabsByWorktree: ((): Record => ({}))() })) function stubCreatedSimulatorTab(groupId: string): void { @@ -60,7 +215,7 @@ function createdSimulatorTabId(): string { vi.mock('@/store', () => ({ useAppStore: { - getState: () => mockStoreState + getState: () => drawn.store?.getState() ?? mockStoreState } })) @@ -89,7 +244,16 @@ describe('ensureSimulatorTab', () => { mockStoreState.focusGroup.mockReset() mockStoreState.setActiveTab.mockReset() mockStoreState.setActiveTabType.mockReset() - vi.resetModules() + // Switch on, main's transport and frames live in modules a reset would orphan. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + vi.resetModules() + } + }) + + afterEach(() => { + drawn.main?.close() + drawn.main = null + drawn.store = null }) it('activates an existing simulator tab through unified tab state', async () => { @@ -123,6 +287,18 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The active twin is host-b's, a paired server's: the window draws it as today. + const { tabId, tab, sent } = await openThroughMain(() => ensureSimulatorTab('wt-1')) + expect(tabId).not.toBe('sim-local') + expect(sent).toEqual([]) + expect(tab).toMatchObject({ + contentType: 'simulator', + groupId: 'group-1', + executionHostId: 'runtime:host-b' + }) + return + } expect(ensureSimulatorTab('wt-1')).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTab).toHaveBeenCalled() }) @@ -232,6 +408,19 @@ describe('ensureSimulatorTab', () => { }) const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([ + expect.objectContaining({ split: { besideGroupId: 'group-1', direction: 'right' } }) + ]) + expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' }) + expect(tab!.groupId).not.toBe('group-1') + expect(sent[0]).toMatchObject({ groupId: tab!.groupId }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe('sim-2') expect(mockStoreState.createEmptySplitGroup).not.toHaveBeenCalled() @@ -268,6 +457,16 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-2') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([expect.objectContaining({ groupId: 'group-2' })]) + expect(sent[0]).not.toHaveProperty('split') + expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-2' }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTabInSplit).not.toHaveBeenCalled() @@ -288,6 +487,18 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main makes the split with the tab in one apply: no half-made split to fall back from. + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([ + expect.objectContaining({ tabId, split: { besideGroupId: 'group-1', direction: 'right' } }) + ]) + expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTabInSplit).toHaveBeenCalledWith( @@ -317,6 +528,16 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false }) + ) + expect(sent).toEqual([expect.objectContaining({ tabId, groupId: 'group-1' })]) + expect(sent[0]).not.toHaveProperty('split') + expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-1' }) + expectNotSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false })).toBe( createdSimulatorTabId() ) @@ -345,6 +566,11 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // This machine's twin goes to main; its frame names the tab's host. + await expectBackgroundLocalOpen(true) + return + } expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe( createdSimulatorTabId() ) @@ -374,6 +600,10 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectBackgroundLocalOpen(false) + return + } expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe( createdSimulatorTabId() ) diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index b728c8bfcd9..055ba8fc45d 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -13,7 +13,10 @@ import { import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { recordOpenedSimulatorTabInLayoutOwner } from './simulator-tab-layout-owner' +import { + openingSimulatorTabId, + openSimulatorTabThroughLayoutOwner +} from './simulator-tab-layout-owner' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type EnsureSimulatorTabOptions = { @@ -102,7 +105,9 @@ export function ensureSimulatorTab( const sourceGroupId = options?.targetGroupId ?? store.activeGroupIdByWorktree[worktreeId] ?? - store.groupsByWorktree[worktreeId]?.[0]?.id + store.groupsByWorktree[worktreeId]?.[0]?.id ?? + // Main's frames draw no empty group: a layout-owner build opens into the workspace's root one. + (ORCA_LAYOUT_OWNER_IS_MAIN ? store.ensureWorktreeRootGroup(worktreeId) : undefined) if (!sourceGroupId) { return null } @@ -124,74 +129,69 @@ export function ensureSimulatorTab( return existing.id } - if (options?.placement === 'rightSplit' && shouldSurface) { - const reusableRightGroupId = findReusableRightSplitGroupId( - store.layoutByWorktree[worktreeId], - sourceGroupId - ) - if (reusableRightGroupId) { - const tabId = createBrowserUuid() - store.createUnifiedTab(worktreeId, 'simulator', { - id: tabId, - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - targetGroupId: reusableRightGroupId, - activate: true, - ...(executionHostId ? { executionHostId } : {}) - }) - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(tabId) - } - surfaceCreatedSimulatorTab(worktreeId, tabId) - return tabId - } - + const label = translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator') + const reusableRightGroupId = + options?.placement === 'rightSplit' && shouldSurface + ? findReusableRightSplitGroupId(store.layoutByWorktree[worktreeId], sourceGroupId) + : null + const rightSplit = options?.placement === 'rightSplit' && shouldSurface && !reusableRightGroupId + const groupId = reusableRightGroupId ?? sourceGroupId + const drawHere = (tabId?: string): string | null => { // Why: publish the simulator directly in its split group. A two-step // create-then-move can persist the midpoint during dev reload/HMR. - const splitTab = store.createUnifiedTabInSplit( - worktreeId, - 'simulator', - { - sourceGroupId, - splitDirection: 'right' - }, - { - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - activate: true, - ...(executionHostId ? { executionHostId } : {}) - } - ) + const splitTab = rightSplit + ? store.createUnifiedTabInSplit( + worktreeId, + 'simulator', + { sourceGroupId, splitDirection: 'right' }, + { ...(tabId ? { id: tabId } : {}), label, activate: true, ...hostOf(executionHostId) } + ) + : null if (splitTab) { - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(splitTab.id) - } return splitTab.id } + const id = tabId ?? createBrowserUuid() + store.createUnifiedTab(worktreeId, 'simulator', { + id, + label, + targetGroupId: groupId, + activate: reusableRightGroupId ? true : shouldSurface, + ...hostOf(executionHostId) + }) + if (reusableRightGroupId || shouldSurface) { + surfaceCreatedSimulatorTab(worktreeId, id) + } + return id } - - const tabId = createBrowserUuid() - store.createUnifiedTab(worktreeId, 'simulator', { - id: tabId, - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - targetGroupId: sourceGroupId, - activate: shouldSurface, - ...(executionHostId ? { executionHostId } : {}) - }) + // Switch point: main opens the tab and its frame draws it; the window surfaces it after. if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(tabId) + const opening = openingSimulatorTabId(worktreeId) + if (opening) { + return opening + } + const sent = openSimulatorTabThroughLayoutOwner(store, { + worktreeId, + label, + groupId, + rightSplit, + surface: (tabId) => { + if (shouldSurface) { + surfaceCreatedSimulatorTab(worktreeId, tabId) + } + }, + drawHere + }) + if (sent) { + return sent + } } - if (shouldSurface) { - surfaceCreatedSimulatorTab(worktreeId, tabId) - } - return tabId + return drawHere() } -/** Switch point: main records the simulator tab the window just drew. */ -function recordOpenedSimulatorTab(tabId: string): void { - const state = useAppStore.getState() - const tab = state.getTab(tabId) - if (tab) { - recordOpenedSimulatorTabInLayoutOwner(state, tab) - } +function hostOf(executionHostId: ExecutionHostId | undefined): { + executionHostId?: ExecutionHostId +} { + return executionHostId ? { executionHostId } : {} } function surfaceCreatedSimulatorTab(worktreeId: string, tabId: string): void { diff --git a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts index 1020007b212..8ae32342966 100644 --- a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredLaunchSlotOwner } from '@/lib/structured-agent-session-launch-slot' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement' @@ -15,6 +15,28 @@ import { import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation' import { resetStructuredAgentLaunchPersistenceForTests } from '../structured-agent-session-launch-persistence' import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { useAppStore } from '@/store' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { makeWorktree } from '@/store/slices/store-test-helpers' + +// Switch on, main draws the window's tabs: `layout.apply` and its frames are the test's main. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const layoutOwner: StructuredLaunchSlotOwner = { unifiedTabsByWorktree: {}, @@ -24,6 +46,10 @@ const layoutOwner: StructuredLaunchSlotOwner = { const WORKTREE_ID = 'repo-1::worktree-1' const SESSION_ID = 'session-close-race' +// A paired server keeps the window's tombstone in both builds; this machine keeps one only with +// the switch off (with it on, main hears the cancel: structured-launch-cancel-layout-owner.test.ts). +const SERVER_HOST = 'runtime:server-1' +const SERVER = { kind: 'environment', environmentId: 'server-1' } as const function latePublication(): RuntimeMobileSessionTabsResult { return { @@ -46,6 +72,157 @@ function latePublication(): RuntimeMobileSessionTabsResult { } } +/** A launch whose create is on its way to `executionHostId`. */ +function setLaunch( + executionHostId: ExecutionHostId, + target: RuntimeClientTarget, + promise: Promise<{ sessionId: string; fence: number }>, + operation = 'close-race' +): void { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + executionHostId, + target, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: `operation-${operation}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${operation}` + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise, + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) +} + +/** The cancelled launch's late publication stays hidden until the launch settles, on `host`. */ +async function expectLatePublicationSuppressed( + host: ExecutionHostId, + target: RuntimeClientTarget +): Promise { + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + setLaunch( + host, + target, + new Promise((resolve) => { + resolveLaunch = resolve + }) + ) + const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host) + ).toBe(false) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, host, layoutOwner) + const afterCancel = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterCancel, host) + ).toBe(false) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + + resolveLaunch({ sessionId: SESSION_ID, fence: 1 }) + await Promise.resolve() + expect(suppressCancelledStructuredSessionTabs(latePublication(), target).tabs).toEqual([]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host) + ).toBe(false) + + const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterSettlement, host) + ).toBe(true) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(false) +} + +const TERMINAL = 'terminal-1' +let main: TabsDrawnFromMain | null = null + +/** Switch on: the worktree, with one terminal, drawn from main's frames. */ +async function drawWorktreeFromMain(): Promise { + useAppStore.setState({ + worktreesByRepo: { 'repo-1': [makeWorktree({ id: WORKTREE_ID, repoId: 'repo-1' })] }, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TERMINAL, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TERMINAL, + entityId: TERMINAL, + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [ + { id: 'group-1', worktreeId: WORKTREE_ID, activeTabId: TERMINAL, tabOrder: [TERMINAL] } + ] + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + layoutByWorktree: { [WORKTREE_ID]: { type: 'leaf', groupId: 'group-1' } } + }) + main = await drawTabsFromMain(useAppStore) +} + +/** What the window shows of the chat, and what main's layout holds of it. */ +async function shownChat(): Promise<{ drawn: boolean; inMain: boolean; opened: boolean }> { + await main?.settle() + return { + drawn: (useAppStore.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).some( + (tab) => tab.entityId === SESSION_ID + ), + inMain: (layoutApplyTestTransport.mainModel?.workspaces[WORKTREE_ID]?.tabs ?? []).some( + (tab) => tab.entityId === SESSION_ID + ), + opened: layoutApplyTestTransport + .sentOfType('openAgentSessionTab') + .some((command) => command.sessionId === SESSION_ID) + } +} + +const NOT_SHOWN = { drawn: false, inMain: false, opened: false } + +afterEach(() => { + main?.close() + main = null +}) + describe('structured launch cancellation retirement', () => { beforeEach(() => { vi.clearAllMocks() @@ -62,182 +239,238 @@ describe('structured launch cancellation retirement', () => { }) }) - it('keeps a late publication suppressed until the cancelled launch settles', async () => { - let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void - const launchPromise = new Promise<{ sessionId: string; fence: number }>((resolve) => { - resolveLaunch = resolve - }) - setStructuredLaunchState({ - identity: `codex:${WORKTREE_ID}`, - intent: { - worktreeId: WORKTREE_ID, - executionHostId: 'local', - target: { kind: 'local' }, - sessionId: SESSION_ID, - agent: 'codex', - params: { - envelope: { - sessionId: SESSION_ID, - clientOperationId: 'operation-close-race', - expectedRuntimeFence: null, - payloadFingerprint: 'fingerprint-close-race' - }, - worktree: `id:${WORKTREE_ID}`, - agent: 'codex' - } - }, - promptDelivery: 'auto-submit', - callers: { - outcome: 'pending', - attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, - entries: new Set(), - promptDeliveryResults: new Set(), - onSettled: () => undefined - }, - promise: launchPromise, - visibilityUnknown: false, - cancelled: false, - selection: { held: {} } - } satisfies StructuredLaunchState) - - const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - beforeCancel, - 'local' - ) - ).toBe(false) - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - const afterCancel = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - afterCancel, - 'local' - ) - ).toBe(false) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) - - resolveLaunch({ sessionId: SESSION_ID, fence: 1 }) - await Promise.resolve() - const suppressed = suppressCancelledStructuredSessionTabs(latePublication(), { kind: 'local' }) - expect(suppressed.tabs).toEqual([]) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - beforeCancel, - 'local' - ) - ).toBe(false) - - const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - afterSettlement, - 'local' - ) - ).toBe(true) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) + it('keeps a paired server’s late publication suppressed until the cancelled launch settles', async () => { + await expectLatePublicationSuppressed(SERVER_HOST, SERVER) }) + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a late publication suppressed until the cancelled launch settles', + async () => { + await expectLatePublicationSuppressed('local', { kind: 'local' }) + } + ) + // A runtime refused its chats answers with no chat rows; that absence is not the host's answer. - it('keeps a tombstone through an inventory that cannot list chats', async () => { - setStructuredLaunchState({ - identity: `codex:${WORKTREE_ID}`, - intent: { - worktreeId: WORKTREE_ID, - executionHostId: 'local', - target: { kind: 'local' }, - sessionId: SESSION_ID, - agent: 'codex', - params: { - envelope: { - sessionId: SESSION_ID, - clientOperationId: 'operation-unverifiable', - expectedRuntimeFence: null, - payloadFingerprint: 'fingerprint-unverifiable' - }, - worktree: `id:${WORKTREE_ID}`, - agent: 'codex' + // Switch on, this machine keeps no tombstone: main's fence is in its close-before-create test. + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a tombstone through an inventory that cannot list chats', + async () => { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + executionHostId: 'local', + target: { kind: 'local' }, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: 'operation-unverifiable', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-unverifiable' + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ + ...latePublication(), + activeTabId: null, + activeTabType: null, + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + }) + const call = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + call.mockResolvedValue({ + ok: true, + result: { snapshots: [frame(true)], authoritative: true } + }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) + + call.mockResolvedValue({ + ok: true, + result: { snapshots: [frame(false)], authoritative: true } + }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + } + ) + + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'drains a restored cancellation before a newer inventory retires it', + async () => { + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + + const close = Promise.withResolvers() + const call = vi.fn(({ method }: { method: string }) => { + if (method === 'agentSession.close') { + return close.promise.then(() => ({ ok: true, result: { ok: true } })) } - }, - promptDelivery: 'auto-submit', - callers: { - outcome: 'pending', - attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, - entries: new Set(), - promptDeliveryResults: new Set(), - onSettled: () => undefined - }, - promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), - visibilityUnknown: false, - cancelled: false, - selection: { held: {} } - } satisfies StructuredLaunchState) - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - await new Promise((resolve) => setTimeout(resolve, 0)) - const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ - ...latePublication(), - activeTabId: null, - activeTabType: null, - tabs: [], - ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) - }) - const call = vi.fn() - Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { runtime: { call } } + }) - call.mockResolvedValue({ ok: true, result: { snapshots: [frame(true)], authoritative: true } }) - await refreshLocalStructuredSessionTabs() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) - call.mockResolvedValue({ ok: true, result: { snapshots: [frame(false)], authoritative: true } }) - await refreshLocalStructuredSessionTabs() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) - }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.listAll' + ]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) - it('drains a restored cancellation before a newer inventory retires it', async () => { - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() + // The close shares the host's session lane with create, so settlement drains a late attach. + close.resolve() + await close.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + await refreshLocalStructuredSessionTabs() - const close = Promise.withResolvers() - const call = vi.fn(({ method }: { method: string }) => { - if (method === 'agentSession.close') { - return close.promise.then(() => ({ ok: true, result: { ok: true } })) - } - return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) - }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { runtime: { call } } - }) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect( + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + ).toHaveLength(1) + } + ) - await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + // Switch on, main holds this machine's cancel: neither inventory brings the chat back. + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a local cancel through an inventory that cannot list chats', + async () => { + await drawWorktreeFromMain() + setLaunch('local', { kind: 'local' }, Promise.resolve({ sessionId: SESSION_ID, fence: 1 })) + const call = vi.fn((_request: { method: string }): Promise => + Promise.resolve({ ok: true, result: { ok: true } }) + ) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + const closes = () => + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + expect(closes()).toHaveLength(1) - expect(call.mock.calls.map(([request]) => request.method)).toEqual([ - 'agentSession.close', - 'session.tabs.listAll' - ]) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + const listing = (unverifiable: boolean) => ({ + ok: true, + result: { + snapshots: [ + unverifiable + ? { ...latePublication(), tabs: [], agentSessionsUnverifiable: true as const } + : latePublication() + ], + authoritative: true + } + }) + call.mockImplementation(() => Promise.resolve(listing(true))) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect(await shownChat()).toEqual(NOT_SHOWN) - // The close shares the host's session lane with create, so settlement drains a late attach. - close.resolve() - await close.promise - await new Promise((resolve) => setTimeout(resolve, 0)) - await refreshLocalStructuredSessionTabs() + // A later inventory that lists the chat (its create landed late) does not show it either. + call.mockImplementation(() => Promise.resolve(listing(false))) + await refreshLocalStructuredSessionTabs() + expect(await shownChat()).toEqual(NOT_SHOWN) + expect(closes()).toHaveLength(1) + } + ) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) - expect( - call.mock.calls.filter(([request]) => request.method === 'agentSession.close') - ).toHaveLength(1) - }) + // An older switch-off build saved the tombstone; this build drains it on its first inventory. + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'drains a tombstone an older build saved: one close, and main shows no chat', + async () => { + localStorage.setItem( + 'orca:structuredAgentLaunchCancelledSessions:v1', + JSON.stringify([SESSION_ID]) + ) + resetStructuredAgentLaunchPersistenceForTests() + await drawWorktreeFromMain() + + const close = Promise.withResolvers() + const call = vi.fn(({ method }: { method: string }) => { + if (method === 'agentSession.close') { + return close.promise.then(() => ({ ok: true, result: { ok: true } })) + } + return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) + }) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.listAll' + ]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) + + close.resolve() + await close.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect( + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + ).toHaveLength(1) + expect(await shownChat()).toEqual(NOT_SHOWN) + } + ) + + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'tells main of a local cancel at once, so the next inventory has nothing to drain', + async () => { + const call = vi.fn(({ method }: { method: string }) => + Promise.resolve( + method === 'session.tabs.listAll' + ? { ok: true, result: { snapshots: [], authoritative: true } } + : { ok: true, result: { ok: true } } + ) + ) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.close', + 'session.tabs.listAll' + ]) + } + ) }) diff --git a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts index f0fd7ebf76f..7d0e540f578 100644 --- a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts +++ b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts @@ -7,7 +7,7 @@ * driven for real below, and every absence is paired with a presence check through the same * oracle so an empty result can never pass for a proof. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { buildWorkspaceSessionPayload } from '../workspace-session' import { buildWorkspaceSessionPatch } from '../workspace-session-patch' @@ -17,17 +17,37 @@ import { getClientHostedBrowserRows, hydrateClientHostedBrowserRows } from './client-hosted-browser-row-state' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' + +// Main answers the window's layout commands; its frames draw the local browser tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const WT = 'wt-1' const CLIENT_HOSTED_PAGE_ID = 'client-hosted-page-1' let initialState: AppState +let main: TabsDrawnFromMain | undefined -function seedLocalBrowserTab(): string { +/** A layout-owner build draws the tab once main's frame is in. */ +async function seedLocalBrowserTab(): Promise { + main = await drawTabsFromMain(useAppStore) const workspace = useAppStore.getState().createBrowserTab(WT, 'https://local.test/', { title: 'Local page', activate: true }) + await main.settle() return workspace.id } @@ -59,13 +79,15 @@ beforeEach(() => { }) afterEach(() => { + main?.close() + main = undefined useAppStore.setState(initialState, true) hydrateClientHostedBrowserRows([]) }) describe('client-hosted rows and the persisted workspace session', () => { - it('persists a local browser tab but never a client-hosted row', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('persists a local browser tab but never a client-hosted row', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() // Guard the oracle itself: the row has to be live for its absence downstream to mean anything. expect(getClientHostedBrowserRows(WT)).toHaveLength(1) @@ -77,8 +99,8 @@ describe('client-hosted rows and the persisted workspace session', () => { expect(serialize(payload)).not.toContain('https://client-hosted.test/') }) - it('keeps a client-hosted row out of incremental session patches too', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('keeps a client-hosted row out of incremental session patches too', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() const patch = buildWorkspaceSessionPatch(useAppStore.getState(), [ @@ -93,8 +115,8 @@ describe('client-hosted rows and the persisted workspace session', () => { expect(serialize(patch)).not.toContain(CLIENT_HOSTED_PAGE_ID) }) - it('leaves the store tab model untouched when a row arrives', () => { - seedLocalBrowserTab() + it('leaves the store tab model untouched when a row arrives', async () => { + await seedLocalBrowserTab() const before = { browserTabs: useAppStore.getState().browserTabsByWorktree[WT], unifiedTabs: useAppStore.getState().unifiedTabsByWorktree[WT], @@ -110,8 +132,8 @@ describe('client-hosted rows and the persisted workspace session', () => { }) describe('client-hosted rows and the runtime window graph', () => { - it('publishes a local browser tab to the host graph but never a client-hosted row', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('publishes a local browser tab to the host graph but never a client-hosted row', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() expect(getClientHostedBrowserRows(WT)).toHaveLength(1) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.test.ts b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts index f6ff5302f60..c9e965fc975 100644 --- a/src/renderer/src/lib/simulator-tab-layout-owner.test.ts +++ b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts @@ -1,17 +1,15 @@ // @vitest-environment happy-dom -// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands, so the -// window's next save changes nothing in main's model (what the save-divergence detector checks). -import { beforeEach, describe, expect, it, vi } from 'vitest' +// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands first and +// the window draws them from main's frames, so its next save changes nothing in main's model. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' -import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' +import type { WorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-model' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createBrowserUuid } from '@/lib/browser-uuid' import { createTestStore, makeRuntimeOwnedWorktree, @@ -20,22 +18,21 @@ import { seedStore } from '@/store/slices/store-test-helpers' import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' type LayoutLoad = ReturnType type Mocks = { layoutOwnerIsMain: boolean store: ReturnType | null - main: LayoutLoad | null - sent: LayoutCommand[] } -const mocks = vi.hoisted((): Mocks => ({ - layoutOwnerIsMain: true, - store: null, - main: null, - sent: [] -})) +const mocks = vi.hoisted((): Mocks => ({ layoutOwnerIsMain: true, store: null })) +let main: TabsDrawnFromMain vi.mock('../../../shared/layout-owner-switch', () => ({ get ORCA_LAYOUT_OWNER_IS_MAIN() { @@ -50,30 +47,16 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn( - async (_target: unknown, method: string, params?: { command?: LayoutCommand }) => { - if (method !== 'layout.apply' || !params?.command) { - return {} - } - mocks.sent.push(params.command) - const main = mainLoad() - // As runtime-layout-apply.ts: the window already confirmed its close, so main forces it. - const command: LayoutCommand = - params.command.type === 'closeTabs' ? { ...params.command, force: true } : params.command - const applied = applyLayoutCommand(main.layout, command, { - mintId: createBrowserUuid, - mintLeafId: createBrowserUuid, - now: Date.now - }) - if (!applied.ok) { - return { ok: false, code: applied.code } - } - mocks.main = { ...main, layout: applied.model } - return { ok: true, result: applied.result } - } - ) -})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) createStoreCascadesMockApi() @@ -90,11 +73,8 @@ function store(): ReturnType { return mocks.store } -function mainLoad(): LayoutLoad { - if (!mocks.main) { - throw new Error('main not loaded') - } - return mocks.main +function sent(): readonly LayoutCommand[] { + return layoutApplyTestTransport.sent } function loadWindowSave(): LayoutLoad { @@ -107,18 +87,17 @@ function loadWindowSave(): LayoutLoad { /** The workspace as the window's save would give it to main, beside main's stored copy. */ function expectSaveTeachesMainNothing(worktreeId = WORKTREE): void { - const stored = saveWorkspaceLayout(mainLoad()) - const main = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - stored, - nameBasedLoadContext() - ).layout - expect(loadWindowSave().layout.workspaces[worktreeId]).toEqual(main.workspaces[worktreeId]) + // A lone group's tree is implied: the window may store its leaf, main none. + const withoutLoneLeaf = (workspace: WorkspaceLayout | undefined) => + workspace && { ...workspace, groupLayout: split(workspace.groupLayout) } + const split = (tree: TabGroupLayoutNode | undefined) => (tree?.type === 'split' ? tree : null) + expect(withoutLoneLeaf(loadWindowSave().layout.workspaces[worktreeId])).toEqual( + withoutLoneLeaf(layoutApplyTestTransport.mainModel!.workspaces[worktreeId]) + ) } async function settled(): Promise { - await vi.waitFor(() => Promise.resolve()) - await new Promise((resolve) => setTimeout(resolve, 0)) + await main.settle() } function terminalTab(id: string, groupId: string, worktreeId = WORKTREE): Tab { @@ -160,23 +139,31 @@ function seed(groups: TabGroup[], layout: TabGroupLayoutNode): void { activeGroupIdByWorktree: { [WORKTREE]: groups[0]!.id }, layoutByWorktree: { [WORKTREE]: layout } }) - // Main starts from the window's last save, as it does today. - mocks.main = loadWindowSave() } -beforeEach(() => { +/** Main takes the window's layout and draws it from here on. */ +async function seedDrawnFromMain(groups: TabGroup[], layout: TabGroupLayoutNode): Promise { + main?.close() + seed(groups, layout) + main = await drawTabsFromMain(store()) +} + +beforeEach(async () => { mocks.layoutOwnerIsMain = true - mocks.sent = [] vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.store = createTestStore() - seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], { - type: 'leaf', - groupId: GROUP - }) + await seedDrawnFromMain( + [{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], + { type: 'leaf', groupId: GROUP } + ) +}) + +afterEach(() => { + main.close() }) function sentTypes(): string[] { - return mocks.sent.map((command) => command.type) + return sent().map((command) => command.type) } describe('simulator tabs in a layout-owner build', () => { @@ -184,7 +171,7 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records a simulator tab opened in the active group, and its close', async () => { + it('opens a simulator tab in the active group through main, and closes it there', async () => { const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' }) expect(tabId).toBeTruthy() await settled() @@ -196,12 +183,12 @@ describe('simulator tabs in a layout-owner build', () => { expect(sentTypes()).toEqual(['openSimulatorTab', 'closeTabs']) }) - it('records the split group a right-side open makes, and its collapse on close', async () => { + it('makes the right split with the tab in main, and collapses it on close', async () => { const tabId = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' }) await settled() expect(store().getState().groupsByWorktree[WORKTREE]).toHaveLength(2) expectSaveTeachesMainNothing() - expect(mainLoad().layout.workspaces[WORKTREE]?.groupLayout).toMatchObject({ + expect(layoutApplyTestTransport.mainModel!.workspaces[WORKTREE]?.groupLayout).toMatchObject({ type: 'split', direction: 'horizontal', first: { type: 'leaf', groupId: GROUP } @@ -213,9 +200,9 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records a right-side open into the right group the workspace already has', async () => { + it('opens a right-side tab into the right group the workspace already has', async () => { const right = 'group-2' - seed( + await seedDrawnFromMain( [ { id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }, { id: right, worktreeId: WORKTREE, activeTabId: 'terminal-2', tabOrder: ['terminal-2'] } @@ -233,26 +220,33 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records an open into the window’s emptied root group', async () => { - seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], { - type: 'leaf', - groupId: GROUP - }) + it('opens into the root group of a workspace whose tabs all closed', async () => { + await seedDrawnFromMain( + [{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], + { + type: 'leaf', + groupId: GROUP + } + ) + // Main keeps no empty group, so its frame drops GROUP; the open makes the root group again. + expect(store().getState().groupsByWorktree[WORKTREE] ?? []).toEqual([]) const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' }) await settled() - expect(mocks.sent).toMatchObject([ - { type: 'openSimulatorTab', tabId, groupId: GROUP, index: 0 } - ]) + const groups = store().getState().groupsByWorktree[WORKTREE] ?? [] + expect(groups).toHaveLength(1) + expect(store().getState().getTab(tabId!)?.groupId).toBe(groups[0]!.id) + expect(groups[0]!.tabOrder).toEqual([tabId]) + expect(sent()).toMatchObject([{ type: 'openSimulatorTab', tabId, groupId: groups[0]!.id }]) expectSaveTeachesMainNothing() }) it('sends nothing when the workspace already has its simulator tab', async () => { const tabId = ensureSimulatorTab(WORKTREE) await settled() - mocks.sent = [] + const before = sent().length expect(ensureSimulatorTab(WORKTREE)).toBe(tabId) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toHaveLength(before) }) it('leaves a paired server’s simulator tab to that server', async () => { @@ -285,7 +279,7 @@ describe('simulator tabs in a layout-owner build', () => { expect(tabId).toBeTruthy() store().getState().closeUnifiedTab(tabId!) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toEqual([]) }) it('sends nothing with the switch off', async () => { @@ -295,6 +289,6 @@ describe('simulator tabs in a layout-owner build', () => { const split = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' }) store().getState().closeUnifiedTab(split!) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toEqual([]) }) }) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.ts b/src/renderer/src/lib/simulator-tab-layout-owner.ts index e5279ad6c85..1f1c5d802d3 100644 --- a/src/renderer/src/lib/simulator-tab-layout-owner.ts +++ b/src/renderer/src/lib/simulator-tab-layout-owner.ts @@ -1,35 +1,59 @@ -// Simulator tabs in a layout-owner build. The window still draws the tab first (the emulator pane -// attaches to it by id); main records each open and close under the window's ids, so a window save -// no longer teaches main a simulator tab. Nothing to undo on any answer. +// Simulator tab opens in a layout-owner build: the window sends the open under a proposed tab id +// (the emulator pane attaches by it); main's frame draws the tab, and only then is it surfaced. -import type { Tab } from '../../../shared/tab-types' +import { createBrowserUuid } from './browser-uuid' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { afterPendingTab, isPendingTab } from '@/runtime/layout-owner-pending-tabs' import type { AppState } from '@/store/types' -import { recordLayoutApply } from '@/runtime/layout-apply-send' -import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement' -type SimulatorTabState = Parameters[0] & - Pick +export type SimulatorTabOpen = { + worktreeId: string + label: string + /** The group the tab joins, or the group a new right split goes beside. */ + groupId: string + rightSplit: boolean + /** Runs once main's frame drew the tab. */ + surface: (tabId: string) => void + /** Today's draw under the proposed id, for a twin main may route to a paired server. */ + drawHere: (tabId: string) => string | null +} -/** A simulator tab the window just drew, placed where the window drew it. */ -export function recordOpenedSimulatorTabInLayoutOwner(state: SimulatorTabState, tab: Tab): void { - if (tab.contentType !== 'simulator') { - return - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openSimulatorTab', - tabId: tab.id, - // Opened to the right: main makes the window's new split with the tab. - ...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id), - createdAt: tab.createdAt +/** A worktree's simulator tab main is still opening: one emulator tab per worktree. */ +const opening = new Map() + +export function openingSimulatorTabId(worktreeId: string): string | undefined { + const tabId = opening.get(worktreeId) + return tabId && isPendingTab(tabId) ? tabId : undefined +} + +/** The proposed tab id, or null when main is not asked and the caller draws as today. */ +export function openSimulatorTabThroughLayoutOwner( + state: AppState, + open: SimulatorTabOpen +): string | null { + const tabId = createBrowserUuid() + const placement = open.rightSplit + ? { + groupId: createBrowserUuid(), + split: { besideGroupId: open.groupId, direction: 'right' as const } + } + : { groupId: open.groupId } + const sent = openContentTabThroughLayoutOwner(state, { + worktreeId: open.worktreeId, + entityId: tabId, + command: { type: 'openSimulatorTab', tabId, ...placement }, + facts: { label: open.label }, + shown: open.surface, + drawHere: () => open.drawHere(tabId) }) -} - -/** A simulator tab the window just closed. */ -export function recordClosedSimulatorTabInLayoutOwner( - state: SimulatorTabState, - tab: Pick -): void { - if (tab.contentType === 'simulator') { - recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] }) + if (!sent) { + return null } + opening.set(open.worktreeId, tabId) + afterPendingTab(tabId, () => { + if (opening.get(open.worktreeId) === tabId) { + opening.delete(open.worktreeId) + } + }) + return tabId } diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index c0477222200..763a3a233e6 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -10,6 +10,9 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau import type { ProvisionalLaunchArgs } from './structured-agent-session-provisional-tab' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { useAppStore } from '@/store' +import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import { notifyHostDeclined, openDeclinedStructuredLaunchTerminal, @@ -54,7 +57,7 @@ export async function openTerminalForDeclinedChat( args: ProvisionalLaunchArgs, worktreeId: string, target: RuntimeClientTarget, - tab: Tab, + tab: OpenedAgentSessionTab, settlement: StructuredAgentLaunchSettlement ): Promise { if ( @@ -125,15 +128,12 @@ export async function openTerminalForDeclinedChat( notifyHostDeclined(args.plan.agent) return settlement } - state.moveUnifiedTabToGroup(replacement.id, current.groupId, { - activate: false, - recordInteraction: false - }) - if (Boolean(current.isPinned) !== Boolean(replacement.isPinned)) { - const setPinned = current.isPinned ? state.pinTab : state.unpinTab - setPinned(replacement.id, { recordInteraction: false }) + // Switch point: main places and pins the tab in one step; a pin sent alone would re-sort it. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await placeReplacementThroughMain(worktreeId, replacement, current) + } else { + placeReplacementAfterSource(worktreeId, replacement, current) } - insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id) const placed = useAppStore.getState() const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId) if ( @@ -157,13 +157,69 @@ export async function openTerminalForDeclinedChat( if (current.color !== null) { placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false }) } - placed.closeUnifiedTab(tab.id, { recordInteraction: false }) - if (foreground) { + const showReplacement = (): void => { useAppStore.getState().setActiveTab(replacement.entityId) useAppStore.getState().activateTab(replacement.id) useAppStore.getState().setActiveTabType('terminal', worktreeId) focusTerminalTabSurface(replacement.id) } + // Switch-on, main's close lands later and shows the group's active tab: make it the terminal. + const showsFirst = foreground && ORCA_LAYOUT_OWNER_IS_MAIN + if (showsFirst) { + showReplacement() + } + placed.closeUnifiedTab(tab.id, { recordInteraction: false }) + if (foreground && !showsFirst) { + showReplacement() + } notifyHostDeclined(args.plan.agent) return { kind: 'terminal' } } + +/** The terminal goes right after the declined chat, pinned as the chat is. */ +function placeReplacementAfterSource(worktreeId: string, replacement: Tab, source: Tab): void { + const state = useAppStore.getState() + state.moveUnifiedTabToGroup(replacement.id, source.groupId, { + activate: false, + recordInteraction: false + }) + if (Boolean(source.isPinned) !== Boolean(replacement.isPinned)) { + const setPinned = source.isPinned ? state.pinTab : state.unpinTab + setPinned(replacement.id, { recordInteraction: false }) + } + insertUnifiedTabAfterAnchor(worktreeId, replacement.id, source.id) +} + +/** One main move puts the terminal after the chat with the chat's pin; resolves once drawn. */ +function placeReplacementThroughMain( + worktreeId: string, + replacement: Tab, + source: Tab +): Promise { + const state = useAppStore.getState() + const rows = new Set((state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + // Main's index counts only tabs it draws: a hidden launch slot has no row and is not in main. + const order = (state.groupsByWorktree[worktreeId] ?? []) + .find((group) => group.id === source.groupId) + ?.tabOrder.filter((id) => id !== replacement.id && rows.has(id)) + return new Promise((resolve) => { + const placeHere = (): void => { + placeReplacementAfterSource(worktreeId, replacement, source) + resolve() + } + const sent = sendLayoutCommandToOwner( + worktreeId, + { + type: 'moveTab', + tabId: replacement.id, + toGroupId: source.groupId, + index: (order?.indexOf(source.id) ?? -1) + 1, + isPinned: Boolean(source.isPinned) + }, + { apply: () => resolve(), asToday: placeHere, refused: () => resolve() } + ) + if (!sent) { + placeHere() + } + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index be8c2303cc4..96df3f9f4d8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../shared/worktree/types' const mocks = vi.hoisted(() => ({ createSupport: vi.fn(), @@ -27,6 +28,17 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/runtime/structured-chat-host-permission', () => ({ readStructuredChatHostPermissionVerdict: mocks.hostPermission })) +// Switch-on, main opens this machine's chats and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { useAppStore } from '@/store' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' @@ -37,6 +49,10 @@ import { getStructuredAgentSessionLaunchSelection } from './structured-agent-ses import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' import { LOCAL_ADMISSION_WAIT_MS } from './structured-agent-session-host-admission' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const WORKTREE = 'repo-1::/srv/app' const INITIAL_SETTINGS = useAppStore.getState().settings @@ -58,6 +74,32 @@ function pairedPlan( const localPlan = () => pairedPlan({ executionHostId: 'local' }) +/** The workspace's row names the paired server as its owner, whose layout draws its tabs. */ +function seedPairedServerWorkspace(): void { + const worktree: Worktree = { + id: WORKTREE, + repoId: 'repo-1', + path: '/srv/app', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'app', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'runtime:server-1', + runtimeOwnerEnvironmentId: 'server-1' + } + useAppStore.setState({ worktreesByRepo: { 'repo-1': [worktree] } }) +} + /** Nothing of a chat exists on this machine: no tab, launch, record, queued prompt or intent. */ function expectNoChatCommitted(): void { expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) @@ -69,7 +111,9 @@ function expectNoChatCommitted(): void { ).toBeNull() } -beforeEach(() => { +let main: TabsDrawnFromMain + +beforeEach(async () => { vi.clearAllMocks() mocks.hostPermission.mockResolvedValue({ kind: 'allowed' }) localStorage.clear() @@ -77,11 +121,17 @@ beforeEach(() => { activeWorktreeId: WORKTREE, activeWorkspaceExecutionHostId: null, unifiedTabsByWorktree: {}, + worktreesByRepo: {}, settings: INITIAL_SETTINGS }) + main = await drawTabsFromMain(useAppStore) }) +afterEach(() => main.close()) + describe('a structured chat launch on a paired server', () => { + beforeEach(seedPairedServerWorkspace) + it('opens only the terminal when the server declines, keeping the workspace selected', async () => { mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) const onHostDeclined = vi.fn(() => ({ @@ -421,6 +471,8 @@ describe('a structured chat launch on this machine', () => { }) describe('a paired server that cannot resolve a new workspace yet', () => { + beforeEach(seedPairedServerWorkspace) + it('keeps its terminal fallback, as before', async () => { mocks.createSupport.mockRejectedValue(new Error('selector_not_found')) const onHostDeclined = vi.fn(() => ({ opened: true })) @@ -437,6 +489,7 @@ describe('a paired server that cannot resolve a new workspace yet', () => { }) it('keeps explicit refusal owned by admission and keeps an opened chat failure out of the menu', async () => { + seedPairedServerWorkspace() for (const supported of [false, true]) { mocks.toastError.mockClear() mocks.createSupport.mockResolvedValue({ supported, reason: 'wsl' }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts index 5eef23f445f..793eefb03c1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts @@ -5,6 +5,7 @@ import { toast } from 'sonner' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' const mocks = vi.hoisted(() => ({ abandonIntent: vi.fn(), @@ -180,13 +181,31 @@ describe('a close that races a structured launch', () => { startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1' }) await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce()) expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)).toBe( - true - ) const persistedTombstones = localStorage.getItem('orca:structuredAgentLaunchCancelledSessions:v1') ?? '' - expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId)) - expect(persistedTombstones).not.toContain(worktreeId) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // This machine's main hears the cancel at once and fences the chat; the window keeps none. + expect( + hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId) + ).toBe(false) + expect(persistedTombstones).not.toContain(JSON.stringify(intent.sessionId)) + expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.close', + { sessionId: intent.sessionId } + ) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'session.tabs.close', + expect.objectContaining({ tabId: `agent-session:${intent.sessionId}` }) + ) + } else { + expect( + hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId) + ).toBe(true) + expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId)) + expect(persistedTombstones).not.toContain(worktreeId) + } resolveRefresh([]) await flushLaunchSettlement() @@ -221,8 +240,11 @@ describe('a close that races a structured launch', () => { resolveRefresh([]) await flushLaunchSettlement() - // Neither sent nor given back to the closed chat's composer. - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + // Neither sent nor given back to the closed chat's composer; a layout-owner build's only call + // retires the chat on this machine's main. + expect(mocks.callRuntimeRpc.mock.calls.map(([, method]) => method)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? ['session.tabs.close'] : [] + ) expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(intent.sessionId))).toBe('') }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 8d026f1512a..d49450e7fcf 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -28,7 +28,10 @@ import { retireStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { cancelStructuredAgentLaunchOnLocalHost } from './structured-agent-session-local-launch-cancel' +import { + cancelStructuredAgentLaunchOnLocalHost, + resetStructuredAgentLocalLaunchCancelsForTests +} from './structured-agent-session-local-launch-cancel' export type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string @@ -56,6 +59,7 @@ export function resetStructuredAgentLaunchRegistryForTests(): void { structuredLaunchesBySessionId.clear() structuredLaunchListeners.clear() resetStructuredAgentLaunchCancellationForTests() + resetStructuredAgentLocalLaunchCancelsForTests() } export function notifyStructuredLaunchListeners(): void { @@ -237,12 +241,7 @@ function markStructuredAgentSessionLaunchCancelledInternal( if (matchesLaunchWorktree(state, worktreeId) && state) { // The launch's own owner outranks the caller's: it is the host the create was sent to. if (ORCA_LAYOUT_OWNER_IS_MAIN && state.intent.executionHostId === LOCAL_EXECUTION_HOST_ID) { - cancelStructuredAgentLaunchOnLocalHost( - worktreeId, - sessionId, - notifyStructuredLaunchListeners, - state.promise - ) + cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId) } else { markStructuredAgentLaunchCancellation( sessionId, @@ -261,7 +260,7 @@ function markStructuredAgentSessionLaunchCancelledInternal( !alreadyCancelled && executionHostId === LOCAL_EXECUTION_HOST_ID ) { - cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId, notifyStructuredLaunchListeners) + cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId) } else if (!alreadyCancelled) { markStructuredAgentLaunchCancellation(sessionId, executionHostId, alreadyCancelled) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-surface.ts b/src/renderer/src/lib/structured-agent-session-launch-surface.ts index b6c8d641352..8c96f3b815a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-surface.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-surface.ts @@ -24,7 +24,10 @@ import { } from './structured-agent-session-launch-registry' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' +import { + openAgentSessionTabThroughLayoutOwner, + type OpenedAgentSessionTab +} from './structured-agent-session-tab-open-through-layout-owner' import { clearWebSessionFocusIntentIfMatches, peekWebSessionFocusIntent, @@ -147,7 +150,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The tab the chat opens right after, in that tab's group. */ afterTabId?: string activate?: boolean -}): Tab { +}): OpenedAgentSessionTab { const state = useAppStore.getState() const existing = findStructuredLaunchTab(args) if (existing) { @@ -165,26 +168,55 @@ export function openStructuredAgentSessionProvisionalTab(args: { const group = state.groupsByWorktree?.[args.worktreeId]?.find((entry) => entry.tabOrder.includes(tabId) ) - const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', { - id: tabId, - entityId: args.sessionId, - executionHostId: args.executionHostId, - agentSessionAgent: args.agent, - label: defaultAgentChatLabel(args.agent), - targetGroupId: - group?.id ?? - (address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId), - ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), - activate: args.activate !== false - }) - // Switch point: main records the chat tab the window just drew. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedAgentSessionTabInLayoutOwner(useAppStore.getState(), tab) + const targetGroupId = + group?.id ?? + (address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId) + const drawHere = (): Tab => { + const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', { + id: tabId, + entityId: args.sessionId, + executionHostId: args.executionHostId, + agentSessionAgent: args.agent, + label: defaultAgentChatLabel(args.agent), + targetGroupId, + ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), + activate: args.activate !== false + }) + if (args.activate !== false) { + state.setActiveTabType('agent-session', args.worktreeId) + } + return tab } - if (args.activate !== false) { - state.setActiveTabType('agent-session', args.worktreeId) + // Switch point: main opens the chat's tab and its frame draws it. + const sent = ORCA_LAYOUT_OWNER_IS_MAIN + ? openAgentSessionTabThroughLayoutOwner({ + worktreeId: args.worktreeId, + executionHostId: args.executionHostId, + sessionId: args.sessionId, + agent: args.agent, + ...(targetGroupId ? { targetGroupId } : {}), + // A reserved slot names where the chat goes: after the tab before it. + ...(args.afterTabId + ? { afterTabId: args.afterTabId } + : slotAnchor(address, args.worktreeId)), + activate: args.activate !== false, + drawHere + }) + : null + return sent ?? drawHere() +} + +/** The last tab still before a reserved chat slot, which main places the chat after. */ +function slotAnchor( + address: StructuredLaunchSurfaceAddress | undefined, + worktreeId: string +): { afterTabId?: string } { + if (address?.worktreeId !== worktreeId) { + return {} } - return tab + const tabs = useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [] + const afterTabId = address.precedingTabIds.findLast((id) => tabs.some((tab) => tab.id === id)) + return afterTabId ? { afterTabId } : {} } export function materializeStructuredLaunchSurface(launch: LaunchSurface, activate?: false): void { diff --git a/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts index 9ac272d43d8..8f5a3876e3d 100644 --- a/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts +++ b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts @@ -1,11 +1,6 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { retireStructuredAgentSessionTab } from '@/runtime/structured-agent-session-tab-retirement' -import { - deleteStructuredAgentLaunchRecord, - hasStructuredAgentLaunchCancellationTombstonePersisted -} from './structured-agent-session-launch-persistence' -import { markStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' +import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' // Chats a cancel closed on this machine's host; session ids are never reused. const closedByLocalCancel = new Set() @@ -15,16 +10,18 @@ export function wasStructuredAgentLaunchClosedByLocalCancel(sessionId: string): return closedByLocalCancel.has(sessionId) } +export function resetStructuredAgentLocalLaunchCancelsForTests(): void { + closedByLocalCancel.clear() +} + /** * ORCA_LAYOUT_OWNER=main only: this machine's host refuses a create whose close came first, or - * closes the chat as it lands, so a local cancel retires the chat on the host at once instead of - * keeping a tombstone until the host lists it. A host that cannot be told gets the tombstone. + * closes the chat as it lands, so a local cancel retires the chat on the host at once and keeps no + * tombstone. A failed retire is logged; main still remembers the close. */ export function cancelStructuredAgentLaunchOnLocalHost( worktreeId: string, - sessionId: string, - onTombstoned: () => void, - launchPromise?: Promise + sessionId: string ): void { deleteStructuredAgentLaunchRecord(sessionId) // Why: main answers a create for a chat closed meanwhile as ok, and no tombstone is written. @@ -35,16 +32,9 @@ export function cancelStructuredAgentLaunchOnLocalHost( sessionId, onError: (error) => { // A create that has not landed has no host tab yet; main still heard the close. - if (hasRuntimeRpcErrorCode(error, 'tab_not_found')) { - return + if (!hasRuntimeRpcErrorCode(error, 'tab_not_found')) { + console.warn('[structured-agent-session] local cancel retirement failed', error) } - markStructuredAgentLaunchCancellation( - sessionId, - LOCAL_EXECUTION_HOST_ID, - hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId), - launchPromise - ) - onTombstoned() } }) } diff --git a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts index 9de47ccd66d..a65b7ad1204 100644 --- a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts @@ -1,11 +1,13 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission' +import type * as RpcClientModule from '@/runtime/runtime-rpc-client' +import type { AppState } from '@/store/types' const mocks = vi.hoisted(() => ({ createIntent: vi.fn(), @@ -49,6 +51,12 @@ function emptyStoreState(): StoreState { } } const store = vi.hoisted((): { state: StoreState } => ({ state: emptyStoreState() })) +type DrawnStore = { + getState: () => AppState + subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void +} +/** Switch on: a real store drawn from main's frames stands in for `store`. */ +const drawn = vi.hoisted((): { store: DrawnStore | null } => ({ store: null })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), message: vi.fn() } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -81,10 +89,21 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: (target: unknown, method: string, params?: unknown) => mocks.callRuntimeRpc(target, method, params) })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: mocks.callRuntimeRpc, - ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) -})) +// Switch-on, main answers the window's chat opens and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply({ + ...(await importOriginal()), + callRuntimeRpc: mocks.callRuntimeRpc, + ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) + }) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ getStructuredAgentSessionStatusFeed: () => ({ getSessionObservation: (sessionId: string) => @@ -97,33 +116,45 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ })) vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ - ...store.state, - createUnifiedTab: ( - worktreeId: string, - contentType: Tab['contentType'], - init: { targetGroupId?: string } - ) => { - const groupId = - init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries. - const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab - store.state.unifiedTabsByWorktree[worktreeId] = [ - ...(store.state.unifiedTabsByWorktree[worktreeId] ?? []), - tab - ] - return tab - }, - activateTab: mocks.activateTab, - focusGroup: mocks.focusGroup, - setActiveTabType: vi.fn(), - seedNativeChatLaunchDraft: vi.fn(), - clearNativeChatLaunchDraft: vi.fn() - }), - subscribe: () => () => undefined + getState: () => + drawn.store + ? { + ...drawn.store.getState(), + activateTab: mocks.activateTab, + focusGroup: mocks.focusGroup + } + : standInState(), + subscribe: (listener: (state: AppState, previous: AppState) => void) => + drawn.store?.subscribe(listener) ?? (() => undefined) } })) +function standInState() { + return { + ...store.state, + createUnifiedTab: ( + worktreeId: string, + contentType: Tab['contentType'], + init: { targetGroupId?: string } + ) => { + const groupId = + init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries. + const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab + store.state.unifiedTabsByWorktree[worktreeId] = [ + ...(store.state.unifiedTabsByWorktree[worktreeId] ?? []), + tab + ] + return tab + }, + activateTab: mocks.activateTab, + focusGroup: mocks.focusGroup, + setActiveTabType: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + } +} + import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' import { clearNativeChatDraftCacheForTests } from '@/components/native-chat/native-chat-draft-cache' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' @@ -138,6 +169,12 @@ import { } from './structured-agent-session-launch-prompt' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const WORKTREE_ID = 'wt-new-chat' @@ -183,6 +220,64 @@ function published(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] { })) } +let main: TabsDrawnFromMain | null = null + +/** + * Switch on: the split workspace in a real store, drawn from main's frames. Main keeps no empty + * group, so each side holds a terminal. + */ +async function drawSplitFromMain(): Promise { + const real = createTestStore() + const side = (groupId: string): Tab => ({ + id: `terminal-${groupId}`, + entityId: `terminal-${groupId}`, + groupId, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }) + const seeded = emptyStoreState() + seedStore(real, { + worktreesByRepo: { repo: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo' })] }, + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + [WORKTREE_ID]: ['group-left', 'group-right'].map((groupId, index) => ({ + id: `terminal-${groupId}`, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [side('group-left'), side('group-right')] }, + groupsByWorktree: { + [WORKTREE_ID]: seeded.groupsByWorktree[WORKTREE_ID]!.map((group) => ({ + ...group, + activeTabId: `terminal-${group.id}`, + tabOrder: [`terminal-${group.id}`] + })) + }, + layoutByWorktree: seeded.layoutByWorktree, + activeGroupIdByWorktree: seeded.activeGroupIdByWorktree + }) + main = await drawTabsFromMain(real) + drawn.store = real +} + +/** The chat tabs the workspace shows: switch-on, as main's frames drew them. */ +async function openedChats(): Promise { + await main?.settle() + const tabs = (drawn.store?.getState() ?? store.state).unifiedTabsByWorktree[WORKTREE_ID] ?? [] + return tabs.filter((tab) => tab.contentType === 'agent-session') +} + const first = launchIntent('session-first') const second = launchIntent('session-second') @@ -258,15 +353,27 @@ beforeEach(() => { }) }) +beforeEach(async () => { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await drawSplitFromMain() + } +}) + +afterEach(() => { + main?.close() + main = null + drawn.store = null +}) + describe('a second "new chat" with no text', () => { - it('opens a second chat while the first empty one is still starting', () => { + it('opens a second chat while the first empty one is still starting', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) const firstPick = pick('plus-pick-1') const secondPick = pick('plus-pick-2') expect(firstPick.sessionId).toBe(first.sessionId) expect(secondPick.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) }) it('opens a second chat beside an empty one that published and sits idle', async () => { @@ -276,7 +383,7 @@ describe('a second "new chat" with no text', () => { const secondPick = pick('plus-pick-2') expect(secondPick.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) await expect(secondPick.settlement).resolves.toEqual({ kind: 'structured', sessionId: second.sessionId @@ -306,30 +413,28 @@ describe('a "new chat" with text', () => { }) }) - it('opens its own chat beside an empty one still starting', () => { + it('opens its own chat beside an empty one still starting', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) const blank = pick('plus-pick-1') const notes = pick('notes-send', { prompt: 'review notes' }) expect(notes.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) expect(hasStagedStructuredLaunchPrompt(blank.sessionId)).toBe(false) expect(hasStagedStructuredLaunchPrompt(second.sessionId)).toBe(true) }) - it('opens its own chat in the split it was sent from', () => { + it('opens its own chat in the split it was sent from', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) pick('plus-pick-1', { group: 'group-left' }) const notes = pick('notes-send', { prompt: 'review notes', group: 'group-right' }) expect(notes.sessionId).toBe(second.sessionId) - expect( - store.state.unifiedTabsByWorktree[WORKTREE_ID]?.find( - (tab) => tab.entityId === second.sessionId - )?.groupId - ).toBe('group-right') + expect((await openedChats()).find((tab) => tab.entityId === second.sessionId)?.groupId).toBe( + 'group-right' + ) expect(mocks.focusGroup).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 5fa9802c1c1..c83b6a87efc 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type * as launchModule from './launch-structured-agent-session' import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' @@ -18,6 +18,26 @@ import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' + +// Switch-on, main opens and closes the chat's tab and its frames draw it. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) vi.mock('./launch-structured-agent-session', async (original) => ({ @@ -32,8 +52,27 @@ vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ structuredAgentSessionDraftScopeKey: (id: string) => `agent-session:${id}` })) +function terminalRecord(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +let refuse = (): void => {} + +// The host's refusal lands once the test releases it, after the chat's tab is drawn. function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentSessionLaunchPlan { - mocks.launch.mockRejectedValue(error) + const refusal = new Promise((_, reject) => { + refuse = () => reject(error) + }) + mocks.launch.mockReturnValue(refusal) return adoptAgentSessionLaunchVerdict({ requestId: 'action', route: 'structured-native-chat', @@ -43,7 +82,9 @@ function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentS }) } -beforeEach(() => { +let main: TabsDrawnFromMain + +beforeEach(async () => { vi.clearAllMocks() mocks.text = '' resetStructuredAgentLaunchRegistryForTests() @@ -56,8 +97,56 @@ beforeEach(() => { tabsByWorktree: {}, tabBarOrderByWorktree: {} }) + main = await drawTabsFromMain(useAppStore) }) +afterEach(() => main.close()) + +// Main takes the terminal tabs the test drew straight into the store, as a real create sends them. +async function mainLoadsStoreLayout(): Promise { + main.close() + main = await drawTabsFromMain(useAppStore) +} + +/** The user's pin: the tab bar sends it to main switch-on, whose frame draws it. */ +function pinAsUser(tabId: string): void { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + useAppStore.getState().pinTab(tabId) + return + } + const worktreeId = useAppStore.getState().getTab(tabId)?.worktreeId ?? 'wt-1' + sendLayoutCommandToOwner( + worktreeId, + { type: 'setTabProps', tabId, isPinned: true }, + { apply: () => {}, asToday: () => useAppStore.getState().pinTab(tabId) } + ) +} + +/** The tab bar's rename and colour: main's commands with the switch on. */ +function labelAsUser(tabId: string, label: string, color: string): void { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + useAppStore.getState().setTabCustomLabel(tabId, label) + useAppStore.getState().setUnifiedTabColor(tabId, color) + return + } + const asToday = { apply: () => {}, asToday: () => {} } + sendLayoutCommandToOwner( + 'wt-1', + { type: 'renameTab', tabId, kind: 'custom', title: label }, + asToday + ) + sendLayoutCommandToOwner('wt-1', { type: 'setTabProps', tabId, color }, asToday) +} + +/** The group main's frame drew the tab in. */ +function groupOf(tabId: string): string { + const groupId = useAppStore.getState().getTab(tabId)?.groupId + if (!groupId) { + throw new Error(`No drawn tab ${tabId}`) + } + return groupId +} + function openChat(requestedSurface?: 'chat') { let complete!: (value: AgentTerminalLaunchCompletion) => void const completion = new Promise((resolve) => { @@ -100,6 +189,7 @@ describe('a provisional structured chat tab', () => { const { launch, chat, fallback, complete } = openChat( scenario === 'explicit' ? 'chat' : undefined ) + await main.settle() expect(mocks.focus).toHaveBeenCalledWith(chat.id) const view = renderHook(() => { const current = useNativeChatProvisionalLaunch('wt-1', chat.entityId) @@ -120,6 +210,8 @@ describe('a provisional structured chat tab', () => { if (scenario === 'closed') { useAppStore.getState().closeUnifiedTab(chat.id) } + await main.settle() + refuse() await vi.waitFor(() => expect( scenario === 'closed' || scenario === 'explicit' || fallback.mock.calls.length @@ -131,19 +223,26 @@ describe('a provisional structured chat tab', () => { view.unmount() return } - expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(chat) + // Switch-on, main's frame draws the tab the open named. + expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual( + expect.objectContaining({ ...chat, contentType: 'agent-session' }) + ) // Move the source while its host is preparing the terminal, then invest in the composer. const sibling = useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { entityId: 'sibling', - split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true } + split: { sourceGroupId: groupOf(chat.id), splitDirection: 'right', activate: true } }) const groupId = sibling.groupId + // Main loads only terminal tabs with their terminal's record. + const siblingRecords = () => + ORCA_LAYOUT_OWNER_IS_MAIN ? [terminalRecord(sibling.entityId)] : [] + useAppStore.setState({ tabsByWorktree: { 'wt-1': siblingRecords() } }) + await mainLoadsStoreLayout() const pinnedSource = scenario === 'empty' || scenario === 'typed-during' if (pinnedSource) { - useAppStore.getState().pinTab(chat.id) - useAppStore.getState().pinTab(sibling.id) - useAppStore.getState().setTabCustomLabel(chat.id, 'My work') - useAppStore.getState().setUnifiedTabColor(chat.id, '#ff0000') + pinAsUser(chat.id) + pinAsUser(sibling.id) + labelAsUser(chat.id, 'My work', '#ff0000') } useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId) useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id]) @@ -163,12 +262,12 @@ describe('a provisional structured chat tab', () => { if (scenario === 'closed-during') { useAppStore.getState().closeUnifiedTab(chat.id) } + await main.settle() if (pinnedSource) { useAppStore.getState().activateTab(chat.id) useAppStore.getState().setActiveTabType('agent-session', 'wt-1') } const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId) - const selected = useAppStore.getState().getActiveTab('wt-2') const terminal = scenario === 'failed' || scenario === 'unreconciled' ? null @@ -177,25 +276,14 @@ describe('a provisional structured chat tab', () => { .createUnifiedTab('wt-1', 'terminal', { id: 'replacement', activate: false }) if (terminal) { useAppStore.setState({ - tabsByWorktree: { - 'wt-1': [ - { - id: terminal.entityId, - ptyId: null, - worktreeId: 'wt-1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } + tabsByWorktree: { 'wt-1': [terminalRecord(terminal.entityId), ...siblingRecords()] } }) if (scenario === 'typed-before') { useAppStore.getState().pinTab(terminal.id) } } + await mainLoadsStoreLayout() + const selected = useAppStore.getState().getActiveTab('wt-2') const interaction = vi.fn() useAppStore.setState({ recordFeatureInteraction: interaction }) await act(async () => { @@ -205,6 +293,7 @@ describe('a provisional structured chat tab', () => { tabId: terminal?.id ?? 'missing' }) await launch?.settlement + await main.settle() }) expect(interaction).not.toHaveBeenCalled() const state = useAppStore.getState() @@ -267,4 +356,51 @@ describe('a provisional structured chat tab', () => { } view.unmount() }) + + // A hidden launch slot is in the window's group order but not in main's: the terminal's place + // is counted among the tabs both draw. + it("places a declined chat's terminal right after the chat past a hidden launch slot", async () => { + const { launch, chat, fallback, complete } = openChat() + await main.settle() + refuse() + await vi.waitFor(() => expect(fallback).toHaveBeenCalled()) + const chatGroupId = groupOf(chat.id) + const state = useAppStore.getState() + const sibling = state.createUnifiedTab('wt-1', 'terminal', { + entityId: 'sibling', + targetGroupId: chatGroupId, + activate: false + }) + const terminal = state.createUnifiedTab('wt-1', 'terminal', { + id: 'replacement', + targetGroupId: chatGroupId, + activate: false + }) + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': ORCA_LAYOUT_OWNER_IS_MAIN + ? [terminalRecord(sibling.entityId), terminalRecord(terminal.entityId)] + : [] + } + }) + await mainLoadsStoreLayout() + const slot = structuredAgentSessionTabId('hidden-launch') + useAppStore.getState().ensureWorktreeRootGroup('wt-1', { + tabId: slot, + targetGroupId: chatGroupId, + precedingTabIds: [] + }) + const order = () => + useAppStore.getState().groupsByWorktree['wt-1']?.find((g) => g.id === chatGroupId)?.tabOrder + expect(order()).toEqual([slot, chat.id, sibling.id, terminal.id]) + + await act(async () => { + complete({ outcome: { status: 'created' }, tabId: terminal.id }) + await launch?.settlement + await main.settle() + }) + + expect(useAppStore.getState().getTab(chat.id)).toBeNull() + expect(order()?.filter((id) => id !== slot)).toEqual([terminal.id, sibling.id]) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index cdf6389ebeb..481608d4aca 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,5 +1,4 @@ import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, AgentSessionLaunchTarget @@ -27,13 +26,14 @@ import { opensAtAction } from './structured-agent-session-action-launch' import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims' +import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner' import { captureStructuredLaunchSurfaceAddress, openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' export { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' -type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } +type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: OpenedAgentSessionTab } /** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */ export type StructuredAgentSessionProvisionalLaunch = diff --git a/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts new file mode 100644 index 00000000000..60be94e2882 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts @@ -0,0 +1,68 @@ +// A chat tab open in a layout-owner build: the window sends the open under the chat's own tab id; +// main's frame draws the tab, and only then does the window select it. A caller acting on the +// returned tab in the store waits with `afterPendingTab`. + +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Tab } from '../../../shared/tab-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { useAppStore } from '@/store' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs' +import { contentTabOpenPlacement } from '@/store/slices/tabs/content-tab-open-placement' + +export type AgentSessionTabOpen = { + worktreeId: string + executionHostId: ExecutionHostId + sessionId: string + agent: TuiAgent + targetGroupId?: string + afterTabId?: string + activate: boolean + /** Today's draw, for a twin main may route to a paired server. */ + drawHere: () => Tab +} + +/** What a caller may rely on before main's frame draws the tab: its id and the chat it shows. */ +export type OpenedAgentSessionTab = Pick + +/** Null when main is not asked (a paired server owns the workspace's layout): draw as today. */ +export function openAgentSessionTabThroughLayoutOwner( + open: AgentSessionTabOpen +): OpenedAgentSessionTab | null { + const { worktreeId, sessionId } = open + const state = useAppStore.getState() + const tabId = structuredAgentSessionTabId(sessionId) + const label = defaultAgentChatLabel(open.agent) + const placement = contentTabOpenPlacement(state, worktreeId, tabId, { + targetGroupId: open.targetGroupId, + afterTabId: open.afterTabId, + executionHostId: open.executionHostId + }) + if (!placement) { + return null + } + const tab = { id: tabId, entityId: sessionId } + // A second launch of the same chat waits for the first open. + if (hasPendingEntityTab(worktreeId, sessionId)) { + return tab + } + const sent = openContentTabThroughLayoutOwner(state, { + worktreeId, + entityId: sessionId, + command: { type: 'openAgentSessionTab', tabId, sessionId, agent: open.agent, ...placement }, + facts: { label }, + shown: (shownId) => { + if (!open.activate) { + return + } + // As the window's own open: the chat is selected in its group; the focused group stays. + const current = useAppStore.getState() + current.activateTab(shownId, { worktreeId }) + current.setActiveTabType('agent-session', worktreeId) + }, + drawHere: () => open.drawHere().id + }) + return sent ? tab : null +} diff --git a/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts b/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts deleted file mode 100644 index 8fca9dfa97c..00000000000 --- a/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -// @vitest-environment happy-dom - -// With ORCA_LAYOUT_OWNER=main, a chat tab's open and close reach main as commands, so the window's -// next save changes nothing in main's model (what the save-divergence detector checks). -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import type { Tab, TabGroup } from '../../../shared/tab-types' -import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' -import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' -import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' -import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' -import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { - createTestStore, - makeRuntimeOwnedWorktree, - makeWorktree, - runtimeOwnedRepoState, - seedStore -} from '@/store/slices/store-test-helpers' -import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' - -type LayoutLoad = ReturnType - -type Mocks = { - layoutOwnerIsMain: boolean - store: ReturnType | null - main: LayoutLoad | null - sent: LayoutCommand[] -} - -const mocks = vi.hoisted((): Mocks => ({ - layoutOwnerIsMain: true, - store: null, - main: null, - sent: [] -})) - -vi.mock('../../../shared/layout-owner-switch', () => ({ - get ORCA_LAYOUT_OWNER_IS_MAIN() { - return mocks.layoutOwnerIsMain - } -})) -vi.mock('@/store', () => ({ - get useAppStore() { - return mocks.store - } -})) -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) -vi.mock('./runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn( - async (_target: unknown, method: string, params?: { command?: LayoutCommand }) => { - if (method !== 'layout.apply' || !params?.command) { - return {} - } - mocks.sent.push(params.command) - const main = mainLoad() - // As runtime-layout-apply.ts: the window already confirmed its close, so main forces it. - const command: LayoutCommand = - params.command.type === 'closeTabs' ? { ...params.command, force: true } : params.command - const applied = applyLayoutCommand(main.layout, command, { - mintId: createBrowserUuid, - mintLeafId: createBrowserUuid, - now: Date.now - }) - if (!applied.ok) { - return { ok: false, code: applied.code } - } - mocks.main = { ...main, layout: applied.model } - return { ok: true, result: applied.result } - } - ) -})) - -createStoreCascadesMockApi() - -import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' -import { applyStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply' -import { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence' -import { recordSyncedAgentSessionTabsInLayoutOwner } from './agent-session-tab-layout-owner' - -const WORKTREE = 'repo1::/path/wt' -const TERMINAL = 'terminal-1' -const GROUP = 'group-1' - -function store(): ReturnType { - if (!mocks.store) { - throw new Error('store not seeded') - } - return mocks.store -} - -function mainLoad(): LayoutLoad { - if (!mocks.main) { - throw new Error('main not loaded') - } - return mocks.main -} - -function windowSave(): WorkspaceSessionState { - return buildWorkspaceSessionPayload(store().getState()) -} - -/** What main's next read of its stored copy holds, as the detector reads it before a save. */ -function mainStoredModel() { - const stored = saveWorkspaceLayout(mainLoad()) - return loadWorkspaceLayoutWithoutReport(LOCAL_EXECUTION_HOST_ID, stored, nameBasedLoadContext()) - .layout -} - -/** The workspace as the window's save would give it to main, beside main's own copy. */ -function windowAndMain() { - const window = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - windowSave(), - nameBasedLoadContext() - ).layout - const main = mainStoredModel() - return { window: window.workspaces[WORKTREE], main: main.workspaces[WORKTREE] } -} - -function expectSaveTeachesMainNothing(): void { - const { window, main } = windowAndMain() - expect(window).toEqual(main) -} - -async function settled(): Promise { - await vi.waitFor(() => Promise.resolve()) - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -function hostListing(version: number, chats: string[]): RuntimeMobileSessionTabsResult { - return { - worktree: WORKTREE, - publicationEpoch: 'epoch-1', - snapshotVersion: version, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: chats.map((sessionId) => ({ - type: 'agent-session' as const, - id: `agent-session:${sessionId}`, - title: 'Codex Chat', - sessionId, - agent: 'codex' as const, - isActive: false - })) - } -} - -beforeEach(() => { - mocks.layoutOwnerIsMain = true - mocks.sent = [] - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const testStore = createTestStore() - mocks.store = testStore - const terminal: Tab = { - id: TERMINAL, - entityId: TERMINAL, - groupId: GROUP, - worktreeId: WORKTREE, - contentType: 'terminal', - label: 'Terminal 1', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - const group: TabGroup = { - id: GROUP, - worktreeId: WORKTREE, - activeTabId: TERMINAL, - tabOrder: [TERMINAL] - } - seedStore(testStore, { - worktreesByRepo: { repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })] }, - activeWorktreeId: WORKTREE, - tabsByWorktree: { - [WORKTREE]: [ - { - id: TERMINAL, - ptyId: null, - worktreeId: WORKTREE, - title: 'Terminal 1', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - unifiedTabsByWorktree: { [WORKTREE]: [terminal] }, - groupsByWorktree: { [WORKTREE]: [group] }, - activeGroupIdByWorktree: { [WORKTREE]: GROUP }, - layoutByWorktree: { [WORKTREE]: { type: 'leaf', groupId: GROUP } } - }) - // Main starts from the window's last save, as it does today. - mocks.main = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - windowSave(), - nameBasedLoadContext() - ) -}) - -afterEach(() => { - resetLocalStructuredSessionVersionForTests() -}) - -describe('chat tabs in a layout-owner build', () => { - it('starts with main holding the window’s layout', () => { - expectSaveTeachesMainNothing() - }) - - it('records a chat the window opens and closes, so its saves teach main nothing', async () => { - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: WORKTREE, - executionHostId: LOCAL_EXECUTION_HOST_ID, - sessionId: 'chat-1', - agent: 'codex' - }) - await settled() - expectSaveTeachesMainNothing() - - store().getState().closeUnifiedTab(tab.id) - await settled() - expectSaveTeachesMainNothing() - expect(mocks.sent.map((command) => command.type)).toEqual(['openAgentSessionTab', 'closeTabs']) - }) - - it('records the chats a host listing adds to and removes from the tab bar', async () => { - applyStructuredSessionTabSnapshots([hostListing(1, ['chat-2'])]) - await settled() - expect( - store() - .getState() - .unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.entityId) - ).toEqual([TERMINAL, 'chat-2']) - expectSaveTeachesMainNothing() - - applyStructuredSessionTabSnapshots([hostListing(2, [])]) - await settled() - expect( - store() - .getState() - .unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.entityId) - ).toEqual([TERMINAL]) - expectSaveTeachesMainNothing() - }) - - it('sends a listing’s new chats in tab order, so each anchor is already in main', async () => { - const before = store().getState() - const chat = (sessionId: string): Tab => ({ - id: `structured-agent-session-${sessionId}`, - entityId: sessionId, - groupId: GROUP, - worktreeId: WORKTREE, - contentType: 'agent-session', - agentSessionAgent: 'codex', - label: 'Codex Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 5 - }) - const [a, b] = [chat('chat-a'), chat('chat-b')] - const after = { - ...before, - // The tab bar lists b first; the group shows a first. - unifiedTabsByWorktree: { [WORKTREE]: [...before.unifiedTabsByWorktree[WORKTREE]!, b, a] }, - groupsByWorktree: { - [WORKTREE]: [ - { ...before.groupsByWorktree[WORKTREE]![0]!, tabOrder: [TERMINAL, a.id, b.id] } - ] - } - } - recordSyncedAgentSessionTabsInLayoutOwner(before, after, [WORKTREE]) - await settled() - expect( - mocks.sent.flatMap((command) => - command.type === 'openAgentSessionTab' ? [[command.tabId, command.afterTabId]] : [] - ) - ).toEqual([ - [a.id, TERMINAL], - [b.id, a.id] - ]) - expect(mainLoad().layout.workspaces[WORKTREE]?.groups[0]?.tabOrder).toEqual([ - TERMINAL, - a.id, - b.id - ]) - }) - - it('leaves a paired server’s chat tabs to that server', async () => { - const remote = 'repo2::/path/remote' - seedStore( - store(), - runtimeOwnedRepoState('env-1', 'repo2', '/path/remote', { - worktreesByRepo: { - repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })], - repo2: [makeRuntimeOwnedWorktree({ id: remote, repoId: 'repo2' }, 'env-1')] - } - }) - ) - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: remote, - executionHostId: 'runtime:env-1', - sessionId: 'chat-5', - agent: 'codex' - }) - expect( - store() - .getState() - .unifiedTabsByWorktree[remote]?.map((entry) => entry.id) - ).toEqual([tab.id]) - store().getState().closeUnifiedTab(tab.id) - await settled() - expect(store().getState().unifiedTabsByWorktree[remote]).toEqual([]) - expect(mocks.sent).toEqual([]) - }) - - it('sends nothing with the switch off', async () => { - mocks.layoutOwnerIsMain = false - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: WORKTREE, - executionHostId: LOCAL_EXECUTION_HOST_ID, - sessionId: 'chat-3', - agent: 'codex' - }) - applyStructuredSessionTabSnapshots([hostListing(1, ['chat-3', 'chat-4'])]) - store().getState().closeUnifiedTab(tab.id) - await settled() - expect(mocks.sent).toEqual([]) - }) -}) diff --git a/src/renderer/src/runtime/agent-session-tab-layout-owner.ts b/src/renderer/src/runtime/agent-session-tab-layout-owner.ts deleted file mode 100644 index 9ed50821921..00000000000 --- a/src/renderer/src/runtime/agent-session-tab-layout-owner.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Chat (agent-session) tabs in a layout-owner build (E2-5). The window still draws a chat's tab -// first, from its own launch or from this machine's host listing the chat; main records each open, -// close and prop change under the window's ids, so a window save no longer teaches main a chat tab. -// Nothing to undo on any answer: the window's next save carries a change main could not take. - -import type { Tab } from '../../../shared/tab-types' -import type { AppState } from '@/store/types' -import { recordLayoutApply } from './layout-apply-send' -import { drawnTabPlacement } from './layout-owner-drawn-tab-placement' - -type ChatTabState = Parameters[0] & - Pick - -/** A chat tab the window just drew, placed where the window drew it. */ -export function recordOpenedAgentSessionTabInLayoutOwner(state: ChatTabState, tab: Tab): void { - if (tab.contentType !== 'agent-session' || !tab.agentSessionAgent) { - return - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openAgentSessionTab', - tabId: tab.id, - sessionId: tab.entityId, - agent: tab.agentSessionAgent, - ...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id), - createdAt: tab.createdAt - }) - recordChatTabProps(state, tab, { color: null }) -} - -/** A chat tab the window just closed. */ -export function recordClosedAgentSessionTabInLayoutOwner( - state: ChatTabState, - tab: Pick -): void { - if (tab.contentType === 'agent-session') { - recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] }) - } -} - -/** The chat tabs a host listing added to, removed from or restyled in these workspaces' tab bars. */ -export function recordSyncedAgentSessionTabsInLayoutOwner( - before: ChatTabState, - after: ChatTabState, - worktreeIds: Iterable -): void { - for (const worktreeId of new Set(worktreeIds)) { - const was = chatTabsById(before, worktreeId) - const now = chatTabsById(after, worktreeId) - for (const [id, tab] of was) { - if (!now.has(id)) { - recordClosedAgentSessionTabInLayoutOwner(after, tab) - } - } - const opened: Tab[] = [] - for (const [id, tab] of now) { - const old = was.get(id) - if (!old) { - opened.push(tab) - } else if (old !== tab) { - recordChatTabProps(after, tab, old) - } - } - // In tab order, so each open's anchor (the tab before it) already reached main. - const position = tabOrderPositions(after, worktreeId) - opened.sort((a, b) => (position.get(a.id) ?? Infinity) - (position.get(b.id) ?? Infinity)) - for (const tab of opened) { - recordOpenedAgentSessionTabInLayoutOwner(after, tab) - } - } -} - -function tabOrderPositions(state: ChatTabState, worktreeId: string): Map { - const ids = (state.groupsByWorktree[worktreeId] ?? []).flatMap((group) => group.tabOrder) - return new Map(ids.map((id, index) => [id, index])) -} - -function chatTabsById(state: ChatTabState, worktreeId: string): Map { - return new Map( - (state.unifiedTabsByWorktree[worktreeId] ?? []).flatMap((tab) => - tab.contentType === 'agent-session' ? [[tab.id, tab] as const] : [] - ) - ) -} - -/** A host's color or pin for a chat; an open carries neither, and a new tab is unpinned in main. */ -function recordChatTabProps( - state: ChatTabState, - tab: Tab, - old: Pick & Partial> -): void { - const color = tab.color ?? null - const props = { - ...(color !== (old.color ?? null) ? { color } : {}), - ...(tab.isPinned !== undefined && tab.isPinned !== old.isPinned - ? { isPinned: tab.isPinned } - : {}) - } - if (Object.keys(props).length > 0) { - recordLayoutApply(state, tab.worktreeId, { type: 'setTabProps', tabId: tab.id, ...props }) - } -} diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index bbf5ae79274..eea73886924 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -40,7 +40,7 @@ export type WindowLayoutCommand = | { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide } | { type: 'setPaneRatios'; tabId: string; ratios: SplitRatioIntent[] } | { type: 'setChatPane'; tabId: string; leafId: string | null } - | { type: 'moveTab'; tabId: string; toGroupId: string; index: number } + | { type: 'moveTab'; tabId: string; toGroupId: string; index: number; isPinned?: boolean } | { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide } | { type: 'setGroupRatios'; ratios: SplitRatioIntent[] } | { type: 'closeTabs'; tabIds: [string]; recordClose?: 'user' | 'cleanup' } @@ -71,8 +71,9 @@ export type WindowLayoutCommand = groupId?: string afterTabId?: string index?: number - createdAt: number - browserCreatedAt: number + /** Absent from a switch-on open: main stamps the tab's time. */ + createdAt?: number + browserCreatedAt?: number split?: NewGroupSplit } | { type: 'openBrowserPage' | 'closeBrowserPage'; browserTabId: string; pageId: string } @@ -105,7 +106,7 @@ export type WindowLayoutCommand = groupId: string afterTabId?: string index?: number - createdAt: number + createdAt?: number split?: NewGroupSplit } | { type: 'promotePreviewTab'; tabId: string } @@ -115,7 +116,7 @@ export type WindowLayoutCommand = groupId: string afterTabId?: string index?: number - createdAt: number + createdAt?: number split?: NewGroupSplit } | { @@ -174,26 +175,24 @@ function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnT switch (command.type) { case 'closeTabs': return [{ workspace: worktreeId, ids: command.tabIds }] - case 'openBrowserTab': - return [{ workspace: worktreeId, ids: [command.tabId, command.browserTabId] }] case 'splitGroup': return [{ workspace: worktreeId, ids: [command.tabId], groupLayout: true }] case 'moveTab': case 'renameTab': case 'setTabProps': - case 'openAgentSessionTab': - case 'openSimulatorTab': case 'createTerminalTab': return [{ workspace: worktreeId, ids: [command.tabId] }] + case 'setGroupRatios': + return [{ workspace: worktreeId, ids: [], groupLayout: true }] + // Pane commands hold their tab's pane tree; records are not drawn from frames; editor, browser, + // chat and simulator opens and page changes are sent before the window draws them. + case 'openBrowserTab': case 'openBrowserPage': case 'closeBrowserPage': case 'replaceBrowserPage': case 'setBrowserTabProfile': - return [{ workspace: worktreeId, ids: [command.browserTabId] }] - case 'setGroupRatios': - return [{ workspace: worktreeId, ids: [], groupLayout: true }] - // Pane commands hold their tab's pane tree; records are not drawn from frames; editor opens, - // promotions, moves and renames are sent before the window draws them. + case 'openAgentSessionTab': + case 'openSimulatorTab': case 'openEditorTab': case 'promotePreviewTab': case 'moveTabToWorkspace': diff --git a/src/renderer/src/runtime/layout-owner-content-tab-open.ts b/src/renderer/src/runtime/layout-owner-content-tab-open.ts new file mode 100644 index 00000000000..89378f0fec0 --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-content-tab-open.ts @@ -0,0 +1,63 @@ +// A browser, chat or simulator tab open in a layout-owner build: the window proposes the ids and +// sends the open; main's frame draws the tab with the window's facts for it, and only then does +// the window act on it (select, focus). A caller acting on the tab waits with `afterPendingTab`. + +import { sendLayoutApply, type WindowLayoutCommand } from './layout-apply-send' +import { beginPendingTab } from './layout-owner-pending-tabs' +import { expectTabFacts, type PendingTabFacts } from './layout-owner-pending-tab-facts' + +type ContentTabOpenCommand = Extract< + WindowLayoutCommand, + { type: 'openBrowserTab' | 'openAgentSessionTab' | 'openSimulatorTab' } +> + +export type ContentTabOpen = { + worktreeId: string + /** What the tab shows: a browser record, chat session or the simulator tab itself. */ + entityId: string + command: ContentTabOpenCommand + facts: Omit + /** Runs once main's frame drew the tab, with the id it shows. */ + shown: (tabId: string) => void + /** Today's draw, for a twin main may route to a paired server; the tab id it drew, if any. */ + drawHere: () => string | null +} + +/** False when main is not asked (a paired server owns the workspace's layout); draw as today. */ +export function openContentTabThroughLayoutOwner( + state: Parameters[0], + open: ContentTabOpen +): boolean { + const { worktreeId, command } = open + const groupId = command.groupId + const settle = beginPendingTab(command.tabId, worktreeId, open.entityId, { + kind: 'open', + groupId + }) + const release = expectTabFacts({ ...open.facts, worktreeId, tabId: command.tabId }) + const sent = sendLayoutApply(state, worktreeId, command, { + apply: (result) => { + const shownId = result.tabId ?? command.tabId + // The frame drew the tab with these facts; the store holds them from here on. + release() + try { + open.shown(shownId) + } finally { + settle(shownId) + } + }, + asToday: () => { + release() + settle(open.drawHere()) + }, + refused: () => { + release() + settle(null) + } + }) + if (!sent) { + release() + settle(null) + } + return sent +} diff --git a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts b/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts deleted file mode 100644 index 82db9803e29..00000000000 --- a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { drawnTabPlacement } from './layout-owner-drawn-tab-placement' - -const WT = 'wt-1' - -function stateOf(groups: TabGroup[], layout: TabGroupLayoutNode) { - return { groupsByWorktree: { [WT]: groups }, layoutByWorktree: { [WT]: layout } } -} - -function group(id: string, tabOrder: string[]): TabGroup { - return { id, worktreeId: WT, activeTabId: tabOrder[0] ?? null, tabOrder } -} - -describe('drawnTabPlacement', () => { - const single = stateOf([group('G', ['a', 'b', 'c'])], { type: 'leaf', groupId: 'G' }) - - it('places a tab drawn first at index 0', () => { - expect(drawnTabPlacement(single, WT, 'G', 'a')).toEqual({ groupId: 'G', index: 0 }) - }) - - it('places a middle tab after the tab before it', () => { - expect(drawnTabPlacement(single, WT, 'G', 'b')).toEqual({ groupId: 'G', afterTabId: 'a' }) - }) - - it('places the last tab after the tab before it', () => { - expect(drawnTabPlacement(single, WT, 'G', 'c')).toEqual({ groupId: 'G', afterTabId: 'b' }) - }) - - it('names only the group for a tab missing from the order', () => { - expect(drawnTabPlacement(single, WT, 'G', 'z')).toEqual({ groupId: 'G' }) - expect(drawnTabPlacement(single, WT, 'missing-group', 'a')).toEqual({ - groupId: 'missing-group' - }) - }) - - it('carries the split for a tab opened to the side in its own new group', () => { - const state = stateOf([group('G', ['a']), group('N', ['n'])], { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'G' }, - second: { type: 'leaf', groupId: 'N' } - }) - expect(drawnTabPlacement(state, WT, 'N', 'n')).toEqual({ - groupId: 'N', - index: 0, - split: { besideGroupId: 'G', direction: 'right' } - }) - }) -}) diff --git a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts b/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts deleted file mode 100644 index fa9b0315538..00000000000 --- a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NewGroupSplit } from '../../../shared/workspace-layout/workspace-layout-command-types' -import { newGroupSplitOf } from './layout-owner-new-group-split' - -type DrawnTabPlacement = { - groupId: string - afterTabId?: string - index?: 0 - split?: NewGroupSplit -} - -/** - * Where the window drew a just-opened tab, as an open command's placement fields: after the tab - * before it, first in its group, or (missing from the order) only the group; plus the split that - * made its group when it was opened to the side. - */ -export function drawnTabPlacement( - state: Parameters[0], - worktreeId: string, - groupId: string, - tabId: string -): DrawnTabPlacement { - const order = state.groupsByWorktree[worktreeId]?.find((group) => group.id === groupId)?.tabOrder - const position = order?.indexOf(tabId) ?? -1 - const afterTabId = position > 0 ? order?.[position - 1] : undefined - return { - groupId, - ...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {}), - ...newGroupSplitOf(state, worktreeId, groupId, tabId) - } -} diff --git a/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts b/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts new file mode 100644 index 00000000000..5f583f9e4dc --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts @@ -0,0 +1,68 @@ +// Content facts of a tab this window asked main to open, in a layout-owner build: its tab-bar +// label and, for a browser tab, its page state and pages. Main holds only the tab's layout, so a +// frame build reads these as the store's own until the frame that draws the tab is in. + +import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import { + BROWSER_TAB_LAYOUT_FIELDS, + BROWSER_TAB_WORKSPACE_FIELDS, + type LayoutContentFacts +} from '../../../shared/workspace-layout/workspace-layout-beside' +import { omitStoredFields } from '../../../shared/workspace-layout/stored-record-fields' + +export type PendingTabFacts = { + worktreeId: string + tabId: string + label: string + browser?: { record: BrowserWorkspace; pages: BrowserPage[] } +} + +const pending = new Map() + +/** Kept until the release, which is a no-op once a later expectation for the tab replaced it. */ +export function expectTabFacts(facts: PendingTabFacts): () => void { + pending.set(facts.tabId, facts) + return () => { + if (pending.get(facts.tabId) === facts) { + pending.delete(facts.tabId) + } + } +} + +/** `facts` with the pending tabs of `keys` added where the store had none of their own. */ +export function withPendingTabFacts( + facts: LayoutContentFacts, + keys: readonly string[] +): LayoutContentFacts { + const entries = [...pending.values()].filter((entry) => keys.includes(entry.worktreeId)) + if (entries.length === 0) { + return facts + } + const tabLabels = { ...facts.tabLabels } + const browserTabs = { ...facts.browserTabs } + const browserPagesByWorkspace = { ...facts.browserPagesByWorkspace } + for (const { worktreeId, tabId, label, browser } of entries) { + if (tabLabels[worktreeId]?.[tabId] === undefined) { + tabLabels[worktreeId] = { ...tabLabels[worktreeId], [tabId]: label } + } + if (!browser) { + continue + } + const id = browser.record.id + if (!browserTabs[worktreeId]?.[id]) { + const live = omitStoredFields(browser.record, [ + ...BROWSER_TAB_LAYOUT_FIELDS, + ...BROWSER_TAB_WORKSPACE_FIELDS + ]) + browserTabs[worktreeId] = { ...browserTabs[worktreeId], [id]: { ...live, loading: false } } + } + if (!browserPagesByWorkspace[id]?.length) { + browserPagesByWorkspace[id] = browser.pages.map((page) => ({ ...page, loading: false })) + } + } + return { ...facts, tabLabels, browserTabs, browserPagesByWorkspace } +} + +export function _resetPendingTabFactsForTests(): void { + pending.clear() +} diff --git a/src/renderer/src/runtime/layout-owner-pending-tabs.ts b/src/renderer/src/runtime/layout-owner-pending-tabs.ts index d5a1e0bbf0e..ec4b091237d 100644 --- a/src/renderer/src/runtime/layout-owner-pending-tabs.ts +++ b/src/renderer/src/runtime/layout-owner-pending-tabs.ts @@ -50,6 +50,10 @@ export function afterPendingTab(tabId: string, run: (outcome: Outcome) => void): } } +export function isPendingTab(tabId: string): boolean { + return pending.has(tabId) +} + /** The group a pending open asked for. */ export function pendingTabGroupId(tabId: string): string | undefined { const ask = pending.get(tabId)?.ask diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts new file mode 100644 index 00000000000..cb6943da11a --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom + +// Switch-on, this machine's chat listing leaves a frame-drawn workspace's tabs as main drew them +// and only updates a drawn chat tab's label from the host's title. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' +import { resetLocalStructuredSessionVersionForTests } from './inventory-generation-fence' +import { resetWebSessionTabsSnapshotFreshnessForTests } from '../web-session-tabs-sync/tracking-lifecycle' + +const WT = 'repo-1::wt-drawn' +const GROUP = 'group-1' +const TERMINAL = 'terminal-1' +const CHAT = 'structured-agent-session-chat-1' + +function tab(id: string, entityId: string, contentType: Tab['contentType'], label: string): Tab { + return { + id, + entityId, + groupId: GROUP, + worktreeId: WT, + contentType, + label, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +const STRUCTURE_FIELDS = [ + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'tabBarOrderByWorktree', + 'activeTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabId', + 'activeTabType' +] as const + +function structure(state: AppState): Partial { + return Object.fromEntries(STRUCTURE_FIELDS.map((field) => [field, state[field]])) +} + +/** The host lists the drawn chat under a new title and a second chat main has not placed yet. */ +function listing(): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'host-group', + activeTabId: 'agent-session:chat-2', + activeTabType: 'agent-session', + navigationIntent: 'follow', + tabGroups: [ + { + id: 'host-group', + activeTabId: 'agent-session:chat-2', + tabOrder: ['agent-session:chat-1', 'agent-session:chat-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:chat-1', + title: 'Renamed by host', + sessionId: 'chat-1', + agent: 'codex', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:chat-2', + title: 'Second chat', + sessionId: 'chat-2', + agent: 'codex', + isActive: true + } + ] + } +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: WT, + unifiedTabsByWorktree: { + [WT]: [ + tab(TERMINAL, TERMINAL, 'terminal', 'Terminal'), + { ...tab(CHAT, 'chat-1', 'agent-session', 'Old title'), agentSessionAgent: 'codex' } + ] + }, + groupsByWorktree: { + [WT]: [{ id: GROUP, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL, CHAT] }] + }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: GROUP } }, + activeGroupIdByWorktree: { [WT]: GROUP }, + tabBarOrderByWorktree: { [WT]: [TERMINAL, CHAT] }, + activeTabIdByWorktree: { [WT]: TERMINAL }, + activeTabTypeByWorktree: { [WT]: 'terminal' }, + activeTabId: TERMINAL, + activeTabType: 'terminal' + }) +}) + +afterEach(() => { + resetLocalStructuredSessionVersionForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +describe('chat listing for a frame-drawn workspace', () => { + it('keeps the tabs main drew and takes the host title as the drawn chat label', () => { + const drawn = structure(useAppStore.getState()) + + applyStructuredSessionTabSnapshots([listing()]) + + const after = useAppStore.getState() + expect(after.unifiedTabsByWorktree[WT]?.find((t) => t.id === CHAT)?.label).toBe( + 'Renamed by host' + ) + const relabelled = { + ...drawn, + unifiedTabsByWorktree: { + [WT]: (drawn.unifiedTabsByWorktree?.[WT] ?? []).map((t) => + t.id === CHAT ? { ...t, label: 'Renamed by host' } : t + ) + } + } + expect(structure(after)).toEqual(relabelled) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts new file mode 100644 index 00000000000..cffe1fb0cf3 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts @@ -0,0 +1,31 @@ +// This machine's chat listing in a layout-owner build: main places and removes a listed chat's tab +// in its own layout, and its frames draw it, so the listing changes no tab-bar structure of a +// workspace frames draw. It still updates what a drawn chat tab shows (its label from the host). + +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' + +export type DrawnChatLabel = { tabId: string; label: string } + +/** The host's chat titles that differ from the labels on the window's drawn chat tabs. */ +export function drawnChatLabelChanges( + drawnTabs: readonly Tab[], + snapshot: RuntimeMobileSessionTabsResult +): DrawnChatLabel[] { + const changes: DrawnChatLabel[] = [] + for (const listed of snapshot.tabs) { + if (listed.type !== 'agent-session') { + continue + } + const drawn = drawnTabs.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === listed.sessionId + ) + // Same rule as the mirror's label, so a drawn tab reads as an undrawn one would. + const label = listed.title?.trim() || defaultAgentChatLabel(listed.agent) + if (drawn && drawn.label !== label) { + changes.push({ tabId: drawn.id, label }) + } + } + return changes +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts index f369c5a1089..83d60627afe 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts @@ -92,7 +92,7 @@ export function refreshLocalStructuredSessionTabs( export async function refreshLocalStructuredSessionWorktreeTabs( worktreeId: string, options: { reacceptCurrentVersion?: boolean } = {} -): Promise { +): Promise { // Structured chat can be switched off mid-call, which wipes the mirror; a late answer must not re-seed it. const expectedGeneration = localStructuredSessionGeneration() const snapshot = await callRuntimeRpc( @@ -100,7 +100,9 @@ export async function refreshLocalStructuredSessionWorktreeTabs( 'session.tabs.list', { worktree: toRuntimeWorktreeSelector(worktreeId) } ) - if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { - applyStructuredSessionTabSnapshots([snapshot], undefined, options) + if (!isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { + return null } + applyStructuredSessionTabSnapshots([snapshot], undefined, options) + return snapshot } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 3f15e082a42..8ee8941f4bd 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -33,9 +33,10 @@ import { import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' -import { useAppStore } from '../../store' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { recordSyncedAgentSessionTabsInLayoutOwner } from '../agent-session-tab-layout-owner' +import { useAppStore } from '../../store' +import { framesDrawWorkspace } from '../workspace-layout-frame-drawn-workspaces' +import { drawnChatLabelChanges, type DrawnChatLabel } from './frame-drawn-chat-tabs' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -60,6 +61,10 @@ export type StructuredSessionSnapshotApplyOptions = { onRetiredEpochDrop?: (worktreeId: string, publicationEpoch: string) => void /** Collects accepted publications so lifecycle listeners run after the store patch settles. */ onAcceptedAgentSession?: (worktreeId: string, sessionId: string) => void + /** Workspaces main's frames draw: the listing writes none of their tabs, only collects labels. */ + framesDraw?: (worktreeId: string) => boolean + /** Collects host titles for drawn chat tabs, applied as content after the store patch. */ + onDrawnChatLabel?: (change: DrawnChatLabel) => void } export function applyStructuredSessionTabSnapshots( @@ -68,7 +73,7 @@ export function applyStructuredSessionTabSnapshots( options: StructuredSessionSnapshotApplyOptions = {} ): void { const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] - const before = ORCA_LAYOUT_OWNER_IS_MAIN ? useAppStore.getState() : null + const drawnChatLabels: DrawnChatLabel[] = [] const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, { @@ -76,18 +81,17 @@ export function applyStructuredSessionTabSnapshots( onAcceptedAgentSession: (worktreeId, sessionId) => { acceptedAgentSessions.push({ worktreeId, sessionId }) options.onAcceptedAgentSession?.(worktreeId, sessionId) - } + }, + // Switch point: main places the listed chats' tabs; its frames draw them. + framesDraw: ORCA_LAYOUT_OWNER_IS_MAIN ? framesDrawWorkspace(state) : undefined, + onDrawnChatLabel: (change) => drawnChatLabels.push(change) }), { frames: [] } ) settleStructuredSessionMirror() - // Switch point: main records the chat tabs this listing added to or removed from the tab bars. - if (ORCA_LAYOUT_OWNER_IS_MAIN && before) { - recordSyncedAgentSessionTabsInLayoutOwner( - before, - useAppStore.getState(), - snapshots.map((snapshot) => snapshot.worktree) - ) + const { setTabLabel } = useAppStore.getState() + for (const { tabId, label } of drawnChatLabels) { + setTabLabel(tabId, label) } markStructuredAgentSessionLaunchesPublished(LOCAL_EXECUTION_HOST_ID, acceptedAgentSessions) if (options.authoritative) { @@ -167,6 +171,7 @@ export function applyLocalStructuredSessionTabSnapshots< options.onAcceptedAgentSession?.(snapshot.worktree, tab.sessionId) } } + // Runs for a drawn workspace too: its tab-id, focus and close-intent ledgers still follow the host. const patch = applyWebSessionTabsSnapshot( next, projectLocalStructuredSessionTabs(effectiveSnapshot), @@ -178,7 +183,17 @@ export function applyLocalStructuredSessionTabSnapshots< terminalPtyMode: 'local' } ) - next = patch === next ? next : ({ ...next, ...patch } as State) + if (options.framesDraw?.(snapshot.worktree)) { + // Main's frames own this workspace's tabs, so the patch (tab structure only here) is dropped. + for (const change of drawnChatLabelChanges( + next.unifiedTabsByWorktree[snapshot.worktree] ?? [], + effectiveSnapshot + )) { + options.onDrawnChatLabel?.(change) + } + } else { + next = patch === next ? next : ({ ...next, ...patch } as State) + } if (isWorktreeRetraction(effectiveSnapshot)) { // A retraction was applied above — the mirrored rows must go — but it is not a publication // to fence later frames against. Recording it would retire the renderer's own epoch, which diff --git a/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts b/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts new file mode 100644 index 00000000000..f559479123d --- /dev/null +++ b/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts @@ -0,0 +1,204 @@ +// Diff and markdown-preview rows: main's projection of its layout model (the window's session plus +// the editor views the window publishes) must equal the rows the window builds for the same tabs. + +import { describe, expect, it, vi } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' +import type { LayoutEditorView } from '../../../shared/workspace-layout/layout-editor-view' +import { projectSessionTabs } from '../../../shared/workspace-layout/old-client/old-client-session-tab-rows' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { + addWorkspace, + emptySession, + GIT_KEY +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import type { OpenFile } from '../store/slices/editor' +import { layoutEditorViewOfOpenFile } from '../store/slices/editor/tabs/open-file-layout-view' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { makeState } from './sync-runtime-graph-test-harness' + +// Main's projection runs on the layout-owner path only. +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const README = '/Users/dev/orca/README.md' +const SERVER_DOC = '/srv/app/GUIDE.md' +const SERVER_DOC_ID = ownedEditorFileId(SERVER_DOC, GIT_KEY, 'env-1') + +const files: OpenFile[] = [ + { + id: README, + filePath: README, + relativePath: 'README.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'edit', + isDirty: true + }, + { + id: SERVER_DOC_ID, + filePath: SERVER_DOC, + relativePath: 'GUIDE.md', + worktreeId: GIT_KEY, + language: 'markdown', + runtimeEnvironmentId: 'env-1', + mode: 'edit', + isDirty: false + }, + { + id: `markdown-preview::${README}`, + filePath: README, + relativePath: 'README.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'markdown-preview', + markdownPreviewSourceFileId: README, + isDirty: false + }, + { + id: `markdown-preview::${SERVER_DOC_ID}`, + filePath: SERVER_DOC, + relativePath: 'GUIDE.md', + worktreeId: GIT_KEY, + language: 'markdown', + runtimeEnvironmentId: 'env-1', + mode: 'markdown-preview', + markdownPreviewSourceFileId: SERVER_DOC_ID, + isDirty: false + }, + // Its source tab is closed: the preview is its own source. + { + id: 'markdown-preview::/Users/dev/orca/CHANGELOG.md', + filePath: '/Users/dev/orca/CHANGELOG.md', + relativePath: 'CHANGELOG.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'markdown-preview', + markdownPreviewSourceFileId: '/Users/dev/orca/CHANGELOG.md', + isDirty: false + }, + { + id: `${GIT_KEY}::diff::unstaged::src/app.ts`, + filePath: '/Users/dev/orca/src/app.ts', + relativePath: 'src/app.ts', + worktreeId: GIT_KEY, + language: 'typescript', + mode: 'diff', + diffSource: 'unstaged', + isDirty: false + }, + { + id: `${GIT_KEY}::diff::branch::src/lib.ts`, + filePath: '/Users/dev/orca/src/lib.ts', + relativePath: 'src/lib.ts', + worktreeId: GIT_KEY, + language: 'typescript', + mode: 'diff', + diffSource: 'branch', + isDirty: false + }, + { + id: `${GIT_KEY}::all-diffs::uncommitted`, + filePath: '/Users/dev/orca', + relativePath: 'All Changes', + worktreeId: GIT_KEY, + language: 'plaintext', + mode: 'diff', + diffSource: 'combined-all', + isDirty: false + } +] + +const tabIdOf = (file: OpenFile): string => `tab:${file.id}` + +/** The window's session for `files`, one tab each in one group, and the views it publishes. */ +function scenario(activeFileId: string) { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-1', + activeTabId: tabIdOf(files.find((file) => file.id === activeFileId)!), + tabs: files.map((file) => ({ + id: tabIdOf(file), + entityId: file.id, + kind: file.mode === 'diff' ? ('diff' as const) : ('editor' as const), + entry: { color: file.mode === 'diff' ? '#00ff00' : null } + })) + } + ]) + const editFiles = files.filter((file) => file.mode === 'edit') + session.openFilesByWorktree = { + [GIT_KEY]: editFiles.map((file) => ({ + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: GIT_KEY, + language: file.language, + ...(file.runtimeEnvironmentId ? { runtimeEnvironmentId: file.runtimeEnvironmentId } : {}), + ...(file.isDirty ? { dirtyDraftContent: '# draft' } : {}) + })) + } + const views: Record = {} + for (const file of files) { + const view = layoutEditorViewOfOpenFile(file) + if (view) { + views[tabIdOf(file)] = view + } + } + const windowState = makeState({ + openFiles: files, + unifiedTabsByWorktree: session.unifiedTabs!, + groupsByWorktree: session.tabGroups!, + layoutByWorktree: session.tabGroupLayouts!, + activeGroupIdByWorktree: { [GIT_KEY]: 'group-1' }, + activeFileId, + activeFileIdByWorktree: { [GIT_KEY]: activeFileId }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [GIT_KEY]: 'editor' } + }) + const loaded = loadWorkspaceLayoutWithoutReport(LOCAL_EXECUTION_HOST_ID, session, { + ...nameBasedLoadContext(), + editorViewsOf: (key) => (key === GIT_KEY ? views : undefined) + }) + const main = projectSessionTabs( + GIT_KEY, + loaded.layout.workspaces[GIT_KEY]!, + loaded.desktopView, + loaded.facts, + false + ) + const window = buildMobileSessionTabSnapshots(windowState).find( + (snapshot) => snapshot.worktree === GIT_KEY + )! + return { main, window } +} + +/** What both sides publish, minus what only the runtime stamps. */ +function published(snapshot: Pick) { + return { tabs: snapshot.tabs, tabGroups: snapshot.tabGroups } +} + +describe("main's diff and markdown-preview rows", () => { + it.each([ + ['a diff', `${GIT_KEY}::diff::unstaged::src/app.ts`], + ['a markdown preview', `markdown-preview::${README}`] + ])("equal the window's rows with %s active", (_label, activeFileId) => { + const { main, window } = scenario(activeFileId) + // Every file but the combined diff, which neither side lists. + expect(window.tabs).toHaveLength(files.length - 1) + expect(published(main)).toEqual(published(window)) + expect(main.activeTabId).toBe(window.activeTabId) + expect(main.activeTabType).toBe(window.activeTabType) + }) + + it('links a preview to its source by the open-file id, owned ids included', () => { + const { main } = scenario(`markdown-preview::${README}`) + const previews = main.tabs.filter((tab) => tab.type === 'markdown' && tab.mode !== 'edit') + expect( + previews.map((tab) => tab.type === 'markdown' && [tab.sourceFileId, tab.isDirty]) + ).toEqual([ + [README, true], + [SERVER_DOC_ID, false], + ['markdown-preview::/Users/dev/orca/CHANGELOG.md', false] + ]) + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 90700044922..6c7cd06bef8 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -83,7 +83,9 @@ export function retireStructuredAgentSessionTab(args: { function reacceptHostSessionTabs( target: RuntimeClientTarget, worktreeId: string, - intentOwner: WebSessionIntentOwner + intentOwner: WebSessionIntentOwner, + /** This machine's host still lists the chat. */ + stillListed?: { sessionId: string; reopen: () => void } ): void { // Lazy imports: both refresh paths apply frames through this module. const reread = @@ -97,8 +99,19 @@ function reacceptHostSessionTabs( }) ) : import('./local-structured-session-tabs-sync/inventory-refresh').then( - ({ refreshLocalStructuredSessionWorktreeTabs }) => - refreshLocalStructuredSessionWorktreeTabs(worktreeId, { reacceptCurrentVersion: true }) + async ({ refreshLocalStructuredSessionWorktreeTabs }) => { + const listed = await refreshLocalStructuredSessionWorktreeTabs(worktreeId, { + reacceptCurrentVersion: true + }) + if ( + stillListed && + listed?.tabs.some( + (tab) => tab.type === 'agent-session' && tab.sessionId === stillListed.sessionId + ) + ) { + stillListed.reopen() + } + } ) reread.catch((error: unknown) => console.warn('[structured-agent-session] tab re-read after a close failed', error) @@ -113,6 +126,8 @@ export function beginStructuredAgentSessionTabClose(args: { provisional: boolean layoutOwner: StructuredLaunchSlotOwner onError?: (error: unknown) => void + /** Main dropped the tab before the host answered: a host still listing the chat shows it again. */ + reopen?: () => void }): void { if (args.provisional) { markStructuredAgentSessionLaunchCancelled( @@ -144,7 +159,14 @@ export function beginStructuredAgentSessionTabClose(args: { // needs a read; a paired server's frames travel apart from its answer, so, as for its terminal // closes, every close is followed by a read. if (!hostTabClosed || args.target.kind === 'environment') { - reacceptHostSessionTabs(args.target, args.worktreeId, intentOwner) + reacceptHostSessionTabs( + args.target, + args.worktreeId, + intentOwner, + !hostTabClosed && args.reopen + ? { sessionId: args.sessionId, reopen: args.reopen } + : undefined + ) } }) } diff --git a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts index a647d207fdd..6e907026017 100644 --- a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts +++ b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts @@ -42,7 +42,6 @@ import { markStructuredAgentSessionLaunchCancelled, resetStructuredAgentLaunchRegistryForTests, setStructuredLaunchState, - subscribeStructuredAgentLaunchStatus, type StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry' import { @@ -50,6 +49,7 @@ import { resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' +import { wasStructuredAgentLaunchClosedByLocalCancel } from '@/lib/structured-agent-session-local-launch-cancel' import { acceptPairedHostStructuredSessions, beginStructuredAgentSessionTabClose, @@ -184,19 +184,24 @@ describe('this machine, with main owning the layout', () => { expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) }) - it('a main that cannot be told gets the tombstone, so a late listing is still hidden', async () => { + // Main notes the close before anything in it can fail, so its fence refuses the late create and + // no chat is published to draw (structured-agent-session-close-before-create.test.ts). + it('a retire main fails is logged; no tombstone, and a late create reply revives nothing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.closeSession.mockRejectedValueOnce(new Error('host unavailable')) pendingLaunch('local', LOCAL) - const listener = vi.fn() - subscribeStructuredAgentLaunchStatus(listener) markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local', layoutOwner) await vi.waitFor(() => - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] local cancel retirement failed', + expect.any(Error) + ) ) - expect(listener).toHaveBeenCalledTimes(2) - expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toEqual([]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + expect(wasStructuredAgentLaunchClosedByLocalCancel(SESSION)).toBe(true) + expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toHaveLength(1) }) it('switch off: keeps the tombstone and leaves the close to the tab close, as today', () => { diff --git a/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts index e16a12392c3..283845f9340 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '../lib/agent-status' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '../store/slices/tabs-drawn-from-main.test-fixture' vi.mock('@/lib/doc-preview-grants', () => ({ releaseDocPreviewGrant: vi.fn(), @@ -14,6 +18,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; its frames draw the browser tabs and conversions. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { kind: 'workspace-doc' as const, @@ -21,7 +37,18 @@ const DOC_LOCATION = { filePath: '/home/alice/wt1/report/index.html' } -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** A layout-owner build draws each open and conversion once main's frame is in. */ +async function settled(): Promise { + await main?.settle() +} + +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -30,6 +57,7 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main = await drawTabsFromMain(store) return store } @@ -86,8 +114,8 @@ function publishedUnifiedTabIds(store: ReturnType): Set< // web page. Driven through the real store actions, not a hand-built state, so the mirror path the // conversion writes is the one the publisher reads. describe('mobile publish across an address-bar conversion', () => { - it('starts publishing a doc tab the moment it converts to web, and stops on the way back', () => { - const store = createStoreWithWorktree() + it('starts publishing a doc tab the moment it converts to web, and stops on the way back', async () => { + const store = await createStoreWithWorktree() // Presence precondition: an ordinary URL tab publishes throughout, so an empty answer would // fail rather than pass by the publisher being broken for browser tabs entirely. const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') @@ -96,6 +124,7 @@ describe('mobile publish across an address-bar conversion', () => { title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) @@ -105,6 +134,7 @@ describe('mobile publish across an address-bar conversion', () => { url: 'https://converted.example/' }) expect(webPage).not.toBeNull() + await settled() expect(publishedBrowserWorkspaceIds(store).sort()).toEqual([urlTab.id, docTab.id].sort()) const docPage = store.getState().convertBrowserPage(webPage?.id ?? '', { @@ -112,15 +142,17 @@ describe('mobile publish across an address-bar conversion', () => { docLocation: DOC_LOCATION }) expect(docPage).not.toBeNull() + await settled() expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) }) // Why groups and the layout are driven separately: the group projection was the historically // untested half of the publish boundary, and a doc tab surviving in a group's tabOrder names a // tab the phone is never sent. - it('holds the doc tab out of published groups and the layout, and admits it on conversion', () => { - const store = createStoreWithWorktree() + it('holds the doc tab out of published groups and the layout, and admits it on conversion', async () => { + const store = await createStoreWithWorktree() const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() // The doc tab gets a split group of its own, so holding it back must also take its group out // of the projection AND its leaf out of the layout — the observable no earlier test drove. const sourceGroupId = store.getState().activeGroupIdByWorktree[WORKTREE_ID] ?? '' @@ -133,6 +165,7 @@ describe('mobile publish across an address-bar conversion', () => { browserRuntimeEnvironmentId: null, targetGroupId: splitGroupId ?? undefined }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' const docUnifiedTabId = (store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).find( @@ -163,6 +196,7 @@ describe('mobile publish across an address-bar conversion', () => { url: 'https://converted.example/' }) expect(webPage).not.toBeNull() + await settled() expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(true) expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(true) expect(layoutGroupIds().sort()).toEqual([sourceGroupId, splitGroupId ?? ''].sort()) @@ -173,6 +207,7 @@ describe('mobile publish across an address-bar conversion', () => { docLocation: DOC_LOCATION }) expect(docPage).not.toBeNull() + await settled() expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(false) expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(false) expect(layoutGroupIds()).toEqual([sourceGroupId]) diff --git a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts index 468da77a8d3..d2d5f131cb7 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts @@ -6,7 +6,6 @@ import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal import type { AppState } from '@/store/types' import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree' import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' -import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds' import { isTabPaneTreeHeld } from './layout-owner-pane-intents' @@ -71,21 +70,3 @@ export function terminalLayoutsWithFrameBindings( } return next } - -/** Every rebound workspace's bindings shown, and the workspaces only a build shows so. */ -export function terminalLayoutsWithReboundFrames( - layouts: TerminalLayouts, - cache: WorkspaceLayoutCache, - rebound: ReadonlyMap> -): { layouts: TerminalLayouts; unshown: string[] } { - const unshown: string[] = [] - for (const [key, tabIds] of rebound) { - const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds) - if (next) { - layouts = next - } else { - unshown.push(key) - } - } - return { layouts, unshown } -} diff --git a/src/renderer/src/runtime/workspace-layout-frame-builds.ts b/src/renderer/src/runtime/workspace-layout-frame-builds.ts index 33d3fa2e888..e5a529dfe26 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-builds.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-builds.ts @@ -23,6 +23,7 @@ import type { } from '../../../shared/workspace-layout/workspace-layout-beside' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { withPendingTabFacts } from './layout-owner-pending-tab-facts' /** What the window's own commands hold as drawn; see buildLayoutFrameStorePatch. */ export type LayoutFrameBuildHolds = { @@ -45,7 +46,8 @@ function patchFromSlices( ): LayoutFrameBuildPatch { const built = buildLayoutFrameSlices({ layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])), - facts, + // A tab this window asked main to open draws with the window's facts for it. + facts: withPendingTabFacts(facts, keys), view }) return buildLayoutFrameStorePatch( diff --git a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts index 45ee2e4d34c..f4a7424942a 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts @@ -17,8 +17,6 @@ import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test- import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' -import { recordOpenedAgentSessionTabInLayoutOwner } from './agent-session-tab-layout-owner' -import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner' import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props' import { endLayoutFramePositions } from './layout-frame-position' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' @@ -237,61 +235,7 @@ describe('a tab the window drew, closed or moved, with a frame from before main sync.close() }) - it.each([ - [ - 'chat', - (store: ReturnType) => { - const tab = drawTab(store, { - id: 'agent-session-new', - contentType: 'agent-session', - agentSessionAgent: 'claude' - }) - recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab) - return tab - } - ], - [ - 'simulator', - (store: ReturnType) => { - const tab = drawTab(store, { id: 'simulator-new' }) - recordOpenedSimulatorTabInLayoutOwner(store.getState(), tab) - return tab - } - ] - ])('keeps a just-opened %s tab', async (_kind, open) => { - const { store, sync, before } = await boot() - const tab = open(store) - expect(rpc.calls.length).toBeGreaterThan(0) - - sendStaleFrame(before) - await settle() - expect(tabBar(store)).toContain(tab.id) - expect(groupOrder(store, 'group-b')).toContain(tab.id) - if (tab.contentType === 'editor') { - expect(store.getState().openFiles.map((file) => file.id)).toContain(tab.entityId) - } - sync.close() - }) - - it('keeps a just-opened browser tab and its page', async () => { - const { store, sync, before } = await boot() - const created = store.getState().createBrowserTab(GIT_KEY, 'https://example.com/', { - title: 'Example' - }) - const entry = store - .getState() - .unifiedTabsByWorktree[GIT_KEY]!.find((tab) => tab.entityId === created.id)! - expect(rpc.calls.some((call) => call.command.type === 'openBrowserTab')).toBe(true) - - sendStaleFrame(before) - await settle() - expect(tabBar(store)).toContain(entry.id) - expect(store.getState().browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain( - created.id - ) - expect(store.getState().browserPagesByWorkspace[created.id]?.length).toBeGreaterThan(0) - sync.close() - }) + // Browser, chat and simulator opens draw from main's frame alone: content-tabs-from-main-frames.test.ts. it('keeps a reorder the window made, and main’s order for the rest', async () => { const { store, sync, before, answer } = await boot() diff --git a/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts b/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts index c46371fea5d..2abdadcaa13 100644 --- a/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts +++ b/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness' +import { createBrowserMockApi } from './browser-slice-test-harness' import { createEditorTabsStore } from './editor-slice-test-harness' import { createTestStore as createAppStore, makeWorktree, seedStore } from './store-test-helpers' import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' @@ -127,12 +127,17 @@ describe('selection in a workspace other than the active one', () => { expect(store.getState().activeFileId).toBe(fileId) }) - it('records a browser selection in its own workspace without moving the global surface', () => { - const store = createTestStore() + it('records a browser selection in its own workspace without moving the global surface', async () => { + const store = createAppStore() + seedStore(store, { activeWorktreeId: 'wt-1' }) + // A layout-owner build draws the tabs from main's frame before they can be selected. + const main = await drawTabsFromMain(store) const mainTab = store.getState().createBrowserTab('wt-1', 'https://main.example.com') const floatingTab = store .getState() .createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'https://floating.example.com') + await main.settle() + main.close() store.getState().setActiveBrowserTab(mainTab.id) store.setState({ activeTabType: 'editor' }) @@ -145,9 +150,13 @@ describe('selection in a workspace other than the active one', () => { expect(state.activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('browser') }) - it('still moves the global browser surface for the active workspace', () => { - const store = createTestStore() + it('still moves the global browser surface for the active workspace', async () => { + const store = createAppStore() + seedStore(store, { activeWorktreeId: 'wt-1' }) + const main = await drawTabsFromMain(store) const tab = store.getState().createBrowserTab('wt-1', 'https://main.example.com') + await main.settle() + main.close() store.setState({ activeTabType: 'editor' }) store.getState().setActiveBrowserTab(tab.id) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index c773d180605..3cb5a35f7bc 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -4,7 +4,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { flushMicrotasks } from './agent-status-test-harness' -import { createTestStore, makeTab, makeUnifiedTab } from './store-test-helpers' +import { createTestStore, makeTab, makeTabGroup, makeUnifiedTab } from './store-test-helpers' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from './agent-status' import { @@ -13,10 +13,34 @@ import { } from '../../../../shared/structured-agent-session-projection' import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' import { createStoreSessionMockApi } from './store-session-test-harness' +import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: vi.fn() })) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +/** Resolves `pending` under fake timers by running the timer ticks main's drain waits on (a 0 ms timer fires at 1 ms). */ +async function underFakeTimers(pending: Promise): Promise { + let done = false + void pending.finally(() => { + done = true + }) + for (let turn = 0; turn < 50 && !done; turn += 1) { + await vi.advanceTimersByTimeAsync(1) + } + return pending +} beforeEach(() => { // Track the original global before the shared fixture installs the renderer API. @@ -417,7 +441,7 @@ afterEach(() => { it.each(['wt-1', 'folder-workspace-1'])( 'preserves an open chat and its read state when an unrelated terminal closes in %s', - (worktreeId) => { + async (worktreeId) => { vi.useFakeTimers() const store = createTestStore() const sessionId = 'chat-1' @@ -441,7 +465,19 @@ afterEach(() => { makeTab({ id: 'tab-live', worktreeId }) ] }, - unifiedTabsByWorktree: { [worktreeId]: [chat] } + unifiedTabsByWorktree: { [worktreeId]: [chat] }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: 'group-1', + worktreeId, + activeTabId: chat.id, + tabOrder: [chat.id] + }) + ] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [worktreeId]: 'group-1' } }) for (const key of [paneKey, missingOwnerPaneKey]) { store @@ -459,6 +495,7 @@ afterEach(() => { activityClearedAtByPaneKey: { [paneKey]: 100, [missingOwnerPaneKey]: 100 }, manuallyUnreadTurnsByPaneKey: { [paneKey]: 200, [missingOwnerPaneKey]: 200 } }) + const main = await underFakeTimers(drawTabsFromMain(store)) const before = store.getState() store.getState().closeTab('tab-closed') @@ -476,7 +513,9 @@ afterEach(() => { expect(after.manuallyUnreadTurnsByPaneKey[missingOwnerPaneKey]).toBeUndefined() store.getState().closeUnifiedTab(chat.id) + await underFakeTimers(main.settle()) store.getState().closeTab('tab-live') + main.close() const closed = store.getState() expect(closed.unifiedTabsByWorktree[worktreeId]).toEqual([]) diff --git a/src/renderer/src/store/slices/browser-annotation-retention.test.ts b/src/renderer/src/store/slices/browser-annotation-retention.test.ts index d469a8f9832..e3603a3bf29 100644 --- a/src/renderer/src/store/slices/browser-annotation-retention.test.ts +++ b/src/renderer/src/store/slices/browser-annotation-retention.test.ts @@ -1,30 +1,43 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { GRAB_BUDGET } from '../../../../shared/browser-grab-types' -import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createBrowserMockApi } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' import { makeAnnotation } from './browser-annotation-test-fixture' +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: vi.fn() })) -function createAnnotatedPage() { +async function createAnnotatedPage() { vi.stubGlobal('window', { api: createBrowserMockApi(vi.fn()) }) - const store = createTestStore() - store.setState({ repos: [], folderWorkspaces: [] }) + const { store, main } = await createTestStoreDrawnFromMain({ repos: [], folderWorkspaces: [] }) const workspace = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = workspace.activePageId if (!pageId) { throw new Error('Expected browser page') } store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) - return { store, workspace, pageId } + return { store, main, workspace, pageId } } afterEach(() => vi.unstubAllGlobals()) describe('saved browser note lifetime and marker eligibility', () => { - it('retires same-URL geometry without changing the saved array or object references', () => { - const { store, pageId } = createAnnotatedPage() + it('retires same-URL geometry without changing the saved array or object references', async () => { + const { store, pageId } = await createAnnotatedPage() const saved = store.getState().browserAnnotationsByPageId[pageId] const note = saved[0] expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id]) @@ -47,8 +60,8 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh']) }) - it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', () => { - const { store, pageId } = createAnnotatedPage() + it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', async () => { + const { store, pageId } = await createAnnotatedPage() for (let index = 0; index < GRAB_BUDGET.annotationsMaxPerPage + 3; index++) { const url = `https://example.com/page-${index % 2}` store.getState().setBrowserPageUrl(pageId, url) @@ -70,8 +83,8 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() }) - it('prunes eligible IDs when the oldest current-document notes are evicted', () => { - const { store, pageId } = createAnnotatedPage() + it('prunes eligible IDs when the oldest current-document notes are evicted', async () => { + const { store, pageId } = await createAnnotatedPage() for (let index = 0; index < 22; index++) { store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, `note-${index}`)) } @@ -82,7 +95,7 @@ describe('saved browser note lifetime and marker eligibility', () => { }) it('removes an accepted delivery snapshot after navigation while preserving later edits and additions', async () => { - const { store, pageId } = createAnnotatedPage() + const { store, pageId } = await createAnnotatedPage() store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'edited')) const delivered = store.getState().browserAnnotationsByPageId[pageId] let accept!: () => void @@ -110,37 +123,44 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) - it.each(['page', 'workspace', 'conversion', 'hydration'] as const)( - 'cleans up note and marker authority on %s teardown', - (mode) => { - const { store, workspace, pageId } = createAnnotatedPage() - if (mode === 'page') { - store.getState().closeBrowserPage(pageId) - } - if (mode === 'workspace') { - store.getState().closeBrowserTab(workspace.id) - } - if (mode === 'conversion') { - store.getState().convertBrowserPage(pageId, { - kind: 'workspace-doc', - docLocation: { - kind: 'workspace-doc', - worktreeId: 'wt-1', - filePath: '/workspace/index.html' - } - }) - } - if (mode === 'hydration') { - store.getState().hydrateBrowserSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - }) - } - expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() - expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + it.each([ + ['page', 'closeBrowserPage'], + ['workspace', 'closeTabs'], + ['conversion', 'replaceBrowserPage'], + ['hydration', null] + ] as const)('cleans up note and marker authority on %s teardown', async (mode, mainCommand) => { + const { store, main, workspace, pageId } = await createAnnotatedPage() + if (mode === 'page') { + store.getState().closeBrowserPage(pageId) } - ) + if (mode === 'workspace') { + store.getState().closeBrowserTab(workspace.id) + } + if (mode === 'conversion') { + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/workspace/index.html' + } + }) + } + if (mode === 'hydration') { + store.getState().hydrateBrowserSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + } + await main.settle() + // A layout-owner build's teardown is main's, drawn from its frame. + expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? ['openBrowserTab', ...(mainCommand ? [mainCommand] : [])] : [] + ) + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + }) }) diff --git a/src/renderer/src/store/slices/browser-cleanup-close.test.ts b/src/renderer/src/store/slices/browser-cleanup-close.test.ts index ca9eaa38e6a..8940efee5dd 100644 --- a/src/renderer/src/store/slices/browser-cleanup-close.test.ts +++ b/src/renderer/src/store/slices/browser-cleanup-close.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { + layoutFrameApplyObserver, + observeLayoutFrameApply +} from '@/runtime/workspace-layout-frame-apply-observer' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mockCallRuntimeRpc = vi.hoisted(() => vi.fn()) @@ -20,32 +25,53 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; the paired runtime's calls reach the mock. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, callRuntimeRpc: mockCallRuntimeRpc } + const { runtimeRpcClientWithLayoutApply } = + await import('@/runtime/layout-apply-test-transport.test-fixture') + return runtimeRpcClientWithLayoutApply({ ...actual, callRuntimeRpc: mockCallRuntimeRpc }) }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) createStoreCascadesMockApi() const WT = 'repo1::/path/wt1' +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */ +async function settled(): Promise { + await main?.settle() +} + /** Seeds a worktree whose only tab is a remote-backed browser workspace. */ -function storeWithOnlyBrowserTab(): { +async function storeWithOnlyBrowserTab(): Promise<{ store: ReturnType workspaceId: string pageId: string -} { +}> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) const workspace = store.getState().createBrowserTab(WT, 'about:blank', { activate: true, browserPageId: 'remote-page-1', browserRuntimeEnvironmentId: 'env-1' }) + await settled() const pageId = store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? '' store.getState().setRemoteBrowserPageHandle(pageId, { environmentId: 'env-1', @@ -56,20 +82,22 @@ function storeWithOnlyBrowserTab(): { } /** Three browser tabs in a known tab-bar order, so neighbor selection is deterministic. */ -function storeWithThreeBrowserTabs(): { +async function storeWithThreeBrowserTabs(): Promise<{ store: ReturnType ids: string[] -} { +}> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) const ids = [0, 1, 2].map( (index) => store.getState().createBrowserTab(WT, `https://example.test/${index}`, { activate: true }).id ) + await settled() return { store, ids } } @@ -81,12 +109,14 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: a failed create must land the user where the click did — the pre-click worktree — not on // the landing screen the real "you closed your last tab" path deliberately falls back to. - it('keeps the worktree active when it unwinds the only tab in that worktree', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('keeps the worktree active when it unwinds the only tab in that worktree', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() expect(store.getState().activeWorktreeId).toBe(WT) store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + const s = store.getState() expect(s.activeWorktreeId).toBe(WT) expect(s.browserTabsByWorktree[WT] ?? []).toEqual([]) @@ -95,29 +125,36 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: the landing-screen fallback is the correct answer for a real close, so pin that the // cleanup carve-out did not disable it for everyone. - it('still returns to the landing state when the user closes the only tab', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('still returns to the landing state when the user closes the only tab', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + expect(store.getState().activeWorktreeId).toBeNull() }) // Why: the staged page's host tab was never minted, so a tabClose would either 404 or, worse, // race the in-flight create and kill the page the host is about to hand back. - it('does not ask the host to close the page it never published', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not ask the host to close the page it never published', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + + // A layout-owner build's close goes to main as `layout.apply`, answered before this mock. expect(mockCallRuntimeRpc).not.toHaveBeenCalled() }) - it('asks the host to close the page on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('asks the host to close the page on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + expect(mockCallRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'browser.tabClose', @@ -128,21 +165,25 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: reopen-stack entries are the user's undo history; a tab that never existed must not // occupy a slot, and reopening it would resurrect a workspace with no host page behind it. - it('does not enter the reopen stack', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not enter the reopen stack', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + const s = store.getState() expect(s.recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([]) expect(s.recentlyClosedTabKindsByWorktree[WT] ?? []).toEqual([]) }) - it('enters the reopen stack on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('enters the reopen stack on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + const s = store.getState() expect(s.recentlyClosedBrowserTabsByWorktree[WT]).toHaveLength(1) expect(s.recentlyClosedTabKindsByWorktree[WT]).toEqual(['browser']) @@ -150,36 +191,56 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: feature-interaction counters drive onboarding nudges; an unwound create is not the user // exercising tabs. - it('does not record a tab interaction', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not record a tab interaction', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const recordFeatureInteraction = vi.fn() store.setState({ recordFeatureInteraction }) store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + expect(recordFeatureInteraction).not.toHaveBeenCalled() }) - it('records a tab interaction on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('records a tab interaction on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const recordFeatureInteraction = vi.fn() store.setState({ recordFeatureInteraction }) store.getState().closeBrowserTab(workspaceId) + await settled() + expect(recordFeatureInteraction).toHaveBeenCalledWith('terminal-tabs') }) - it('clears focus for closed pages without rescanning pages for unrelated focus entries', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('clears focus for closed pages without rescanning pages for unrelated focus entries', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const original = store.getState().browserPagesByWorkspace[workspaceId][0] let idReads = 0 + let counting = true const pages = Array.from({ length: 1000 }, (_, index) => ({ ...original, get id() { - idReads++ + if (counting) { + idReads++ + } return `closed-${index}` } })) + // The test transport's frame-apply check rebuilds every flush; its reads are not the close's. + const check = layoutFrameApplyObserver() + observeLayoutFrameApply( + check && + ((observation) => { + counting = false + try { + check(observation) + } finally { + counting = true + } + }) + ) const unrelated = Object.fromEntries( Array.from({ length: 1000 }, (_, i) => [`other-${i}`, true as const]) ) @@ -189,7 +250,12 @@ describe('closeBrowserTab with reason cleanup', () => { pendingAddressBarFocusByTabId: { ...unrelated, 'closed-999': true, [workspaceId]: true } }) idReads = 0 - store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + try { + store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + } finally { + observeLayoutFrameApply(check) + } expect(store.getState().pendingAddressBarFocusByPageId).toEqual({ ...unrelated, [workspaceId]: true @@ -199,8 +265,8 @@ describe('closeBrowserTab with reason cleanup', () => { }) // Why: the omit path must not hand every pending-focus selector a fresh record on each close. - it('keeps the pending focus records identical when the closed tab had no pending entry', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('keeps the pending focus records identical when the closed tab had no pending entry', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const pending = { 'other-tab': true as const } store.setState({ pendingAddressBarFocusByPageId: pending, @@ -209,18 +275,22 @@ describe('closeBrowserTab with reason cleanup', () => { store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + expect(store.getState().pendingAddressBarFocusByPageId).toBe(pending) expect(store.getState().pendingAddressBarFocusByTabId).toBe(pending) }) // Why: closing a tab the user is not looking at must never move focus, whatever the // pending-address-bar bookkeeping does around it. - it('leaves the active browser tab alone when a background tab closes', () => { - const { store, ids } = storeWithThreeBrowserTabs() + it('leaves the active browser tab alone when a background tab closes', async () => { + const { store, ids } = await storeWithThreeBrowserTabs() store.getState().setActiveBrowserTab(ids[0]!) store.getState().closeBrowserTab(ids[2]!) + await settled() + expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(ids[0]!) }) }) 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 index 5b3d7abc809..e9e7e4dcaa5 100644 --- a/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts +++ b/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts @@ -1,13 +1,24 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +// A layout-owner build's browser tab and page commands: the window sends main each change under +// the ids it proposes, and draws it from main's frame (content-tabs-from-main-frames.test.ts +// covers what shows before and after main answers). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' -import type { recordLayoutApply } from '@/runtime/layout-apply-send' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' - -const mocks = vi.hoisted(() => ({ send: vi.fn() })) +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) -vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) @@ -20,122 +31,120 @@ createStoreCascadesMockApi() const WT = 'repo1::/path/wt1' -function seeded(): ReturnType { - const store = createTestStore() +let store: ReturnType +let main: TabsDrawnFromMain + +beforeEach(async () => { + store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT }) - return store -} + main = await drawTabsFromMain(store) +}) -const sent = () => mocks.send.mock.calls.map(([, worktreeId, command]) => ({ worktreeId, command })) +afterEach(() => { + main.close() +}) -function entryOf(store: ReturnType, browserTabId: string) { +const sent = () => [...layoutApplyTestTransport.sent] + +function entryOf(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() + it('sends an open under the ids it proposes, which main’s frame then draws', async () => { 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) + const [firstOpen, secondOpen] = layoutApplyTestTransport.sentOfType('openBrowserTab') + await main.settle() - 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, - createdAt: firstEntry.createdAt, - browserCreatedAt: first.createdAt - } - }, - { - worktreeId: WT, - command: expect.objectContaining({ - type: 'openBrowserTab', - tabId: entryOf(store, second.id).id, - browserTabId: second.id, - afterTabId: firstEntry.id - }) - } + const firstEntry = entryOf(first.id) + expect(firstOpen).toEqual({ + type: 'openBrowserTab', + workspace: WT, + tabId: firstEntry.id, + browserTabId: first.id, + pageId: store.getState().browserPagesByWorkspace[first.id]![0]!.id, + profileId: 'p1', + sessionPartition: null, + groupId: firstEntry.groupId + }) + expect(secondOpen).toMatchObject({ tabId: entryOf(second.id).id, browserTabId: second.id }) + expect(store.getState().groupsByWorktree[WT]![0]!.tabOrder).toEqual([ + firstEntry.id, + entryOf(second.id).id ]) }) - it('records a close with the window’s reason while the tab-bar entry still names it', () => { - const store = seeded() + it('sends a close with the window’s reason while the tab still shows', async () => { 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() + await main.settle() + const userEntry = entryOf(user.id).id + const cleanupEntry = entryOf(cleanup.id).id + const before = sent().length store.getState().closeBrowserTab(user.id) store.getState().closeBrowserTab(cleanup.id, { reason: 'cleanup' }) + await main.settle() - expect(sent().map(({ command }) => command)).toEqual([ - { type: 'closeTabs', tabIds: [userEntry], recordClose: 'user' }, - { type: 'closeTabs', tabIds: [cleanupEntry], recordClose: 'cleanup' } + expect(sent().slice(before)).toEqual([ + { type: 'closeTabs', workspace: WT, tabIds: [userEntry], recordClose: 'user' }, + { type: 'closeTabs', workspace: WT, tabIds: [cleanupEntry], recordClose: 'cleanup' } ]) }) - it('records page create, convert and close, and a profile switch', () => { - const store = seeded() + it('sends page create, convert and close, and a profile switch', async () => { const tab = store.getState().createBrowserTab(WT, 'about:blank') - mocks.send.mockClear() + await main.settle() + const before = sent().length // 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' } })! + await main.settle() const converted = store.getState().convertBrowserPage(page.id, { kind: 'web', url: 'https://other.example/' })! + await main.settle() store.getState().closeBrowserPage(converted.id) + await main.settle() store.getState().switchBrowserTabProfile(tab.id, 'p2', 'persist:p2') + await main.settle() - expect(sent().map(({ command }) => command)).toEqual([ - { type: 'openBrowserPage', browserTabId: tab.id, pageId: page.id }, + expect(sent().slice(before)).toEqual([ + { type: 'openBrowserPage', workspace: WT, browserTabId: tab.id, pageId: page.id }, { type: 'replaceBrowserPage', + workspace: WT, browserTabId: tab.id, pageId: page.id, newPageId: converted.id }, - { type: 'closeBrowserPage', browserTabId: tab.id, pageId: converted.id }, + { type: 'closeBrowserPage', workspace: WT, browserTabId: tab.id, pageId: converted.id }, { type: 'setBrowserTabProfile', + workspace: WT, browserTabId: tab.id, profileId: 'p2', sessionPartition: 'persist:p2' } ]) - expect(sent().every(({ worktreeId }) => worktreeId === WT)).toBe(true) + expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1) }) 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() + expect(sent()).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts b/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts index 930f9bf7efd..753b7efabdb 100644 --- a/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts +++ b/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts @@ -3,10 +3,11 @@ * leaves the store. A bulk worktree purge used to drop the page records without releasing it. * The sweep is the same function useTerminalWindowLifecycle runs on every store change. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -27,6 +28,18 @@ vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOr destroyPersistentWebview: vi.fn(() => Promise.resolve()) })) +// Main answers the window's layout commands; its frames draw the browser tabs. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + createStoreCascadesMockApi() const { isBrowserPageMountAdmitted } = @@ -36,13 +49,21 @@ const { destroyWebviewsRemovedFromStore, destroyWorktreeBrowserGuests } = const WT = 'repo1::/path/wt1' -function storeWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined + +/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */ +async function settled(): Promise { + await main?.settle() +} + +async function storeWithWorktree(): Promise> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) return store } @@ -58,15 +79,18 @@ function sweepRemovedPages( ) } -function openBackgroundTabs(store: ReturnType, count: number): string[] { - const pageIds: string[] = [] +async function openBackgroundTabs( + store: ReturnType, + count: number +): Promise { + const workspaceIds: string[] = [] for (let index = 0; index < count; index += 1) { - const workspace = store - .getState() - .createBrowserTab(WT, `https://example.com/${index}`, { activate: false }) - pageIds.push(store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? '') + workspaceIds.push( + store.getState().createBrowserTab(WT, `https://example.com/${index}`, { activate: false }).id + ) } - return pageIds + await settled() + return workspaceIds.map((id) => store.getState().browserPagesByWorkspace[id]?.[0]?.id ?? '') } const admittedCount = (pageIds: readonly string[]): number => @@ -76,22 +100,27 @@ describe('browser page mount admission ends when the page leaves the store', () beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + main?.close() + main = undefined + }) - it('ends on a bulk worktree purge', () => { - const store = storeWithWorktree() - const pageIds = openBackgroundTabs(store, 5) + it('ends on a bulk worktree purge', async () => { + const store = await storeWithWorktree() + const pageIds = await openBackgroundTabs(store, 5) const before = sweepRemovedPages(store, new Set()) expect(admittedCount(pageIds)).toBe(5) store.getState().purgeWorktreeTerminalState([WT]) + await settled() sweepRemovedPages(store, before) expect(admittedCount(pageIds)).toBe(0) }) - it('ends when the guest is evicted, though the page stays in the store', () => { - const store = storeWithWorktree() - const pageIds = openBackgroundTabs(store, 3) + it('ends when the guest is evicted, though the page stays in the store', async () => { + const store = await storeWithWorktree() + const pageIds = await openBackgroundTabs(store, 3) const state = store.getState() expect(admittedCount(pageIds)).toBe(3) @@ -101,19 +130,22 @@ describe('browser page mount admission ends when the page leaves the store', () expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toHaveLength(3) }) - it('ends when the tab or the page is closed', () => { - const store = storeWithWorktree() - const [closedTabPage, keptTabPage] = openBackgroundTabs(store, 2) + it('ends when the tab or the page is closed', async () => { + const store = await storeWithWorktree() + const [closedTabPage, keptTabPage] = await openBackgroundTabs(store, 2) const keptWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[1]?.id ?? '' const extraPage = store.getState().createBrowserPage(keptWorkspaceId, 'https://example.com/x', { activate: false }) + await settled() let seen = sweepRemovedPages(store, new Set()) const closedWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[0]?.id ?? '' store.getState().closeBrowserTab(closedWorkspaceId) + await settled() seen = sweepRemovedPages(store, seen) store.getState().closeBrowserPage(extraPage?.id ?? '') + await settled() sweepRemovedPages(store, seen) expect(isBrowserPageMountAdmitted(closedTabPage)).toBe(false) diff --git a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts index 3e91acab288..9fc774ecf69 100644 --- a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts +++ b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -13,16 +14,34 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc } }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) -// Main's answer to the window's layout commands, as a switch-on build sends them. -mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) => - method === 'layout.apply' ? layoutApplyTestTransport.call(params) : undefined -) +/** The paired runtime's answer to the page close; main answers the window's layout commands. */ +function runtimeAnswers(answer: () => Promise | undefined): void { + mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) => + method === 'layout.apply' ? layoutApplyTestTransport.call(params) : answer() + ) +} +runtimeAnswers(() => undefined) + +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** Calls that reached the paired runtime, not main's layout. */ +const runtimeCalls = () => + mocks.callRuntimeRpc.mock.calls.filter(([, method]) => method !== 'layout.apply') const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { @@ -31,10 +50,10 @@ const DOC_LOCATION = { filePath: '/home/alice/wt1/report/index.html' } -function createStoreWithClientHostedPage(handleOverrides: Record = {}): { - store: ReturnType - pageId: string -} { +async function createStoreWithClientHostedPage( + handleOverrides: Record = {} +): Promise<{ store: ReturnType; pageId: string }> { + runtimeAnswers(() => undefined) const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -43,7 +62,11 @@ function createStoreWithClientHostedPage(handleOverrides: Record ({ remoteBrowserPageHandlesByPageId: { @@ -72,8 +95,8 @@ function recordedIntents(store: ReturnType): string[] { // so the reconnect replay can deliver the close late instead of the page coming back. describe('durable close intents for unheard client-hosted page closes', () => { it('records an intent when the conversion-away close never reaches the runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', @@ -85,8 +108,8 @@ describe('durable close intents for unheard client-hosted page closes', () => { }) it('records an intent when the plain page close never reaches the runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().closeBrowserPage(pageId) @@ -94,57 +117,61 @@ describe('durable close intents for unheard client-hosted page closes', () => { }) it('records nothing when the close succeeds', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockResolvedValue({}) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.resolve({})) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing when the runtime has definitively forgotten the page', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('browser_tab_not_found')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('browser_tab_not_found'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing for a server-placed page, which dies with its runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage({ placement: { kind: 'server' } }) - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage({ + placement: { kind: 'server' } + }) + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing for a staged page the runtime never minted', async () => { - const { store, pageId } = createStoreWithClientHostedPage({ staged: true }) - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage({ staged: true }) + // A staged page is left out of the window's save, so frames would redraw it from main's copy. + main?.close() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) diff --git a/src/renderer/src/store/slices/browser-page-conversion.test.ts b/src/renderer/src/store/slices/browser-page-conversion.test.ts index 7a0703b52d1..b584409ba44 100644 --- a/src/renderer/src/store/slices/browser-page-conversion.test.ts +++ b/src/renderer/src/store/slices/browser-page-conversion.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { browserPageSchema } from '../../../../shared/workspace-session-browser-schema' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn(), callRuntimeRpc: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -10,10 +12,21 @@ vi.mock('@/lib/doc-preview-grants', () => ({ ensureDocPreviewGrant: vi.fn(), buildDocPreviewGrantRequest: vi.fn() })) +// Main answers the window's layout commands (switch-on); every other call reaches the mock. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) } + const actual = await importOriginal() + const { runtimeRpcClientWithLayoutApply } = + await import('@/runtime/layout-apply-test-transport.test-fixture') + return runtimeRpcClientWithLayoutApply({ + ...actual, + callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) + }) }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -33,7 +46,18 @@ const OTHER_DOC_LOCATION = { } const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html` -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +async function settled(): Promise { + await main?.settle() +} + +/** A store showing one worktree, its tabs drawn from main once the switch is on. */ +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -42,31 +66,34 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main = await drawTabsFromMain(store) return store } -function createDocTab(store: ReturnType): { +async function createDocTab(store: ReturnType): Promise<{ tabId: string pageId: string -} { +}> { const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' return { tabId: tab.id, pageId } } describe('convertBrowserPage doc→web', () => { - it('replaces the page under a fresh id in the same workspace row', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('replaces the page under a fresh id in the same workspace row', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) const converted = store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(converted).not.toBeNull() // Fresh id: the old id must never resurface in the other registry half. @@ -85,22 +112,24 @@ describe('convertBrowserPage doc→web', () => { expect(workspace?.activePageId).toBe(converted?.id) }) - it('releases the old grant exactly once, after the store stops naming the document', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('releases the old grant exactly once, after the store stops naming the document', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) }) - it('records one level of provenance so Back can return to the document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('records one level of provenance so Back can return to the document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() const page = store.getState().browserPagesByWorkspace[tabId]?.[0] expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) @@ -109,14 +138,16 @@ describe('convertBrowserPage doc→web', () => { expect(parsed.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) }) - it('refuses to convert a web page to web — that is navigation, not conversion', () => { - const store = createStoreWithWorktree() + it('refuses to convert a web page to web — that is navigation, not conversion', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const converted = store .getState() .convertBrowserPage(pageId, { kind: 'web', url: 'https://other.example/' }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id).toBe(pageId) @@ -125,9 +156,10 @@ describe('convertBrowserPage doc→web', () => { }) describe('convertBrowserPage web→doc', () => { - it('replaces the page with a client-local document page and mirrors it', () => { - const store = createStoreWithWorktree() + it('replaces the page with a client-local document page and mirrors it', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' mocks.releaseDocPreviewGrant.mockClear() @@ -135,6 +167,7 @@ describe('convertBrowserPage web→doc', () => { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).not.toBeNull() expect(converted?.id).not.toBe(pageId) @@ -151,15 +184,17 @@ describe('convertBrowserPage web→doc', () => { expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() }) - it('never lets a grant url into the converted page, even handed one directly', () => { - const store = createStoreWithWorktree() + it('never lets a grant url into the converted page, even handed one directly', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] const written = JSON.stringify([page, store.getState().browserTabsByWorktree[WORKTREE_ID]]) @@ -168,15 +203,16 @@ describe('convertBrowserPage web→doc', () => { }) describe('convertBrowserPage doc→doc retarget', () => { - it('replaces the page and its grant for a different document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('replaces the page and its grant for a different document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: OTHER_DOC_LOCATION }) + await settled() expect(converted?.id).not.toBe(pageId) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) @@ -185,15 +221,16 @@ describe('convertBrowserPage doc→doc retarget', () => { expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) }) - it('is a no-op for the same document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('is a no-op for the same document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace[tabId]?.[0]?.id).toBe(pageId) @@ -202,14 +239,15 @@ describe('convertBrowserPage doc→doc retarget', () => { }) describe('convertBrowserPage history legs', () => { - it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) const webPage = store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(webPage?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) expect(webPage?.convertedTo ?? null).toBeNull() @@ -220,6 +258,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'workspace-doc', docLocation: DOC_LOCATION }, { leg: 'history-return' } ) + await settled() expect(returned?.docLocation).toEqual(DOC_LOCATION) expect(returned?.convertedFrom ?? null).toBeNull() @@ -235,12 +274,13 @@ describe('convertBrowserPage history legs', () => { expect(parsed.convertedTo).toEqual(expectedForward) }) - it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) const webPage = store .getState() .convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() const docPage = store .getState() .convertBrowserPage( @@ -248,6 +288,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'workspace-doc', docLocation: DOC_LOCATION }, { leg: 'history-return' } ) + await settled() const advanced = store .getState() @@ -256,6 +297,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'web', url: 'https://example.com/' }, { leg: 'history-advance' } ) + await settled() expect(advanced?.url).toBe('https://example.com/') expect(advanced?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) @@ -264,17 +306,19 @@ describe('convertBrowserPage history legs', () => { }) describe('convertBrowserPage placement and activation', () => { - it('converts a background page without stealing the active page', () => { - const store = createStoreWithWorktree() - const { tabId, pageId: docPageId } = createDocTab(store) + it('converts a background page without stealing the active page', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId: docPageId } = await createDocTab(store) const webPage = store.getState().createBrowserPage(tabId, 'https://active.example/', { activate: true }) + await settled() const converted = store.getState().convertBrowserPage(docPageId, { kind: 'web', url: 'https://converted.example/' }) + await settled() const pages = store.getState().browserPagesByWorkspace[tabId] ?? [] // Replacement holds the old page's position. @@ -289,9 +333,9 @@ describe('convertBrowserPage placement and activation', () => { expect(converted?.id).not.toBe(docPageId) }) - it('clears the old page id from every per-page side table', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('clears the old page id from every per-page side table', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) // Every table SEEDED, so a deletion that stops running fails instead of passing vacuously. store.setState((s) => ({ pendingAddressBarFocusByPageId: { ...s.pendingAddressBarFocusByPageId, [pageId]: true }, @@ -307,6 +351,7 @@ describe('convertBrowserPage placement and activation', () => { })) store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(store.getState().pendingAddressBarFocusByPageId[pageId]).toBeUndefined() expect(store.getState().pendingAddressBarFocusByTabId[pageId]).toBeUndefined() @@ -316,9 +361,10 @@ describe('convertBrowserPage placement and activation', () => { // The leg the remote pane's address bar reaches: converting a runtime-owned page must close the // host's page and drop the handle, or a ghost page stays open on the remote host. - it('closes the remote page and drops its handle when a runtime-owned page converts', () => { - const store = createStoreWithWorktree() + it('closes the remote page and drops its handle when a runtime-owned page converts', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.setState((s) => ({ remoteBrowserPageHandlesByPageId: { @@ -332,6 +378,7 @@ describe('convertBrowserPage placement and activation', () => { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).not.toBeNull() expect(store.getState().remoteBrowserPageHandlesByPageId[pageId]).toBeUndefined() @@ -345,17 +392,19 @@ describe('convertBrowserPage placement and activation', () => { // Ownership rides provenance both ways, or Back silently moves a remote tab's browsing onto // this desktop (the ssh-execution-boundary concern). - it('carries runtime ownership through provenance and honors it on the return leg', () => { - const store = createStoreWithWorktree() + it('carries runtime ownership through provenance and honors it on the return leg', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/', { browserRuntimeEnvironmentId: 'env-1' }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const docPage = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(docPage?.convertedFrom).toEqual({ kind: 'url', url: 'https://remote.example/', @@ -371,12 +420,13 @@ describe('convertBrowserPage placement and activation', () => { }, { leg: 'history-return' } ) + await settled() expect(returned?.browserRuntimeEnvironmentId).toBe('env-1') }) - it('returns a worktree-inferred remote page as inferred, never as client-local', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('returns a worktree-inferred remote page as inferred, never as client-local', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) // The return leg says "inferred" by passing the property explicitly undefined. const returned = store .getState() @@ -385,18 +435,20 @@ describe('convertBrowserPage placement and activation', () => { { kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: undefined }, { leg: 'history-return' } ) + await settled() expect(returned).not.toBeNull() expect('browserRuntimeEnvironmentId' in (returned ?? {})).toBe(false) }) - it('returns null for an unknown page and changes nothing', () => { - const store = createStoreWithWorktree() - createDocTab(store) + it('returns null for an unknown page and changes nothing', async () => { + const store = await createStoreWithWorktree() + await createDocTab(store) const before = store.getState().browserPagesByWorkspace const converted = store .getState() .convertBrowserPage('missing-page', { kind: 'web', url: 'https://example.com/' }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace).toBe(before) diff --git a/src/renderer/src/store/slices/browser-page-conversion.ts b/src/renderer/src/store/slices/browser-page-conversion.ts index b7874f557c7..49d6f73215a 100644 --- a/src/renderer/src/store/slices/browser-page-conversion.ts +++ b/src/renderer/src/store/slices/browser-page-conversion.ts @@ -52,7 +52,11 @@ export function planBrowserPageConversion( }, pageId: string, target: BrowserPageConversionTarget, - options?: { leg?: BrowserPageConversionLeg } + options?: { + leg?: BrowserPageConversionLeg + /** The id main already holds the new page under. */ + newPageId?: string + } ): BrowserPageConversionPlan | null { const oldPage = findPage(state.browserPagesByWorkspace, pageId) if (!oldPage) { @@ -101,7 +105,7 @@ export function planBrowserPageConversion( // Why explicitly client-local: the document is read through a grant this desktop mints, // so the page never belongs to a remote runtime even when the worktree does. null, - undefined, + options?.newPageId, target.docLocation ) : buildBrowserPage( @@ -116,7 +120,8 @@ export function planBrowserPageConversion( // when it restores a worktree-owned remote page. 'browserRuntimeEnvironmentId' in target ? target.browserRuntimeEnvironmentId - : (oldPage.browserRuntimeEnvironmentId ?? null) + : (oldPage.browserRuntimeEnvironmentId ?? null), + options?.newPageId )), ...(convertedFrom ? { convertedFrom } : {}), ...(convertedTo ? { convertedTo } : {}) diff --git a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts index 57da462aeac..620bc69f902 100644 --- a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts +++ b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts @@ -1,16 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import type { AppState } from '../types' import { createBrowserMockApi, createTestStore, resetBrowserRuntimeMocks, settingsWithRuntime } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' import { makeWorktree } from './worktrees-slice-test-fixtures' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -20,6 +34,84 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } +/** wt-1's one browser tab, whose page is a runtime's remote page, seeded in its pane group. */ +function remoteBackedBrowserTab(): Partial { + return { + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'unified-browser-tab', + entityId: 'workspace-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: 'Example', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'unified-browser-tab', + tabOrder: ['unified-browser-tab'] + } + ] + }, + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + } +} + +/** A layout-owner build's close is main's: the window sent it, and main's frame drew it. */ +function expectMainClosed(command: 'closeBrowserPage' | 'closeTabs'): void { + expect(layoutApplyTestTransport.sent.map((sent) => sent.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [command] : [] + ) +} + describe('createBrowserSlice runtime guard', () => { beforeEach(() => { resetBrowserRuntimeMocks({ @@ -69,51 +161,14 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes the mapped remote tab when closing a browser page in the active runtime', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-1'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + ...remoteBackedBrowserTab() }) store.getState().closeBrowserPage('page-1') + await main.settle() + expectMainClosed('closeBrowserPage') await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -127,53 +182,16 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote tabs when closing a browser workspace in the active runtime', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-1'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, + ...remoteBackedBrowserTab(), activeBrowserTabId: 'workspace-1', - activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' } }) store.getState().closeBrowserTab('workspace-1') + await main.settle() + expectMainClosed('closeTabs') await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -187,50 +205,12 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote pages in their owning environment after switching local', async () => { - const store = createTestStore() - store.setState({ - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } - }) + const { store, main } = await createTestStoreDrawnFromMain(remoteBackedBrowserTab()) store.getState().closeBrowserPage('page-1') + await main.settle() + expectMainClosed('closeBrowserPage') + expect(store.getState().browserPagesByWorkspace['workspace-1'] ?? []).toEqual([]) await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -243,51 +223,15 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote tabs in their owning environment after switching environments', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-2'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + ...remoteBackedBrowserTab() }) store.getState().closeBrowserTab('workspace-1') + await main.settle() + expectMainClosed('closeTabs') + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ diff --git a/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts b/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts index 06fcaab8512..fb919113ffa 100644 --- a/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts +++ b/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts @@ -1,17 +1,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, - createTestStore, resetBrowserRuntimeMocks, runtimeStatuses, settingsWithRuntime } from './browser-slice-test-harness' +import { + createTestStoreDrawnFromMain, + onePaneGroup +} from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/store/slices/browser/paired-browser-tab-creator', () => ({ getRegisteredPairedBrowserTabCreator: () => null, loadPairedBrowserTabCreator: async () => createWebRuntimeSessionBrowserTabMock @@ -22,6 +36,12 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } +/** A server-owned workspace's desktop tab: the window draws it, as main never holds that layout. */ +function expectDrawnHere(state: AppState): void { + expect(layoutApplyTestTransport.sent).toEqual([]) + expect(state.createUnifiedTab).toHaveBeenCalled() +} + describe('createBrowserSlice runtime guard', () => { beforeEach(() => { resetBrowserRuntimeMocks({ @@ -35,9 +55,9 @@ describe('createBrowserSlice runtime guard', () => { vi.unstubAllGlobals() }) - it('rejects direct client-local materialization in the web client', () => { + it('rejects direct client-local materialization in the web client', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() + const { store } = await createTestStoreDrawnFromMain() expect(() => store.getState().createBrowserTab('wt-1', 'about:blank')).toThrow( 'must be created by a capable paired runtime' @@ -46,10 +66,11 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() }) - it('rejects direct remote materialization without the provider capability', () => { + it('rejects direct remote materialization without the provider capability', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() - store.setState({ runtimeStatusByEnvironmentId: runtimeStatuses([]) }) + const { store } = await createTestStoreDrawnFromMain({ + runtimeStatusByEnvironmentId: runtimeStatuses([]) + }) expect(() => store.getState().createBrowserTab('wt-1', 'about:blank', { @@ -61,8 +82,9 @@ describe('createBrowserSlice runtime guard', () => { }) it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + // group-1 is wt-remote's, where the new-tab click lands. + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -117,11 +139,13 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('uses the desktop client browser when a remote npm host cannot stream', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -138,21 +162,22 @@ describe('createBrowserSlice runtime guard', () => { }) await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + await main.settle() expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() const tab = store.getState().browserTabsByWorktree['wt-remote']?.[0] const page = tab ? store.getState().browserPagesByWorkspace[tab.id]?.[0] : undefined expect(page?.browserRuntimeEnvironmentId).toBeNull() - expect(store.getState().createUnifiedTab).toHaveBeenCalled() + expectDrawnHere(store.getState()) }) it('does not create a local fallback tab when remote browser creation fails', async () => { - const store = createTestStore() // Why: a remote-owned workspace must stay remote-owned. If the remote host // cannot create the page, we must NOT silently open a local desktop tab — // that produces confusing split ownership (issue #5321 UX requirement). createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false) - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -180,6 +205,8 @@ describe('createBrowserSlice runtime guard', () => { clientTargetGroupId: 'group-1' }) // No local tab created, no unified tab, no feature interaction recorded. + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith( @@ -188,8 +215,8 @@ describe('createBrowserSlice runtime guard', () => { }) it('opens a remote sign-in tab with the imported browser profile', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -218,11 +245,13 @@ describe('createBrowserSlice runtime guard', () => { profileId: 'profile-1' }) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('opens a local desktop sign-in tab when the remote host cannot stream browsers', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -243,18 +272,20 @@ describe('createBrowserSlice runtime guard', () => { .getState() .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') ).resolves.toBe(true) + await main.settle() expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']?.[0]).toMatchObject({ url: 'https://accounts.google.com/', sessionProfileId: 'profile-1' }) + expectDrawnHere(store.getState()) }) it('rejects a paired-web sign-in tab when the remote host cannot stream browsers', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -278,12 +309,14 @@ describe('createBrowserSlice runtime guard', () => { expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('does not create a local fallback tab when remote browser creation throws', async () => { - const store = createTestStore() createWebRuntimeSessionBrowserTabMock.mockRejectedValueOnce(new Error('remote down')) - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -302,6 +335,8 @@ describe('createBrowserSlice runtime guard', () => { await expect(store.getState().openNewBrowserTabInActiveWorkspace('group-1')).rejects.toThrow( 'remote down' ) + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts b/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts new file mode 100644 index 00000000000..119dda31ef7 --- /dev/null +++ b/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts @@ -0,0 +1,52 @@ +// The browser slice's partial test store with main drawing its tabs in a layout-owner build. + +import { onTestFinished } from 'vitest' +import type { AppState } from '../types' +import { createTabsGroupActions } from './tabs/tabs-group-actions' +import { createTestStore } from './browser-slice-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +export type BrowserTestStore = ReturnType + +/** A worktree's one empty pane group, which main places an open in; it replaces wt-1's. */ +export function onePaneGroup(worktreeId: string, groupId = 'group-1'): Partial { + return { + groupsByWorktree: { + [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId } }, + activeGroupIdByWorktree: { [worktreeId]: groupId } + } +} + +/** wt-1's pane group, plus the slices a session payload reads that the partial store lacks. */ +const DRAWN_STORE_SEED: Partial = { + repos: [], + editorDrafts: {}, + markdownFrontmatterVisible: {}, + activeFileIdByWorktree: {}, + workspaceDocHistory: [], + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, + ...onePaneGroup('wt-1') +} + +/** + * The partial store with main drawing its tabs (switch-on): seeded first, since main loads the + * layout the store holds now. The test file installs the layout-apply transport (see + * tabs-drawn-from-main.test-fixture.ts); main closes when the test finishes. + */ +export async function createTestStoreDrawnFromMain( + seed?: Partial +): Promise<{ store: BrowserTestStore; main: TabsDrawnFromMain }> { + const store = createTestStore() + // Main drops a workspace with its last tab; the next open places through the real root group. + const { ensureWorktreeRootGroup, focusGroup } = createTabsGroupActions( + store.setState, + store.getState + ) + store.setState({ ...DRAWN_STORE_SEED, ensureWorktreeRootGroup, focusGroup, ...seed }) + const main = await drawTabsFromMain(store) + onTestFinished(() => main.close()) + return { store, main } +} diff --git a/src/renderer/src/store/slices/browser-slice-test-harness.ts b/src/renderer/src/store/slices/browser-slice-test-harness.ts index bad5a94139c..faf83f75cdd 100644 --- a/src/renderer/src/store/slices/browser-slice-test-harness.ts +++ b/src/renderer/src/store/slices/browser-slice-test-harness.ts @@ -56,6 +56,8 @@ export function createTestStore() { browserDefaultUrl: 'about:blank', unifiedTabsByWorktree: {}, groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, tabBarOrderByWorktree: {}, tabsByWorktree: {}, openFiles: [], diff --git a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts index b3dd75a3839..675f5075edc 100644 --- a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts +++ b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -12,6 +12,8 @@ import { seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -25,6 +27,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; its frames draw the browser tabs. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + createStoreCascadesMockApi() const WT = 'repo1::/path/wt-placement' @@ -104,19 +118,36 @@ function createdUnifiedId(store: Store, workspaceId: string, worktreeId = WT): s describe('browser tab creation placement', () => { let store: Store + let main: TabsDrawnFromMain | undefined beforeEach(() => { store = createTestStore() }) + afterEach(() => { + main?.close() + main = undefined + }) + + /** Main takes the seeded layout; a layout-owner build draws the created tab from its frame. */ + async function drawFromMain(): Promise { + main?.close() + main = await drawTabsFromMain(store) + } + async function settled(): Promise { + await main?.settle() + } it('appends Group New Browser Tab regardless of the active tab', async () => { seedSplit(store) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() const n = store.getState().unifiedTabsByWorktree[WT]!.at(-1)! expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id]) store.getState().activateTab('A') await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() const m = store.getState().unifiedTabsByWorktree[WT]!.at(-1)! expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id, m.id]) }) @@ -124,7 +155,9 @@ describe('browser tab creation placement', () => { it('uses the invoking group workspace instead of the globally selected one', async () => { seedSplit(store) store.setState({ activeWorktreeId: 'repo1::/elsewhere' }) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() expect(group(store, G1)?.tabOrder).toHaveLength(4) expect(store.getState().unifiedTabsByWorktree['repo1::/elsewhere']).toBeUndefined() }) @@ -136,8 +169,10 @@ describe('browser tab creation placement', () => { repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:target' })] } }) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) await store.getState().openBrowserProfileTabInActiveWorkspace('https://example.com', null) + await settled() const workspaces = store.getState().browserTabsByWorktree[WT] ?? [] expect(workspaces).toHaveLength(2) for (const workspace of workspaces) { @@ -147,32 +182,33 @@ describe('browser tab creation placement', () => { } }) - it('places a duplicate directly after its source', () => { + it('places a duplicate directly after its source', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() const duplicate = createdUnifiedId(store, workspace.id) expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', duplicate, 'C']) expect( - store - .getState() - .unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1) - .map((tab) => [tab.id, tab.sortOrder]) - ).toEqual([ - ['A', 0], - ['B', 1], - ['C', 3], - [duplicate, 2] - ]) + Object.fromEntries( + store + .getState() + .unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1) + .map((tab) => [tab.id, tab.sortOrder]) + ) + ).toEqual({ A: 0, B: 1, [duplicate]: 2, C: 3 }) }) - it('places a background link after its source without touching selection or MRU', () => { + it('places a background link after its source without touching selection or MRU', async () => { seedSplit(store) const before = store.getState() + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com/link', { afterTabId: 'B', activate: false }) + await settled() const link = createdUnifiedId(store, workspace.id) const after = store.getState() @@ -191,11 +227,13 @@ describe('browser tab creation placement', () => { expect(after.unifiedTabsByWorktree[WT]!.find((tab) => tab.id === 'B')?.lastFocusedAt).toBe(100) }) - it('focuses the resolved group for a foreground source action in an unfocused split', () => { + it('focuses the resolved group for a foreground source action in an unfocused split', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() const created = createdUnifiedId(store, workspace.id) const state = store.getState() expect(state.activeGroupIdByWorktree[WT]).toBe(G1) @@ -205,64 +243,88 @@ describe('browser tab creation placement', () => { expect(state.activeTabType).toBe('browser') }) - it('lets an explicit destination win over the anchor group', () => { + it('lets an explicit destination win over the anchor group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B', targetGroupId: G2 }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C']) }) - it('treats a closed anchor as absent and appends in the active group', () => { + it('treats a closed anchor as absent and appends in the active group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'closed-tab' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('falls back to the active group, not the first group, for an expired requested group', () => { + it('falls back to the active group, not the first group, for an expired requested group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { targetGroupId: 'deleted-group' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('ignores an ambiguous anchor id', () => { - seedSplit(store, { - tabs: [browserTab('A', G1), browserTab('B', G1), browserTab('B', G2), browserTab('C', G1)] - }) + it('ignores an ambiguous anchor id', async () => { + const seed = { + tabs: [ + browserTab('A', G1), + browserTab('B', G1), + browserTab('B', G2), + browserTab('C', G1), + browserTab('X', G2) + ] + } + seedSplit(store, seed) + await drawFromMain() + // Main's load repairs the malformed seed; the window's copy keeps it for the guard to read. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('ignores an anchor whose group order no longer contains it', () => { - seedSplit(store, { + it('ignores an anchor whose group order no longer contains it', async () => { + const seed = { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'A', tabOrder: ['A', 'C'] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] - }) + } + seedSplit(store, seed) + await drawFromMain() + // Main's load repairs the malformed seed; the window's copy keeps it for the guard to read. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('collapses duplicate order entries while inserting once', () => { + it('collapses duplicate order entries while inserting once', async () => { seedSplit(store, { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'B', tabOrder: ['A', 'B', 'A', 'C'] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual([ 'A', 'B', @@ -271,7 +333,7 @@ describe('browser tab creation placement', () => { ]) }) - it('keeps an unpinned tab from a pinned source after the pinned prefix', () => { + it('keeps an unpinned tab from a pinned source after the pinned prefix', async () => { seedSplit(store, { tabs: [ browserTab('P1', G1, { isPinned: true }), @@ -284,9 +346,11 @@ describe('browser tab creation placement', () => { makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'P1' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual([ 'P1', 'P2', @@ -350,25 +414,28 @@ describe('browser tab creation placement', () => { expect(sortOrderById).toMatchObject({ A: 0, C: 1, [created.id]: 2 }) }) - it('leaves the wrapper host unset instead of stamping a substituted routing host', () => { + it('leaves the wrapper host unset instead of stamping a substituted routing host', async () => { seedSplit(store) // Why: no repo row and a focused runtime make the routing resolver substitute runtime:env-1. store.setState({ activeWorktreeId: 'repo1::/elsewhere', settings: { ...store.getState().settings!, activeRuntimeEnvironmentId: 'env-1' } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { activate: false, browserRuntimeEnvironmentId: null }) + await settled() const wrapper = store .getState() .unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === workspace.id) expect(wrapper).toBeDefined() - expect(wrapper?.executionHostId).toBeUndefined() + // A layout-owner build draws every tab with main's own host, never the routing substitute. + expect(wrapper?.executionHostId).toBe(ORCA_LAYOUT_OWNER_IS_MAIN ? 'local' : undefined) }) - it('rejects an anchor owned by a foreign execution host', () => { + it('rejects an anchor owned by a foreign execution host', async () => { seedSplit(store, { tabs: [browserTab('A', G1, { executionHostId: 'ssh:other' }), browserTab('X', G2)], groups: [ @@ -376,14 +443,16 @@ describe('browser tab creation placement', () => { makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { afterTabId: 'A', executionHostId: 'runtime:env-1' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('accepts an SSH-backed anchor through the worktree runtime-owner alias', () => { + it('accepts an SSH-backed anchor through the worktree runtime-owner alias', async () => { const sshHost: ExecutionHostId = 'ssh:target-1' seedSplit(store, { tabs: [browserTab('A', G1, { executionHostId: sshHost }), browserTab('C', G1)], @@ -401,14 +470,16 @@ describe('browser tab creation placement', () => { ] } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { afterTabId: 'A', executionHostId: 'runtime:env-1' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual(['A', createdUnifiedId(store, workspace.id), 'C']) }) - it('places source actions in folder and floating workspaces', () => { + it('places source actions in folder and floating workspaces', async () => { const folderWt = 'folder:folder-1' const folder: FolderWorkspace = { id: 'folder-1', @@ -432,7 +503,12 @@ describe('browser tab creation placement', () => { unifiedTabsByWorktree: { ...store.getState().unifiedTabsByWorktree, [worktreeId]: ['A', 'B'].map((id) => - makeUnifiedTab({ id: `${worktreeId}-${id}`, worktreeId, groupId: `${worktreeId}-g` }) + makeUnifiedTab({ + id: `${worktreeId}-${id}`, + worktreeId, + groupId: `${worktreeId}-g`, + contentType: 'browser' + }) ) }, groupsByWorktree: { @@ -447,10 +523,12 @@ describe('browser tab creation placement', () => { ] } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(worktreeId, 'https://example.com', { afterTabId: `${worktreeId}-A`, browserRuntimeEnvironmentId: null }) + await settled() expect(group(store, `${worktreeId}-g`, worktreeId)?.tabOrder).toEqual([ `${worktreeId}-A`, createdUnifiedId(store, workspace.id, worktreeId), @@ -459,17 +537,22 @@ describe('browser tab creation placement', () => { } }) - it('selects the first tab of an empty group structurally without recording a visit', () => { - seedSplit(store, { + it('selects the first tab of an empty group structurally without recording a visit', async () => { + const seed = { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: null, tabOrder: [], recentTabIds: [] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ], tabs: [browserTab('X', G2)] - }) + } + seedSplit(store, seed) + await drawFromMain() + // Main stores no empty group; only the window's copy holds it. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { targetGroupId: G1, activate: false }) + await settled() const created = createdUnifiedId(store, workspace.id) expect(group(store, G1)).toMatchObject({ activeTabId: created, recentTabIds: [] }) expect(store.getState().activeGroupIdByWorktree[WT]).toBe(G2) diff --git a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts index c60fe7bd2f7..3d70896238f 100644 --- a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { buildBrowserSessionData } from '@/lib/workspace-session-browser-tabs' @@ -7,6 +7,7 @@ import { browserWorkspaceSchema } from '../../../../shared/workspace-session-browser-schema' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -19,6 +20,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands and its frames draw the tabs (switch-on only). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { @@ -30,7 +42,18 @@ const DOC_LOCATION = { // process. Nothing in the store, on disk or on the wire may ever carry it. const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html` -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +async function settled(): Promise { + await main?.settle() +} + +/** A store showing one worktree, its tabs drawn from main once the switch is on. */ +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -39,6 +62,8 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main?.close() + main = await drawTabsFromMain(store) return store } @@ -55,13 +80,14 @@ function persistedSession( } describe('a browser page that shows a workspace document', () => { - it('is created blank, with the document as its identity', () => { - const store = createStoreWithWorktree() + it('is created blank, with the document as its identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL) @@ -78,10 +104,11 @@ describe('a browser page that shows a workspace document', () => { // The presence precondition for every blank-url assertion here: an ordinary tab created the same // way keeps the URL it was given, so a store that had stopped recording urls at all would fail // rather than pass by being uniformly blank. - it('leaves an ordinary browser tab url alone', () => { - const store = createStoreWithWorktree() + it('leaves an ordinary browser tab url alone', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.url).toBe('https://example.com/') expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull() @@ -89,13 +116,14 @@ describe('a browser page that shows a workspace document', () => { // Browser parity: a browser tab is named by the document it shows, and so is this one. Without // this the blank url that is correct for a document page named every preview "New Tab". - it('takes its name from the document, and falls back to the file', () => { - const store = createStoreWithWorktree() + it('takes its name from the document, and falls back to the file', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.title).toBe('index.html') @@ -108,13 +136,14 @@ describe('a browser page that shows a workspace document', () => { // Why a title needs the same refusal the url has: Chromium reports the URL as the title when a // document declares none, and titles are stored, mirrored onto the tab and written to disk. - it('refuses a grant url arriving as the document title', () => { - const store = createStoreWithWorktree() + it('refuses a grant url arriving as the document title', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().updateBrowserPageState(pageId, { title: LIVE_GRANT_URL }) @@ -126,9 +155,10 @@ describe('a browser page that shows a workspace document', () => { // The presence half for both: an ordinary page still gets the blank-url name, so a fallback that // had swallowed every title would fail here rather than pass by naming everything after a file. - it('still names an ordinary blank browser tab New Tab', () => { - const store = createStoreWithWorktree() + it('still names an ordinary blank browser tab New Tab', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().updateBrowserPageState(pageId, { title: '' }) @@ -139,13 +169,14 @@ describe('a browser page that shows a workspace document', () => { // Why the third url door is fenced even though nothing reaches it today: creation and the title // update are fenced, and a door left open is one navigation report away from committing a grant // to the page every persisted, mirrored and published reader takes its url from. - it('keeps its blank url when a navigation commits one onto it', () => { - const store = createStoreWithWorktree() + it('keeps its blank url when a navigation commits one onto it', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().setBrowserPageUrl(pageId, LIVE_GRANT_URL) @@ -163,9 +194,10 @@ describe('a browser page that shows a workspace document', () => { // The presence half for the door above: an ordinary page still takes the url it is given, so a // door that had stopped writing urls entirely would fail here rather than pass by writing none. - it('still commits a navigation url onto an ordinary browser tab', () => { - const store = createStoreWithWorktree() + it('still commits a navigation url onto an ordinary browser tab', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().setBrowserPageUrl(pageId, 'https://example.com/next') @@ -176,14 +208,16 @@ describe('a browser page that shows a workspace document', () => { }) }) - it('never asks for the address bar it does not have', () => { - const store = createStoreWithWorktree() + it('never asks for the address bar it does not have', async () => { + const store = await createStoreWithWorktree() const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const newTab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[docTab.id]).toBeUndefined() // The blank url is exactly what marks an ordinary New Tab as wanting focus there. @@ -194,15 +228,17 @@ describe('a browser page that shows a workspace document', () => { // its one page, while every later change re-mirrors from whichever page is active. A mirror that // does not carry this field leaves the strip entry naming a document the reader has switched away // from — and the publish boundary reads the tab, not the page. - it('follows the active page in and out of the document', () => { - const store = createStoreWithWorktree() + it('follows the active page in and out of the document', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const urlPage = store.getState().createBrowserPage(tab.id, 'https://example.com/') + await settled() expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull() expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.url).toBe( @@ -221,12 +257,13 @@ describe('a browser page that shows a workspace document', () => { // not — two documents named index.html in different folders share a title, and a document page's // url is blank by construction. Without this field in the comparison the strip entry keeps naming // the document the reader moved away from, and the publish boundary reads the entry. - it('repairs a workspace still naming the document its page has left', () => { - const store = createStoreWithWorktree() + it('repairs a workspace still naming the document its page has left', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] const otherDocument = { ...DOC_LOCATION, filePath: '/home/alice/wt1/appendix/index.html' } store.setState({ @@ -246,49 +283,59 @@ describe('a browser page that shows a workspace document', () => { // Why closing has to say so: a grant is the only authority the preview scheme honors, and it // outlives the guest. A closed document that stays readable is a read authority nothing revokes // until the process ends. - it('revokes the grant of the document tab that was closed, and only that one', () => { - const store = createStoreWithWorktree() + it('revokes the grant of the document tab that was closed, and only that one', async () => { + const store = await createStoreWithWorktree() const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() store.getState().closeBrowserTab(urlTab.id) + await settled() expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() store.getState().closeBrowserTab(docTab.id) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(docPageId) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) }) - it('revokes a document grant when only that page is closed', () => { + it('revokes a document grant when only that page is closed', async () => { mocks.releaseDocPreviewGrant.mockClear() - const store = createStoreWithWorktree() + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().createBrowserPage(tab.id, 'https://example.com/') + await settled() store.getState().closeBrowserPage(docPageId) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledExactlyOnceWith(docPageId) expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1) }) - it('reopens a closed document tab with its document identity', () => { - const store = createStoreWithWorktree() + it('reopens a closed document tab with its document identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() store.getState().closeBrowserTab(tab.id) + await settled() const reopened = store.getState().reopenClosedBrowserTab(WORKTREE_ID) + await settled() const page = reopened ? store.getState().browserPagesByWorkspace[reopened.id]?.[0] : undefined expect(page?.docLocation).toEqual(DOC_LOCATION) @@ -296,30 +343,35 @@ describe('a browser page that shows a workspace document', () => { expect(reopened?.docLocation).toEqual(DOC_LOCATION) }) - it('reopens a closed document page with its document identity', () => { - const store = createStoreWithWorktree() + it('reopens a closed document page with its document identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const documentPage = store.getState().createBrowserPage(tab.id, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() if (!documentPage) { throw new Error('Expected a document page') } store.getState().closeBrowserPage(documentPage.id) + await settled() const reopened = store.getState().reopenClosedBrowserPage(tab.id) + await settled() expect(reopened?.docLocation).toEqual(DOC_LOCATION) expect(reopened?.url).toBe(ORCA_BROWSER_BLANK_URL) }) - it('writes the document and not the grant to the session', () => { - const store = createStoreWithWorktree() + it('writes the document and not the grant to the session', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) const written = JSON.stringify(session) @@ -332,12 +384,13 @@ describe('a browser page that shows a workspace document', () => { // Why the schema is asserted separately: both browser schemas are plain z.object, which strips // what it does not name. An unlisted docLocation survives every step above and disappears on // load, restoring the document as a blank New Tab under a strip entry that still names it. - it('survives the session schema in both halves', () => { - const store = createStoreWithWorktree() + it('survives the session schema in both halves', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) const parsedPage = browserPageSchema.parse(session.browserPagesByWorkspace?.[tab.id]?.[0]) @@ -349,15 +402,16 @@ describe('a browser page that shows a workspace document', () => { expect(parsedTab.docLocation).toEqual(DOC_LOCATION) }) - it('restores as the document it was, still blank', () => { - const store = createStoreWithWorktree() + it('restores as the document it was, still blank', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) - const restored = createStoreWithWorktree() + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', activeWorktreeId: WORKTREE_ID, @@ -377,15 +431,16 @@ describe('a browser page that shows a workspace document', () => { // Why this shape and not just a missing field: salvage drops a malformed page row and leaves the // array empty, and hydration then rebuilds one page from the tab's own mirrored chrome. - it('restores from the tab alone when its page row was salvaged away', () => { - const store = createStoreWithWorktree() + it('restores from the tab alone when its page row was salvaged away', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) - const restored = createStoreWithWorktree() + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', activeWorktreeId: WORKTREE_ID, @@ -404,8 +459,8 @@ describe('a browser page that shows a workspace document', () => { // Why a hand-written row and not one this build can produce: the store refuses to mint one, so // the only way a grant url reaches this field is a session written by another build. It names a // grant that died with the process that minted it, and the address bar would show it. - it('blanks a grant url a foreign session carried in', () => { - const restored = createStoreWithWorktree() + it('blanks a grant url a foreign session carried in', async () => { + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', @@ -451,6 +506,7 @@ describe('a browser page that shows a workspace document', () => { ] } }) + await settled() expect(restored.getState().browserPagesByWorkspace['browser-1']?.[0]?.url).toBe( ORCA_BROWSER_BLANK_URL diff --git a/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts b/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts index ea74f0dc8c8..6bfe5afd403 100644 --- a/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts @@ -1,16 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isLocalBrowserPageOwner } from './browser' import type { AppState } from '../types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, createTestStore, resetBrowserRuntimeMocks } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -119,14 +132,26 @@ describe('createBrowserSlice runtime guard', () => { expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', null)).toBe(true) }) - it('stores a runtime-resolved browser partition without a renderer profile mirror', () => { - const store = createTestStore() - store.setState({ browserSessionProfiles: [] }) + it('stores a runtime-resolved browser partition without a renderer profile mirror', async () => { + const { store, main } = await createTestStoreDrawnFromMain({ browserSessionProfiles: [] }) const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { sessionProfileId: 'profile-isolated', sessionPartition: 'persist:orca-browser-session-profile-isolated' }) + await main.settle() + + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [ + expect.objectContaining({ + browserTabId: tab.id, + profileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ] + : [] + ) expect(tab.sessionProfileId).toBe('profile-isolated') expect(tab.sessionPartition).toBe('persist:orca-browser-session-profile-isolated') @@ -135,12 +160,13 @@ describe('createBrowserSlice runtime guard', () => { ) }) - it('stores a runtime-resolved partition when switching browser tab profiles', () => { - const store = createTestStore() + it('stores a runtime-resolved partition when switching browser tab profiles', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { sessionProfileId: null, sessionPartition: 'persist:orca-browser' }) + await main.settle() store .getState() @@ -149,6 +175,20 @@ describe('createBrowserSlice runtime guard', () => { 'profile-isolated', 'persist:orca-browser-session-profile-isolated' ) + await main.settle() + + // A layout-owner build's main records the switch; its frame draws the record. + expect(layoutApplyTestTransport.sentOfType('setBrowserTabProfile')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [ + expect.objectContaining({ + browserTabId: tab.id, + profileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ] + : [] + ) expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toEqual( expect.objectContaining({ diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index f3c6f19f6e4..48cffee768d 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -3,15 +3,31 @@ import type { AppState } from '../types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' import { makeAnnotation } from './browser-annotation-test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, createTestStore, settingsWithRuntime } from './browser-slice-test-harness' +import { + createTestStoreDrawnFromMain, + type BrowserTestStore +} from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -21,11 +37,7 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } -function seedUnifiedBrowserTab( - store: ReturnType, - entityId: string, - label: string -): void { +function seedUnifiedBrowserTab(store: BrowserTestStore, entityId: string, label: string): void { store.setState({ unifiedTabsByWorktree: { 'wt-1': [ @@ -47,26 +59,30 @@ function seedUnifiedBrowserTab( } describe('createBrowserSlice annotations', () => { - it('announces the store-selected browser page before its guest is destroyed', () => { - const store = createTestStore() + it('announces the store-selected browser page before its guest is destroyed', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const previous = store.getState().createBrowserTab('wt-1', 'https://previous.example.com') + await main.settle() const closing = store.getState().createBrowserTab('wt-1', 'https://closing.example.com') + await main.settle() store.getState().setActiveBrowserTab(previous.id) store.getState().setActiveBrowserTab(closing.id) mockApi.browser.notifyActiveTabChanged.mockClear() store.getState().closeBrowserTab(closing.id) + await main.settle() expect(mockApi.browser.notifyActiveTabChanged).toHaveBeenCalledWith({ browserPageId: previous.activePageId }) }) - it('keeps a requested canonical page identity distinct from its workspace', () => { - const store = createTestStore() + it('keeps a requested canonical page identity distinct from its workspace', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'about:blank', { browserPageId: 'page-canonical' }) + await main.settle() expect(tab.id).not.toBe('page-canonical') expect(tab.activePageId).toBe('page-canonical') @@ -79,9 +95,10 @@ describe('createBrowserSlice annotations', () => { }) it('records browser-tab-created only for the explicit new-tab action', async () => { - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith( 'browser-tab-created' ) @@ -92,13 +109,14 @@ describe('createBrowserSlice annotations', () => { }) it('opens a local sign-in tab with the imported browser profile', async () => { - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() await expect( store .getState() .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') ).resolves.toBe(true) + await main.settle() expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toMatchObject({ url: 'https://accounts.google.com/', @@ -106,9 +124,10 @@ describe('createBrowserSlice annotations', () => { }) }) - it('retains saved page annotations while retiring geometry when the URL changes', () => { - const store = createTestStore() + it('retains saved page annotations while retiring geometry when the URL changes', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -126,9 +145,10 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) - it('can commit a navigation URL without hiding an active recovery error', () => { - const store = createTestStore() + it('can commit a navigation URL without hiding an active recovery error', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -151,9 +171,10 @@ describe('createBrowserSlice annotations', () => { }) }) - it('keeps certificate challenges transient across navigation, success, and close', () => { - const store = createTestStore() + it('keeps certificate challenges transient across navigation, success, and close', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://localhost:3443/') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -181,31 +202,56 @@ describe('createBrowserSlice annotations', () => { store.getState().setBrowserPageCertificateFailure(pageId, failure) store.getState().closeBrowserTab(tab.id) + await main.settle() expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() }) - it('creates inactive browser unified tabs without stealing the visible tab', () => { - const store = createTestStore() - - store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false }) - - expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( - 'wt-1', - 'browser', - expect.objectContaining({ activate: false }) - ) - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() - }) - - it('creates pages cold so a deferred guest is never owed a navigation', () => { - const store = createTestStore() + it('creates inactive browser unified tabs without stealing the visible tab', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false }) - store.getState().createBrowserPage(tab.id, 'https://example.com/second', { activate: false }) + await main.settle() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main opens the tab and its frame draws it; the window leaves it unselected. + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual([ + expect.objectContaining({ browserTabId: tab.id, groupId: 'group-1' }) + ]) + expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ contentType: 'browser', entityId: tab.id }) + ]) + expect(store.getState().activateTab).not.toHaveBeenCalled() + } else { + expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( + 'wt-1', + 'browser', + expect.objectContaining({ activate: false }) + ) + } + // A background open leaves the worktree's remembered browser tab unset. + expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() + expect(store.getState().activeTabType).toBe('terminal') + expect(store.getState().activeBrowserTabId).toBeNull() + }) + + it('creates pages cold so a deferred guest is never owed a navigation', async () => { + const { store, main } = await createTestStoreDrawnFromMain() + + const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { + activate: false + }) + await main.settle() + const second = store + .getState() + .createBrowserPage(tab.id, 'https://example.com/second', { activate: false }) + await main.settle() + + // A layout-owner build's main adds the page and its frame draws it. + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId: second?.id })] : [] + ) // Why: only a live guest reports loading; a background page has none until first shown. expect(store.getState().browserPagesByWorkspace[tab.id]?.map((page) => page.loading)).toEqual([ false, @@ -214,8 +260,8 @@ describe('createBrowserSlice annotations', () => { expect(tab.loading).toBe(false) }) - it('uses local browser profile defaults for client-local fallback pages', () => { - const store = createTestStore() + it('uses local browser profile defaults for client-local fallback pages', async () => { + const { store, main } = await createTestStoreDrawnFromMain() store.setState({ settings: settingsWithRuntime('env-1'), defaultBrowserSessionProfileIdByHostId: { @@ -227,19 +273,22 @@ describe('createBrowserSlice annotations', () => { const localFallback = store.getState().createBrowserTab('wt-1', 'about:blank', { browserRuntimeEnvironmentId: null }) + await main.settle() const remoteTab = store.getState().createBrowserTab('wt-1', 'about:blank', { browserRuntimeEnvironmentId: 'env-1' }) + await main.settle() expect(localFallback.sessionProfileId).toBe('local-profile') expect(remoteTab.sessionProfileId).toBe('runtime-profile') }) - it('preserves browser map references when a page-state update is unchanged', () => { - const store = createTestStore() + it('preserves browser map references when a page-state update is unchanged', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -264,11 +313,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree) }) - it('persists a captured favicon with history and refreshes it with the page state', () => { - const store = createTestStore() + it('persists a captured favicon with history and refreshes it with the page state', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -283,11 +333,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon) }) - it('clears a stale history favicon when a page reports none, and keeps it when none is reported', () => { - const store = createTestStore() + it('clears a stale history favicon when a page reports none, and keeps it when none is reported', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -330,11 +381,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserUrlHistory).toBe(revisited) }) - it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => { - const store = createTestStore() + it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -350,11 +402,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree) }) - it('repairs stale active browser workspace metadata on an otherwise unchanged page update', () => { - const store = createTestStore() + it('repairs stale active browser workspace metadata on an otherwise unchanged page update', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -393,11 +446,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace) }) - it('updates the active browser unified-tab label without a second tab-label write', () => { - const store = createTestStore() + it('updates the active browser unified-tab label without a second tab-label write', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -410,11 +464,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().setTabLabel).not.toHaveBeenCalled() }) - it('updates inactive browser pages without relabeling or rebuilding the workspace map', () => { - const store = createTestStore() + it('updates inactive browser pages without relabeling or rebuilding the workspace map', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const activePageId = tab.activePageId if (!activePageId) { throw new Error('Expected a new browser page') @@ -425,9 +480,15 @@ describe('createBrowserSlice annotations', () => { title: 'Inactive', activate: false }) + await main.settle() if (!inactivePage) { throw new Error('Expected inactive browser page') } + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [expect.objectContaining({ browserTabId: tab.id, pageId: inactivePage.id })] + : [] + ) seedUnifiedBrowserTab(store, tab.id, 'Example') const browserPagesByWorkspace = store.getState().browserPagesByWorkspace const browserTabsByWorktree = store.getState().browserTabsByWorktree @@ -450,9 +511,10 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().setTabLabel).not.toHaveBeenCalled() }) - it('caps stored browser annotations per page', () => { - const store = createTestStore() + it('caps stored browser annotations per page', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -467,9 +529,10 @@ describe('createBrowserSlice annotations', () => { expect(annotations[0]?.id).toBe('annotation-3') }) - it('sanitizes persistent annotation payloads at the store boundary', () => { - const store = createTestStore() + it('sanitizes persistent annotation payloads at the store boundary', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -496,9 +559,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.payload.screenshot).toBeNull() }) - it('updates an existing annotation comment and intent', () => { - const store = createTestStore() + it('updates an existing annotation comment and intent', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -514,9 +578,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.intent).toBe('change') }) - it('sanitizes an oversized comment when updating an annotation', () => { - const store = createTestStore() + it('sanitizes an oversized comment when updating an annotation', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -533,9 +598,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.comment).toHaveLength(GRAB_BUDGET.annotationCommentMaxLength) }) - it('is a no-op when updating an annotation id that does not exist', () => { - const store = createTestStore() + it('is a no-op when updating an annotation id that does not exist', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -553,15 +619,17 @@ describe('createBrowserSlice annotations', () => { }) describe('createBrowserSlice floating tabs', () => { - it('tracks new floating browser tabs without changing the main browser surface', () => { - const store = createTestStore() + it('tracks new floating browser tabs without changing the main browser surface', async () => { + const { store, main } = await createTestStoreDrawnFromMain() store.setState({ activeWorktreeId: 'wt-1', activeTabType: 'terminal' } as Partial) const mainTab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const activeTabTypeBeforeFloating = store.getState().activeTabType const tab = store.getState().createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', { focusAddressBar: true }) + await main.settle() expect(store.getState().activeBrowserTabId).toBe(mainTab.id) expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBe(mainTab.id) @@ -574,30 +642,46 @@ describe('createBrowserSlice floating tabs', () => { }) describe('createBrowserSlice closed browser workspaces', () => { - it('reopens duplicate-URL browser pages on the originally active page', () => { - const store = createTestStore() + it('reopens duplicate-URL browser pages on the originally active page', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com/dashboard', { title: 'First copy' }) + await main.settle() const secondPage = store.getState().createBrowserPage(tab.id, 'https://example.com/dashboard', { title: 'Second copy' }) + await main.settle() if (!secondPage) { throw new Error('Expected a second browser page') } store.getState().closeBrowserTab(tab.id) + await main.settle() const restored = store.getState().reopenClosedBrowserTab('wt-1') + // Main draws the tab, then the window sends its second page. + await main.settle() + await main.settle() if (!restored) { throw new Error('Expected a reopened browser workspace') } const restoredPages = store.getState().browserPagesByWorkspace[restored.id] ?? [] - const activePage = restoredPages.find((page) => page.id === restored.activePageId) + // The returned record is as proposed; the drawn one carries the reselected page. + const activePageId = store + .getState() + .browserTabsByWorktree['wt-1']?.find((entry) => entry.id === restored.id)?.activePageId + const activePage = restoredPages.find((page) => page.id === activePageId) expect(restoredPages.map((page) => page.url)).toEqual([ 'https://example.com/dashboard', 'https://example.com/dashboard' ]) expect(activePage?.title).toBe('Second copy') + // A layout-owner build's main closed the tab, then reopened it with both pages. + expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? ['openBrowserTab', 'openBrowserPage', 'closeTabs', 'openBrowserTab', 'openBrowserPage'] + : [] + ) }) }) 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 f3ac38d44a8..f15cfc4b897 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -13,8 +13,13 @@ 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 { landOnEmptiedWorktree } from '../tabs/tabs-close-actions' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordBrowserTabClose } from './browser-layout-owner-commands' +import { + closeBrowserTabThroughLayoutOwner, + withDrawnBrowserPages, + type DrawnBrowserTab +} from './browser-tab-close-through-layout-owner' export function createBrowserCloseActions( set: BrowserSliceSet, @@ -25,220 +30,246 @@ export function createBrowserCloseActions( // Why: a cleanup close unwinds a tab that never finished being created — it owns no host // page to close and must not enter the reopen stack as if the user had closed something. const isCleanup = options?.reason === 'cleanup' - let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - // Why collected rather than released inside the reducer: revoking is main-process work, and a - // grant is the only authority the preview scheme honors — a closed document must stop being - // readable, and it must stop being readable even if the reducer bails out below. - let docPageIdsToRelease: string[] = [] - let activeBrowserWorktreeIdToNotify: string | null = null - set((s) => { - let owningWorktreeId: string | null = null - let closedWorkspace: BrowserWorkspace | null = null - const nextBrowserTabsByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const removedTab = tabs.find((tab) => tab.id === tabId) ?? null - const filtered = tabs.filter((tab) => tab.id !== tabId) - if (filtered.length !== tabs.length) { - owningWorktreeId = worktreeId - closedWorkspace = removedTab + const closeHere = (drawn?: DrawnBrowserTab): void => { + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + // Why collected rather than released inside the reducer: revoking is main-process work, and a + // grant is the only authority the preview scheme honors — a closed document must stop being + // readable, and it must stop being readable even if the reducer bails out below. + let docPageIdsToRelease: string[] = [] + let activeBrowserWorktreeIdToNotify: string | null = null + set((current) => { + const s = drawn ? withDrawnBrowserPages(current, drawn) : current + // Main's frame already dropped a drawn tab's record. + let owningWorktreeId: string | null = drawn?.worktreeId ?? null + let closedWorkspace: BrowserWorkspace | null = drawn?.record ?? null + const nextBrowserTabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const removedTab = tabs.find((tab) => tab.id === tabId) ?? null + const filtered = tabs.filter((tab) => tab.id !== tabId) + if (filtered.length !== tabs.length) { + owningWorktreeId = worktreeId + closedWorkspace = removedTab + } + if (filtered.length > 0) { + nextBrowserTabsByWorktree[worktreeId] = filtered + } } - if (filtered.length > 0) { - nextBrowserTabsByWorktree[worktreeId] = filtered + if (!owningWorktreeId || !closedWorkspace) { + return current } - } - if (!owningWorktreeId || !closedWorkspace) { - return s - } - const closedPages = s.browserPagesByWorkspace[tabId] ?? [] - const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } - delete nextBrowserPagesByWorkspace[tabId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - for (const page of closedPages) { - delete nextBrowserAnnotationsByPageId[page.id] - delete nextBrowserAnnotationMarkerIdsByPageId[page.id] - delete nextBrowserCertificateFailuresByPageId[page.id] - } - docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) - remotePagesToClose = isCleanup - ? [] - : closedPages.flatMap((page) => { - const handle = s.remoteBrowserPageHandlesByPageId[page.id] - return handle ? [{ worktreeId: page.worktreeId, handle }] : [] - }) - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - for (const page of closedPages) { - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - - const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { - const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) - nextActiveBrowserTabIdByWorktree[owningWorktreeId] = - (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) - ? neighborId - : remainingBrowserTabs[0]?.id) ?? null - } - - const nextTabBarOrder = { - ...s.tabBarOrderByWorktree, - [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( - (entryId) => entryId !== tabId - ) - } - - const isActiveTabInOwningWorktree = - s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId - if (isActiveTabInOwningWorktree) { - activeBrowserWorktreeIdToNotify = owningWorktreeId - } - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - let nextActiveTabType = s.activeTabType - if (remainingBrowserTabs.length === 0) { - const fallbackTabType = getFallbackTabTypeForWorktree( - owningWorktreeId, - s.openFiles, - s.tabsByWorktree - ) - nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType - if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { - nextActiveTabType = fallbackTabType + const closedPages = s.browserPagesByWorkspace[tabId] ?? [] + const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } + delete nextBrowserPagesByWorkspace[tabId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + for (const page of closedPages) { + delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserAnnotationMarkerIdsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] + } + docPageIdsToRelease = closedPages + .filter((page) => page.docLocation) + .map((page) => page.id) + remotePagesToClose = isCleanup + ? [] + : closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] } - } - const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } - if (!isCleanup) { - const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] - const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) - nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ - { - workspace: closedWorkspace, - pages: closedPages, - ...(position ? { position } : {}) - }, - ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) - ].slice(0, 10) - } - const nextRecentlyClosedTabKindsByWorktree = isCleanup - ? s.recentlyClosedTabKindsByWorktree - : pushRecentlyClosedTabKind( - s.recentlyClosedTabKindsByWorktree, + const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { + const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = + (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) + ? neighborId + : remainingBrowserTabs[0]?.id) ?? null + } + + const nextTabBarOrder = { + ...s.tabBarOrderByWorktree, + [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( + (entryId) => entryId !== tabId + ) + } + + const isActiveTabInOwningWorktree = + s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId + if (isActiveTabInOwningWorktree) { + activeBrowserWorktreeIdToNotify = owningWorktreeId + } + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + let nextActiveTabType = s.activeTabType + if (remainingBrowserTabs.length === 0) { + const fallbackTabType = getFallbackTabTypeForWorktree( owningWorktreeId, - 'browser' + s.openFiles, + s.tabsByWorktree ) + nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType + if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { + nextActiveTabType = fallbackTabType + } + } - const nextRecentlyClosedBrowserPagesByWorkspace = { - ...s.recentlyClosedBrowserPagesByWorkspace - } - delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] + const nextRecentlyClosedBrowserTabsByWorktree = { + ...s.recentlyClosedBrowserTabsByWorktree + } + if (!isCleanup) { + const existingSnapshots = + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + const position = drawn + ? drawn.position + : getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ + { + workspace: closedWorkspace, + pages: closedPages, + ...(position ? { position } : {}) + }, + ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) + ].slice(0, 10) + } + const nextRecentlyClosedTabKindsByWorktree = isCleanup + ? s.recentlyClosedTabKindsByWorktree + : pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) - // Why omit rather than rebuild: only the closed ids can match, so this touches the - // closed pages instead of every pending entry and keeps the record identity when none did. - const closedPageIds = closedPages.map((page) => page.id) - const nextPendingAddressBarFocusByPageId = omitRecordKeys( - s.pendingAddressBarFocusByPageId, - closedPageIds - ) - const nextPendingAddressBarFocusByTabId = omitRecordKeys(s.pendingAddressBarFocusByTabId, [ - ...closedPageIds, - tabId - ]) + const nextRecentlyClosedBrowserPagesByWorkspace = { + ...s.recentlyClosedBrowserPagesByWorkspace + } + delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - browserPagesByWorkspace: nextBrowserPagesByWorkspace, - activeBrowserTabId: - s.activeBrowserTabId === tabId - ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) - : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - tabBarOrderByWorktree: nextTabBarOrder, - activeTabType: nextActiveTabType, - pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId - } - }) + // Why omit rather than rebuild: only the closed ids can match, so this touches the + // closed pages instead of every pending entry and keeps the record identity when none did. + const closedPageIds = closedPages.map((page) => page.id) + const nextPendingAddressBarFocusByPageId = omitRecordKeys( + s.pendingAddressBarFocusByPageId, + closedPageIds + ) + const nextPendingAddressBarFocusByTabId = omitRecordKeys( + s.pendingAddressBarFocusByTabId, + [...closedPageIds, tabId] + ) - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + browserPagesByWorkspace: nextBrowserPagesByWorkspace, + activeBrowserTabId: + s.activeBrowserTabId === tabId + ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) + : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrder, + activeTabType: nextActiveTabType, + pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } + }) - for (const docPageId of docPageIdsToRelease) { - releaseDocPreviewGrant(docPageId) - } - - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - 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' + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents ) } - if (workspaceItem) { - get().closeUnifiedTab( - workspaceItem.id, - isCleanup - ? { preserveWorktreeSelection: true, recordInteraction: false } - : options?.preserveWorktreeSelection - ? { preserveWorktreeSelection: true } - : undefined - ) - } - } - // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. - if (activeBrowserWorktreeIdToNotify) { - const state = get() - const activeWorkspaceId = - state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] - const activeWorkspace = activeWorkspaceId - ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) - : null - const activePage = activeWorkspace?.activePageId - ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( - (page) => page.id === activeWorkspace.activePageId + for (const docPageId of docPageIdsToRelease) { + releaseDocPreviewGrant(docPageId) + } + + // Main's frame already dropped a drawn tab's entry. + for (const tabs of drawn ? [] : Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'browser' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab( + workspaceItem.id, + isCleanup + ? { preserveWorktreeSelection: true, recordInteraction: false } + : options?.preserveWorktreeSelection + ? { preserveWorktreeSelection: true } + : undefined ) - : undefined - if ( - activeWorkspace?.activePageId && - isLocalBrowserPageOwner( - state, - activeBrowserWorktreeIdToNotify, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) - .catch(() => {}) + } + } + + // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. + if (activeBrowserWorktreeIdToNotify) { + const state = get() + const activeWorkspaceId = + state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] + const activeWorkspace = activeWorkspaceId + ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) + : null + const activePage = activeWorkspace?.activePageId + ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( + (page) => page.id === activeWorkspace.activePageId + ) + : undefined + if ( + activeWorkspace?.activePageId && + isLocalBrowserPageOwner( + state, + activeBrowserWorktreeIdToNotify, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) + .catch(() => {}) + } } } + // Switch point: main closes the tab first; today's close then runs over the tab as drawn. + const owningWorktreeId = ORCA_LAYOUT_OWNER_IS_MAIN + ? findWorkspace(get().browserTabsByWorktree, tabId)?.worktreeId + : undefined + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + closeBrowserTabThroughLayoutOwner( + get, + tabId, + isCleanup ? 'cleanup' : 'user', + (drawn) => closeHere(drawn), + // As today's tab-bar close: a cleanup keeps the selection and counts no interaction. + isCleanup || options?.preserveWorktreeSelection || !owningWorktreeId + ? undefined + : () => landOnEmptiedWorktree(set, get, owningWorktreeId) + ) + ) { + if (!isCleanup) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return + } + closeHere() }, shutdownWorktreeBrowsers: (worktreeId, options) => { 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 5dd58f5f382..73493b750bf 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -8,7 +8,7 @@ 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' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' import { selectFolderWorkspaces } from '../../workspace-record-selectors' export function createBrowserHydrationActions( @@ -76,38 +76,48 @@ export function createBrowserHydrationActions( }, switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { - set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const tabIndex = tabs.findIndex((t) => t.id === workspaceId) - if (tabIndex !== -1) { - const updatedTabs = [...tabs] - updatedTabs[tabIndex] = { - ...updatedTabs[tabIndex], - sessionProfileId: profileId, - sessionPartition: sessionPartition ?? null - } - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: updatedTabs + const switchHere = (): void => + set((s) => { + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const tabIndex = tabs.findIndex((t) => t.id === workspaceId) + if (tabIndex !== -1) { + const updatedTabs = [...tabs] + updatedTabs[tabIndex] = { + ...updatedTabs[tabIndex], + sessionProfileId: profileId, + sessionPartition: sessionPartition ?? null + } + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: updatedTabs + } } } } - } - 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, { + return s + }) + // Switch point: main switches the tab's profile; its frame shows the change. + const tab = ORCA_LAYOUT_OWNER_IS_MAIN + ? findWorkspace(get().browserTabsByWorktree, workspaceId) + : null + if ( + tab && + sendBrowserLayoutCommand( + get, + tab.worktreeId, + { type: 'setBrowserTabProfile', browserTabId: workspaceId, profileId, sessionPartition: sessionPartition ?? null - }) - } + }, + { here: switchHere } + ) + ) { + return } + switchHere() } } } diff --git a/src/renderer/src/store/slices/browser/browser-hydration-state.ts b/src/renderer/src/store/slices/browser/browser-hydration-state.ts index 1e069116ff5..93e99c902f1 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-state.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-state.ts @@ -23,7 +23,9 @@ export function projectHydratedBrowserSession( | 'tabsByWorktree' >, session: WorkspaceSessionState, - validWorktreeIds: ReadonlySet + validWorktreeIds: ReadonlySet, + /** A workspace with no remembered browser tab keeps none, instead of restoring its first. */ + options: { unchosenStaysUnset?: true } = {} ) { const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} @@ -113,7 +115,9 @@ export function projectHydratedBrowserSession( activeBrowserTabIdByWorktree[worktreeId] = persistedTabId && validBrowserTabIds.has(persistedTabId) ? persistedTabId - : (tabs[0]?.id ?? null) + : !persistedTabId && options.unchosenStaysUnset + ? null + : (tabs[0]?.id ?? null) } const activeWorktreeId = s.activeWorktreeId 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 index 02af41e8a13..4fd33a0c202 100644 --- a/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts +++ b/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts @@ -1,56 +1,52 @@ -// 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. +// A browser page create, close, convert or profile switch in a layout-owner build: the window sends +// the command first and changes nothing until main answers. Main's frame shows the tab's own +// fields (its pages, its profile); the window then writes what only it holds (each page's state). +// A refusal leaves the tab as it was. -import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' -import { recordLayoutApply, type sendLayoutApply } from '@/runtime/layout-apply-send' -import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement' -import type { AppState } from '../../types' +import { sendLayoutApply, type WindowLayoutCommand } from '@/runtime/layout-apply-send' +import { beginPendingTab } from '@/runtime/layout-owner-pending-tabs' +import type { BrowserSliceGet } from './browser-slice-contract' -type BrowserCommandState = Parameters[0] & - Pick +type BrowserLayoutCommand = Extract< + WindowLayoutCommand, + { type: 'openBrowserPage' | 'closeBrowserPage' | 'replaceBrowserPage' | 'setBrowserTabProfile' } +> -/** 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 - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openBrowserTab', - tabId: entry.id, - browserTabId: tab.id, - pageId, - profileId: tab.sessionProfileId ?? null, - sessionPartition: tab.sessionPartition ?? null, - // Open to the side: main makes the window's new split with the tab. - ...drawnTabPlacement(state, tab.worktreeId, entry.groupId, entry.id), - createdAt: entry.createdAt, - browserCreatedAt: tab.createdAt - }) -} - -/** The close of a browser tab whose tab-bar entry the window is about to remove. */ -export function recordBrowserTabClose( - state: BrowserCommandState, +/** + * False when main is not asked (a paired server owns the workspace's layout): the caller writes + * the change as today. `here` is today's write, for a twin main may route to a paired server; + * `shown` runs once main's frame is in. + */ +export function sendBrowserLayoutCommand( + get: BrowserSliceGet, 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 - }) + command: BrowserLayoutCommand, + write: { here: () => void; shown?: () => void } +): boolean { + // A page being added is pending on its tab: a caller acting on the tab's pages waits for it. + const settle = + command.type === 'openBrowserPage' + ? beginPendingTab(command.pageId, worktreeId, command.browserTabId, { kind: 'open' }) + : () => {} + const sent = sendLayoutApply(get(), worktreeId, command, { + apply: () => { + try { + write.shown?.() + } finally { + settle(command.browserTabId) + } + }, + asToday: () => { + try { + write.here() + } finally { + settle(command.browserTabId) + } + }, + refused: () => settle(null) + }) + if (!sent) { + settle(null) } -} - -function browserTabEntry(state: BrowserCommandState, worktreeId: string, browserTabId: string) { - return state.unifiedTabsByWorktree[worktreeId]?.find( - (entry) => entry.contentType === 'browser' && entry.entityId === browserTabId - ) + return sent } 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 59aca0e2cc1..b267728534d 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 @@ -13,7 +13,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos 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' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' export function createBrowserPageConversionActions( set: BrowserSliceSet, @@ -40,102 +40,122 @@ export function createBrowserPageConversionActions( ? runtimeEnvironmentIdOrLocalUnlessTwin(get(), oldPageForOwnership.worktreeId) : null ) - let converted: BrowserPage | null = null - let docPageIdToRelease: string | null = null - let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null - set((s) => { - const plan = planBrowserPageConversion(s, pageId, target, options) - if (!plan) { - return s - } - converted = plan.newPage - // Why collected rather than released inside the reducer: revoking is main-process work, and - // it must happen exactly once even if a later set() retries the reducer. - if (plan.oldPage.docLocation) { - docPageIdToRelease = plan.oldPage.id - } - const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] - if (remoteHandle) { - remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } - } - const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } - delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - delete nextBrowserAnnotationsByPageId[plan.oldPage.id] - delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] - const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } - delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [plan.workspace.id]: plan.nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [plan.workspace.worktreeId]: ( - s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] - ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId - } - }) - // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does - // not track, so the initializers' null narrowing would otherwise read these as never. - const newPage = converted as BrowserPage | null - const remoteClose = remotePageToClose as { - worktreeId: string - handle: RemoteBrowserPageHandle - } | null - if (!newPage) { - return null - } - if (remoteClose) { - closeRemoteBrowserPageInOwningEnvironment( - remoteClose.worktreeId, - remoteClose.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - // Why after the reducer: a closed document must stop being readable, and the grant is the only - // authority the preview scheme honors — but the store row has to stop naming it first. - if (docPageIdToRelease) { - 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 + const convertHere = (newPageId?: string): BrowserPage | null => { + let converted: BrowserPage | null = null + let docPageIdToRelease: string | null = null + let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null + set((s) => { + const plan = planBrowserPageConversion(s, pageId, target, { + ...options, + ...(newPageId ? { newPageId } : {}) + }) + if (!plan) { + return s + } + converted = plan.newPage + // Why collected rather than released inside the reducer: revoking is main-process work, and + // it must happen exactly once even if a later set() retries the reducer. + if (plan.oldPage.docLocation) { + docPageIdToRelease = plan.oldPage.id + } + const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] + if (remoteHandle) { + remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } + } + const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } + delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] + const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } + delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [plan.workspace.id]: plan.nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [plan.workspace.worktreeId]: ( + s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] + ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } }) - } - if (workspaceAfter?.activePageId === newPage.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find( - (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId - ) - if (item) { - get().setTabLabel(item.id, newPage.title) + // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does + // not track, so the initializers' null narrowing would otherwise read these as never. + const newPage = converted as BrowserPage | null + const remoteClose = remotePageToClose as { + worktreeId: string + handle: RemoteBrowserPageHandle + } | null + if (!newPage) { + return null } + if (remoteClose) { + closeRemoteBrowserPageInOwningEnvironment( + remoteClose.worktreeId, + remoteClose.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + // Why after the reducer: a closed document must stop being readable, and the grant is the only + // authority the preview scheme honors — but the store row has to stop naming it first. + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) + if (workspaceAfter?.activePageId === newPage.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find( + (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId + ) + if (item) { + get().setTabLabel(item.id, newPage.title) + } + } + return newPage } - return newPage + // Switch point: main puts the new page in the old one's place; the page shows once it did. + const sentPlan = ORCA_LAYOUT_OWNER_IS_MAIN + ? planBrowserPageConversion(get(), pageId, target, options) + : null + if ( + sentPlan && + sendBrowserLayoutCommand( + get, + sentPlan.workspace.worktreeId, + { + type: 'replaceBrowserPage', + browserTabId: sentPlan.workspace.id, + pageId, + newPageId: sentPlan.newPage.id + }, + { + here: () => convertHere(sentPlan.newPage.id), + shown: () => convertHere(sentPlan.newPage.id) + } + ) + ) { + return sentPlan.newPage + } + return convertHere() }, reopenClosedBrowserPage: (workspaceId) => { 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 03ec28ddf11..6c92fbd1ff4 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 @@ -15,7 +15,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos 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' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' export function createBrowserPageCreateActions( set: BrowserSliceSet, @@ -39,189 +39,212 @@ export function createBrowserPageCreateActions( if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) { admitBrowserPageMount(page.id) } - - set((s) => { - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - const shouldActivate = options?.activate ?? true - const nextPages = [...pages, page] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const shouldUpdateGlobalActiveSurface = - shouldActivate && - s.activeWorktreeId === workspace.worktreeId && - s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId - const shouldFocusAddressBar = - shouldUpdateGlobalActiveSurface && - !page.docLocation && - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - }, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId + const addPage = (): void => { + // The tab may have closed while main was asked. + const current = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!current) { + return } - }) + set((s) => { + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + const shouldActivate = options?.activate ?? true + const nextPages = [...pages, page] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...current, + activePageId: shouldActivate ? page.id : (current.activePageId ?? page.id), + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const shouldUpdateGlobalActiveSurface = + shouldActivate && + s.activeWorktreeId === current.worktreeId && + s.activeBrowserTabIdByWorktree[current.worktreeId] === workspaceId + const shouldFocusAddressBar = + shouldUpdateGlobalActiveSurface && + !page.docLocation && + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - // 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 + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [current.worktreeId]: (s.browserTabsByWorktree[current.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + }, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } }) - } - const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (nextWorkspace?.activePageId === page.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, page.title) + + const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (nextWorkspace?.activePageId === page.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, page.title) + } } } + // Switch point: main adds the page to the tab first; the window shows it once main did. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + sendBrowserLayoutCommand( + get, + workspace.worktreeId, + { type: 'openBrowserPage', browserTabId: workspaceId, pageId: page.id }, + { here: addPage, shown: addPage } + ) + ) { + return page + } + addPage() return page }, closeBrowserPage: (pageId) => { - let closedWorkspaceIdForLabel: string | null = null - let docPageIdToRelease: string | null = null - const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - closedWorkspaceIdForLabel = page.workspaceId - docPageIdToRelease = page.docLocation ? page.id : null - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const nextPages = currentPages.filter((entry) => entry.id !== pageId) - const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) - const nextActivePageId = - workspace.activePageId === pageId - ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) - : workspace.activePageId - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: nextActivePageId, - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] - if (remoteHandle) { - remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) - } - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - delete nextBrowserAnnotationsByPageId[pageId] - delete nextBrowserAnnotationMarkerIdsByPageId[pageId] - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - delete nextBrowserCertificateFailuresByPageId[pageId] + const closeHere = (): void => { + let closedWorkspaceIdForLabel: string | null = null + let docPageIdToRelease: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + closedWorkspaceIdForLabel = page.workspaceId + docPageIdToRelease = page.docLocation ? page.id : null + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.filter((entry) => entry.id !== pageId) + const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) + const nextActivePageId = + workspace.activePageId === pageId + ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) + : workspace.activePageId + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: nextActivePageId, + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + delete nextBrowserAnnotationsByPageId[pageId] + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspace.id]: [ - page, - ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( - (entry) => entry.id !== page.id + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) ) - ].slice(0, 10) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + }, + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspace.id]: [ + page, + ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( + (entry) => entry.id !== page.id + ) + ].slice(0, 10) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) } - }) - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) + if (item && workspace) { + get().setTabLabel(item.id, workspace.title) + } + } + const page = ORCA_LAYOUT_OWNER_IS_MAIN + ? findPage(get().browserPagesByWorkspace, pageId) + : null + const owner = page ? findWorkspace(get().browserTabsByWorktree, page.workspaceId) : null + // Switch point: main drops the page from its tab first; the window closes it once main did. + if ( + page && + owner && + sendBrowserLayoutCommand( + get, + owner.worktreeId, + { type: 'closeBrowserPage', browserTabId: owner.id, pageId }, + { here: closeHere, shown: closeHere } ) - } - - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - - const closedWorkspaceId = closedWorkspaceIdForLabel - if (!closedWorkspaceId) { + ) { 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) - if (item && workspace) { - get().setTabLabel(item.id, workspace.title) - } + closeHere() } } } 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 23cd123ed5d..57d2a14d0a0 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -1,10 +1,11 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' -import { - FLOATING_TERMINAL_WORKTREE_ID, - ORCA_BROWSER_BLANK_URL -} from '../../../../../shared/constants' -import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + CreateBrowserTabOptions +} from './browser-slice-contract' import { assertManagedBrowserMaterializationAllowed, getClientCreationActionPolicy @@ -18,14 +19,20 @@ import { import { getBrowserSessionProfileHostId } from './browser-host-state' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission' -import { ownsGlobalSelection } from '../../global-selection-owner' import { getRegisteredPairedBrowserTabCreator, 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' +import { openBrowserTabThroughLayoutOwner } from './browser-tab-open-through-layout-owner' +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import type { AppState } from '../../types' +import { ownsGlobalSelection } from '../../global-selection-owner' export function createBrowserTabActions( set: BrowserSliceSet, @@ -80,105 +87,56 @@ export function createBrowserTabActions( options?.sessionPartition ) - set((s) => { - const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const nextTabBarOrder = (() => { - const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] - const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = s.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => file.id) - const browserIds = existingTabs.map((tab) => tab.id) - const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) - const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) - const inBase = new Set(base) - for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { - if (!inBase.has(entryId)) { - base.push(entryId) - inBase.add(entryId) - } - } - base.push(workspaceId) - return base - })() - - const shouldActivate = options?.activate ?? true - const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId) - const shouldFocusFloatingTab = - shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID - const shouldFocusAddressBar = - (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && - // Why the doc check and not just the url: a document page is blank by construction, and - // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. - !page.docLocation && - (options?.focusAddressBar ?? - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) - - return { + const drawHere = (): void => { + set((s) => ({ browserTabsByWorktree: { ...s.browserTabsByWorktree, - [worktreeId]: [...existingTabs, browserTab] + [worktreeId]: [...(s.browserTabsByWorktree[worktreeId] ?? []), browserTab] }, browserPagesByWorkspace: { ...s.browserPagesByWorkspace, [workspaceId]: [page] }, - tabBarOrderByWorktree: { - ...s.tabBarOrderByWorktree, - [worktreeId]: nextTabBarOrder - }, - activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: shouldActivate - ? workspaceId - : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) - }, - activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, - activeTabTypeByWorktree: shouldActivate - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } - : s.activeTabTypeByWorktree, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [workspaceId]: true, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) + ...browserTabOpenView(s, worktreeId, browserTab, page, options) + })) - const state = get() - const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === workspaceId - ) - if (!alreadyHasUnifiedTab) { - const shouldActivate = options?.activate ?? true - const created = state.createUnifiedTab(worktreeId, 'browser', { - entityId: workspaceId, - label: browserTab.title, - targetGroupId: options?.targetGroupId, - ...(options?.split ? { split: options.split } : {}), - ...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}), - // Why no routing-host default: a substituted host would disown the wrapper from its worktree. - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - activate: shouldActivate - }) - // Why: unified creation already selected the tab and recorded the visit; only the group moves. - if (shouldActivate && created) { - get().focusGroup(worktreeId, created.groupId) + const state = get() + const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === workspaceId + ) + if (!alreadyHasUnifiedTab) { + const shouldActivate = options?.activate ?? true + const created = state.createUnifiedTab(worktreeId, 'browser', { + entityId: workspaceId, + label: browserTab.title, + targetGroupId: options?.targetGroupId, + ...(options?.split ? { split: options.split } : {}), + ...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}), + // Why no routing-host default: a substituted host would disown the wrapper from its worktree. + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + activate: shouldActivate + }) + // Why: unified creation already selected the tab and recorded the visit; only the group moves. + if (shouldActivate && created) { + 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) + // Switch point: main opens the tab under these ids and its frame draws it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + openBrowserTabThroughLayoutOwner(get, { + worktreeId, + browserTab, + page, + options, + showView: () => set((s) => browserTabOpenView(s, worktreeId, browserTab, page, options)), + drawHere + }) + ) { + return browserTab } + drawHere() return browserTab }, @@ -281,3 +239,73 @@ export function createBrowserTabActions( } } } + +// The window's own view of a browser tab it opens: its tab-bar order entry, browser selection and +// the address-bar focus a blank New Tab asks for; a layout-owner build writes it once drawn. +function browserTabOpenView( + s: AppState, + worktreeId: string, + browserTab: BrowserWorkspace, + page: BrowserPage, + options: CreateBrowserTabOptions | undefined +): Partial { + const workspaceId = browserTab.id + // Without this tab: a frame may already have drawn its record. + const existingTabs = (s.browserTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.id !== workspaceId + ) + const nextTabBarOrder = (() => { + const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] + const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = s.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((file) => file.id) + const ids = [...terminalIds, ...editorIds, ...existingTabs.map((tab) => tab.id)] + const allExistingIds = new Set(ids) + const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) + const inBase = new Set(base) + return [...base, ...[...allExistingIds].filter((entryId) => !inBase.has(entryId)), workspaceId] + })() + + const shouldActivate = options?.activate ?? true + const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId) + const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const shouldFocusAddressBar = + (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && + // Why the doc check and not just the url: a document page is blank by construction, and + // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. + !page.docLocation && + (options?.focusAddressBar ?? + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) + + return { + tabBarOrderByWorktree: { + ...s.tabBarOrderByWorktree, + [worktreeId]: nextTabBarOrder + }, + activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: shouldActivate + ? workspaceId + : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + }, + activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, + activeTabTypeByWorktree: shouldActivate + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } + : s.activeTabTypeByWorktree, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [workspaceId]: true, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts b/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts new file mode 100644 index 00000000000..3cd1f6d5ff1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts @@ -0,0 +1,90 @@ +// A browser tab close in a layout-owner build: main closes the tab-bar entry and its record first, +// and its frame drops both. The window then runs today's close over the tab as it drew it (the +// reopen list, host pages, document grants), since the frame already took the record away. + +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' +import type { AppState } from '../../types' +import { pickNextActiveTab } from '../tab-group-state' +import { + getRecentlyClosedTabPosition, + type RecentlyClosedTabPosition +} from '../recently-closed-tab-position' +import { closeContentTabThroughLayoutOwner } from '../tabs/content-tab-close-through-layout-owner' +import type { BrowserSliceGet, RemoteBrowserPageHandle } from './browser-slice-contract' + +/** A browser tab as the window drew it when it asked main to close it. */ +export type DrawnBrowserTab = { + worktreeId: string + record: BrowserWorkspace + pages: BrowserPage[] + handles: Record + /** Where it sat, for the reopen list; the frame has moved the rest on. */ + position: RecentlyClosedTabPosition | undefined +} + +/** `state` with the tab's pages and host handles back as drawn, for today's close to read. */ +export function withDrawnBrowserPages(state: AppState, drawn: DrawnBrowserTab): AppState { + const { record } = drawn + return { + ...state, + browserPagesByWorkspace: { + ...state.browserPagesByWorkspace, + [record.id]: state.browserPagesByWorkspace[record.id] ?? drawn.pages + }, + remoteBrowserPageHandlesByPageId: { + ...drawn.handles, + ...state.remoteBrowserPageHandlesByPageId + } + } +} + +/** + * Sends the close of the browser tab showing `browserTabId`; false when it shows in no tab-bar + * entry or main is not asked (a paired server owns the workspace's layout): close it as today. + * `closeHere` is today's close; given the drawn tab, it runs once main's frame dropped it. + */ +export function closeBrowserTabThroughLayoutOwner( + get: BrowserSliceGet, + browserTabId: string, + reason: 'user' | 'cleanup', + closeHere: (drawn?: DrawnBrowserTab) => void, + landIfEmptied?: () => boolean +): boolean { + const state = get() + const entry = Object.values(state.unifiedTabsByWorktree) + .flat() + .find((tab) => tab.contentType === 'browser' && tab.entityId === browserTabId) + const record = entry + ? state.browserTabsByWorktree[entry.worktreeId]?.find((tab) => tab.id === browserTabId) + : undefined + if (!entry || !record) { + return false + } + const pages = state.browserPagesByWorkspace[browserTabId] ?? [] + const drawn: DrawnBrowserTab = { + worktreeId: entry.worktreeId, + record, + pages, + handles: Object.fromEntries( + pages.flatMap((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [[page.id, handle] as const] : [] + }) + ), + position: getRecentlyClosedTabPosition(state, entry.worktreeId, browserTabId) + } + const tabs = state.unifiedTabsByWorktree[entry.worktreeId] ?? [] + const group = state.groupsByWorktree[entry.worktreeId]?.find((g) => g.id === entry.groupId) + const nextActiveTabId = + group?.activeTabId === entry.id + ? pickNextActiveTab(dedupeTabOrder(group.tabOrder), group.recentTabIds, entry.id, tabs) + : (group?.activeTabId ?? null) + return closeContentTabThroughLayoutOwner(get, entry, { + nextActiveTabId, + recordClose: reason, + closeHere: () => closeHere(), + closed: () => closeHere(drawn), + ...(landIfEmptied ? { landIfEmptied } : {}) + }) +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts index a0cbbb2819e..91dcbbac557 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts @@ -7,6 +7,8 @@ import type { import { findWorkspace } from '../browser-page-records' import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs' import { isLocalBrowserPageOwner } from './browser-host-state' +import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { afterPendingEntityTabs, hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs' export function createBrowserTabFocusActions( set: BrowserSliceSet, @@ -34,6 +36,10 @@ export function createBrowserTabFocusActions( if (!entryToRestore) { return null } + /** The restored record; a layout-owner build's, before main's frame drew it, as proposed. */ + const restoredRecord = (restored: BrowserWorkspace): BrowserWorkspace | null => + get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? + (hasPendingEntityTab(worktreeId, restored.id) ? restored : null) const snap = entryToRestore.workspace const pages = entryToRestore.pages @@ -49,10 +55,12 @@ export function createBrowserTabFocusActions( ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), targetGroupId: entryToRestore.position?.groupId }) - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - return ( - get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + const position = entryToRestore.position + // A layout-owner build's tab moves once main's frame drew it. + afterPendingEntityTabs(worktreeId, restored.id, () => + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position) ) + return restoredRecord(restored) } // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. @@ -67,29 +75,34 @@ export function createBrowserTabFocusActions( ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) }) - for (const p of restPages) { - get().createBrowserPage(restored.id, p.url, { - activate: false, - title: p.title, - browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, - ...(p.docLocation ? { docLocation: p.docLocation } : {}) - }) - } - - // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. - const activePageId = snap.activePageId - if (activePageId) { - const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] - const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) - const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null - if (targetPage && targetPage.id !== restoredPages[0]?.id) { - get().setActiveBrowserPage(restored.id, targetPage.id) + const position = entryToRestore.position + const selectRestoredPage = (): void => { + // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. + const activePageId = snap.activePageId + if (activePageId) { + const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] + const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) + const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null + if (targetPage && targetPage.id !== restoredPages[0]?.id) { + get().setActiveBrowserPage(restored.id, targetPage.id) + } } + + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position) } - - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + // A layout-owner build adds the pages once main drew the tab, and selects once it added them. + afterPendingEntityTabs(worktreeId, restored.id, () => { + for (const p of restPages) { + get().createBrowserPage(restored.id, p.url, { + activate: false, + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, + ...(p.docLocation ? { docLocation: p.docLocation } : {}) + }) + } + afterPendingEntityTabs(worktreeId, restored.id, selectRestoredPage) + }) + return restoredRecord(restored) }, setActiveBrowserTab: (tabId, targetWorktreeId) => { diff --git a/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts b/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts new file mode 100644 index 00000000000..5eddb9edb2c --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts @@ -0,0 +1,75 @@ +// A browser tab open in a layout-owner build: the window proposes the tab-bar entry, record and +// page ids and sends the open; main's frame draws the tab with the window's page state, and only +// then does the window select it. A caller acting on the record waits with `afterPendingTab`. + +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { contentTabOpenPlacement } from '../tabs/content-tab-open-placement' +import type { BrowserSliceGet, CreateBrowserTabOptions } from './browser-slice-contract' + +type BrowserTabOpen = { + worktreeId: string + browserTab: BrowserWorkspace + page: BrowserPage + options: CreateBrowserTabOptions | undefined + /** The window's own view of the tab, written once main's frame drew it. */ + showView: () => void + /** Today's draw, for a twin main may route to a paired server. */ + drawHere: () => void +} + +/** False when main is not asked (a paired server owns the workspace's layout): draw as today. */ +export function openBrowserTabThroughLayoutOwner( + get: BrowserSliceGet, + open: BrowserTabOpen +): boolean { + const { worktreeId, browserTab, page, options } = open + const state = get() + const tabId = createBrowserUuid() + const placement = contentTabOpenPlacement(state, worktreeId, tabId, { + targetGroupId: options?.targetGroupId, + afterTabId: options?.afterTabId, + split: options?.split, + executionHostId: options?.executionHostId + }) + if (!placement) { + return false + } + const activate = options?.activate ?? true + return openContentTabThroughLayoutOwner(state, { + worktreeId, + entityId: browserTab.id, + command: { + type: 'openBrowserTab', + tabId, + browserTabId: browserTab.id, + pageId: page.id, + profileId: browserTab.sessionProfileId ?? null, + sessionPartition: browserTab.sessionPartition ?? null, + ...placement + }, + facts: { label: browserTab.title, browser: { record: browserTab, pages: [page] } }, + shown: (shownId) => { + open.showView() + if (!activate) { + return + } + get().activateTab(shownId, { worktreeId }) + const groupId = get().unifiedTabsByWorktree[worktreeId]?.find( + (t) => t.id === shownId + )?.groupId + if (groupId) { + get().focusGroup(worktreeId, groupId) + } + }, + drawHere: () => { + open.drawHere() + return ( + get().unifiedTabsByWorktree[worktreeId]?.find( + (t) => t.contentType === 'browser' && t.entityId === browserTab.id + )?.id ?? null + ) + } + }) +} diff --git a/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts b/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts new file mode 100644 index 00000000000..abb69f63efd --- /dev/null +++ b/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts @@ -0,0 +1,384 @@ +// Browser, chat and simulator tabs in a layout-owner build (ORCA_LAYOUT_OWNER=main): the window +// sends each open, close and page change to main first and draws the tab only from main's frame, +// so nothing shows before main answers and a refused close leaves the tab. Switched off, the +// window draws them itself and sends nothing. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +const mocks = vi.hoisted((): { store: unknown } => ({ store: null })) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreCascadesMockApi() + +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' +import { resetLocalStructuredSessionVersionForTests } from '@/runtime/local-structured-session-tabs-sync/inventory-generation-fence' + +const WT = 'repo1::/path/wt1' +const ROOT = 'group-root' + +let store: ReturnType +let main: TabsDrawnFromMain + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store = createTestStore() + mocks.store = store + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + activeWorktreeId: WT, + groupsByWorktree: { [WT]: [{ id: ROOT, worktreeId: WT, activeTabId: null, tabOrder: [] }] }, + activeGroupIdByWorktree: { [WT]: ROOT }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: ROOT } }, + unifiedTabsByWorktree: { [WT]: [] } + }) + main = await drawTabsFromMain(store) +}) + +afterEach(() => { + main.close() + resetLocalStructuredSessionVersionForTests() +}) + +const tabs = () => store.getState().unifiedTabsByWorktree[WT] ?? [] +const tabOf = (entityId: string) => tabs().find((tab) => tab.entityId === entityId) +const mainTabs = () => layoutApplyTestTransport.mainModel?.workspaces[WT]?.tabs ?? [] + +function hostListing(chats: string[]): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: chats.map((sessionId) => ({ + type: 'agent-session' as const, + id: `agent-session:${sessionId}`, + title: 'Codex Chat', + sessionId, + agent: 'codex' as const, + isActive: false + })) + } +} + +const TERMINAL = 'terminal-1' + +/** The workspace with one terminal in the root group, drawn from main again. */ +async function redrawWithTerminal(): Promise { + main.close() + seedStore(store, { + tabsByWorktree: { + [WT]: [ + { + id: TERMINAL, + ptyId: null, + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: TERMINAL, + entityId: TERMINAL, + groupId: ROOT, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WT]: [{ id: ROOT, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL] }] + } + }) + main = await drawTabsFromMain(store) +} + +/** Opens with main's answer held back, then answers it; returns the opened entity id. */ +async function openHeld(open: () => string): Promise { + layoutApplyTestTransport.answerWith('pending') + const entityId = open() + await main.settle() + // Nothing drawn before main answers. + expect(tabOf(entityId)).toBeUndefined() + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + return entityId +} + +describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs drawn from main’s frames', () => { + it('shows a browser tab, its label and page only once main’s frame drew it, then selects it', async () => { + const id = await openHeld( + () => store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Example' }).id + ) + const entry = tabOf(id)! + expect(entry).toMatchObject({ contentType: 'browser', label: 'Example', groupId: ROOT }) + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')[0]).toMatchObject({ + tabId: entry.id, + browserTabId: id, + groupId: ROOT + }) + const pages = store.getState().browserPagesByWorkspace[id]! + expect(pages.map((page) => page.url)).toEqual(['https://example.com/']) + expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({ + id, + pageIds: [pages[0]!.id], + title: 'Example' + }) + expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(entry.id) + expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(id) + }) + + it('closes a browser tab once main did, keeps it on the reopen list, and reopens it', async () => { + const id = store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Ex' }).id + await main.settle() + layoutApplyTestTransport.answerWith('pending') + store.getState().closeBrowserTab(id) + await main.settle() + expect(tabOf(id)).toBeDefined() + + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + expect(tabOf(id)).toBeUndefined() + expect(store.getState().browserTabsByWorktree[WT] ?? []).toEqual([]) + expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({ + recordClose: 'user' + }) + expect( + store.getState().recentlyClosedBrowserTabsByWorktree[WT]?.map((entry) => entry.workspace.id) + ).toEqual([id]) + + const reopened = store.getState().reopenClosedBrowserTab(WT) + await main.settle() + expect(reopened).not.toBeNull() + expect(tabOf(reopened!.id)).toMatchObject({ label: 'Ex' }) + expect(store.getState().browserPagesByWorkspace[reopened!.id]?.[0]?.url).toBe( + 'https://example.com/' + ) + }) + + it('keeps a cleanup close off the reopen list, in main and in the window', async () => { + const id = store.getState().createBrowserTab(WT, 'about:blank').id + await main.settle() + store.getState().closeBrowserTab(id, { reason: 'cleanup' }) + await main.settle() + expect(tabOf(id)).toBeUndefined() + expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({ + recordClose: 'cleanup' + }) + expect(store.getState().recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([]) + }) + + it('adds, converts and closes a page and switches a profile only once main did', async () => { + const id = store.getState().createBrowserTab(WT, 'https://a.test/').id + await main.settle() + layoutApplyTestTransport.answerWith('pending') + const page = store.getState().createBrowserPage(id, 'https://b.test/')! + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]).toHaveLength(1) + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).toContain(page.id) + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual([ + expect.objectContaining({ browserTabId: id, pageId: page.id }) + ]) + + store.getState().closeBrowserPage(page.id) + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).not.toContain(page.id) + + store.getState().switchBrowserTabProfile(id, 'profile-2', 'persist:profile-2') + await main.settle() + expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({ + sessionProfileId: 'profile-2', + sessionPartition: 'persist:profile-2' + }) + expect(mainTabs().filter((tab) => tab.kind === 'browser')).toHaveLength(1) + }) + + it('opens and closes a chat tab through main, and keeps one main refused to close', async () => { + const sessionId = await openHeld(() => { + openStructuredAgentSessionProvisionalTab({ + worktreeId: WT, + executionHostId: 'local', + sessionId: 'chat-1', + agent: 'codex' + }) + return 'chat-1' + }) + const chat = tabOf(sessionId)! + expect(chat).toMatchObject({ contentType: 'agent-session', label: expect.any(String) }) + expect(chat.label).not.toBe('') + expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(chat.id) + + // A deliberate freeze refuses before anything changes, so the tab stays. + layoutApplyTestTransport.answerWith({ refuse: 'wsl_move_in_progress' }) + store.getState().closeUnifiedTab(chat.id) + await main.settle() + expect(tabOf(sessionId)).toBeDefined() + + layoutApplyTestTransport.answerWith('apply') + store.getState().closeUnifiedTab(chat.id) + await main.settle() + expect(tabOf(sessionId)).toBeUndefined() + expect(mainTabs().some((tab) => tab.entityId === sessionId)).toBe(false) + }) + + it('closes a window-only tab of a workspace main does not hold, as main’s tab_not_found does', async () => { + const OTHER = 'repo1::/path/wt2' + const tab = { + id: 'browser-tab-1', + entityId: 'browser-1', + groupId: 'group-other', + worktreeId: OTHER, + contentType: 'browser' as const, + label: 'Page', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + seedStore(store, { + unifiedTabsByWorktree: { ...store.getState().unifiedTabsByWorktree, [OTHER]: [tab] }, + groupsByWorktree: { + ...store.getState().groupsByWorktree, + [OTHER]: [{ id: 'group-other', worktreeId: OTHER, activeTabId: tab.id, tabOrder: [tab.id] }] + } + }) + layoutApplyTestTransport.answerWith({ refuse: 'workspace_not_found' }) + + store.getState().closeUnifiedTab(tab.id) + await main.settle() + + expect(layoutApplyTestTransport.sentOfType('closeTabs')).toEqual([ + expect.objectContaining({ workspace: OTHER, tabIds: [tab.id] }) + ]) + expect(store.getState().unifiedTabsByWorktree[OTHER] ?? []).toEqual([]) + layoutApplyTestTransport.answerWith('apply') + }) + + it('draws a listing’s new chats in order once main places them; the listing sends nothing', async () => { + await redrawWithTerminal() + applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])]) + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) + expect(tabOf('chat-a')).toBeUndefined() + + // Main places listed chats itself (chatTabLayoutCommand): in the active + // group, in listing order. + const commands = ['chat-a', 'chat-b'].map((sessionId) => ({ + type: 'openAgentSessionTab' as const, + workspace: WT, + tabId: structuredAgentSessionTabId(sessionId), + sessionId, + agent: 'codex' as const, + groupId: store.getState().activeGroupIdByWorktree[WT] + })) + for (const command of commands) { + await layoutApplyTestTransport.call({ command }) + } + await main.settle() + expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([ + TERMINAL, + structuredAgentSessionTabId('chat-a'), + structuredAgentSessionTabId('chat-b') + ]) + expect(mainTabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + }) + + it('opens a simulator tab to the right through main and surfaces it once drawn', async () => { + store.getState().createBrowserTab(WT, 'about:blank') + await main.settle() + layoutApplyTestTransport.answerWith('pending') + const tabId = ensureSimulatorTab(WT, { placement: 'rightSplit' })! + // A second ask while main opens it names the same tab. + expect(ensureSimulatorTab(WT, { placement: 'rightSplit' })).toBe(tabId) + await main.settle() + expect(tabs().some((tab) => tab.id === tabId)).toBe(false) + + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + const simulator = tabs().find((tab) => tab.id === tabId)! + expect(simulator).toMatchObject({ contentType: 'simulator' }) + expect(simulator.groupId).not.toBe(ROOT) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(simulator.groupId) + expect(layoutApplyTestTransport.sentOfType('openSimulatorTab')).toHaveLength(1) + + store.getState().closeUnifiedTab(tabId) + await main.settle() + expect(tabs().some((tab) => tab.id === tabId)).toBe(false) + }) +}) + +describe.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs with the switch off', () => { + it('draws browser, chat and simulator tabs at once and sends nothing', () => { + const id = store.getState().createBrowserTab(WT, 'https://example.com/').id + openStructuredAgentSessionProvisionalTab({ + worktreeId: WT, + executionHostId: 'local', + sessionId: 'chat-1', + agent: 'codex' + }) + const simulator = ensureSimulatorTab(WT) + expect(tabOf(id)).toBeDefined() + expect(tabOf('chat-1')).toBeDefined() + expect(tabs().some((tab) => tab.id === simulator)).toBe(true) + store.getState().closeBrowserTab(id) + expect(tabOf(id)).toBeUndefined() + expect(layoutApplyTestTransport.sent).toEqual([]) + }) + + it('draws a listing’s new chats itself, in order, and sends nothing', async () => { + await redrawWithTerminal() + applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])]) + expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + expect(layoutApplyTestTransport.sent).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts b/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts new file mode 100644 index 00000000000..f2ff177a3b7 --- /dev/null +++ b/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts @@ -0,0 +1,183 @@ +// A workspace a paired server owns (its row names the server, as the app's catalog sets it): its +// browser and chat tabs open and close as today in both builds, and never reach main's layout. +// An SSH worktree is laid out by this machine's main but has no host to run a chat on. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type * as RpcClientModule from '@/runtime/runtime-rpc-client' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +const mocks = vi.hoisted(() => ({ + store: ((): unknown => null)(), + hostCalls: ((): { target: RuntimeClientTarget; method: string }[] => [])() +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + callRuntimeRpc: (target: RuntimeClientTarget, method: string, params?: unknown) => { + if (target.kind === 'local' && method === 'layout.apply') { + return layoutApplyTestTransport.call(params) + } + mocks.hostCalls.push({ target, method }) + return Promise.resolve({ ok: true }) + }, + ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) + } +}) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +createStoreCascadesMockApi() + +import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' +import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' + +const SERVER = 'server-1' +const SERVER_WT = 'repo-s::/srv/app' +const LOCAL_WT = 'repo-l::/work/app' +const SSH_WT = 'repo-h::/home/app' +const SSH_HOST = toSshExecutionHostId('box') + +let store: ReturnType +let main: TabsDrawnFromMain + +function group(worktreeId: string) { + return [{ id: `group-${worktreeId}`, worktreeId, activeTabId: null, tabOrder: [] }] +} + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.hostCalls = [] + store = createTestStore() + mocks.store = store + seedStore(store, { + worktreesByRepo: { + 'repo-s': [ + makeWorktree({ + id: SERVER_WT, + repoId: 'repo-s', + path: '/srv/app', + hostId: toRuntimeExecutionHostId(SERVER) + }) + ], + 'repo-l': [makeWorktree({ id: LOCAL_WT, repoId: 'repo-l', path: '/work/app' })], + 'repo-h': [ + makeWorktree({ id: SSH_WT, repoId: 'repo-h', path: '/home/app', hostId: SSH_HOST }) + ] + }, + activeWorktreeId: SERVER_WT, + groupsByWorktree: { + [SERVER_WT]: group(SERVER_WT), + [LOCAL_WT]: group(LOCAL_WT), + [SSH_WT]: group(SSH_WT) + }, + activeGroupIdByWorktree: { + [SERVER_WT]: `group-${SERVER_WT}`, + [LOCAL_WT]: `group-${LOCAL_WT}`, + [SSH_WT]: `group-${SSH_WT}` + }, + layoutByWorktree: { + [SERVER_WT]: { type: 'leaf', groupId: `group-${SERVER_WT}` }, + [LOCAL_WT]: { type: 'leaf', groupId: `group-${LOCAL_WT}` }, + [SSH_WT]: { type: 'leaf', groupId: `group-${SSH_WT}` } + }, + unifiedTabsByWorktree: { [SERVER_WT]: [], [LOCAL_WT]: [], [SSH_WT]: [] } + }) + main = await drawTabsFromMain(store) +}) + +afterEach(() => { + main.close() +}) + +const tabsOf = (worktreeId: string) => store.getState().unifiedTabsByWorktree[worktreeId] ?? [] +const sentFor = (worktreeId: string) => + layoutApplyTestTransport.sent.filter((command) => command.workspace === worktreeId) + +describe("a paired server's workspace", () => { + it('opens and closes a browser tab without main', async () => { + const browserTab = store.getState().createBrowserTab(SERVER_WT, 'https://example.com') + await main.settle() + const drawn = tabsOf(SERVER_WT).find((tab) => tab.entityId === browserTab.id) + expect(drawn).toMatchObject({ contentType: 'browser' }) + + store.getState().closeBrowserTab(browserTab.id) + await main.settle() + + expect(tabsOf(SERVER_WT).some((tab) => tab.entityId === browserTab.id)).toBe(false) + expect(sentFor(SERVER_WT)).toEqual([]) + }) + + it('opens and closes a chat tab without main', async () => { + const chat = openStructuredAgentSessionProvisionalTab({ + worktreeId: SERVER_WT, + executionHostId: toRuntimeExecutionHostId(SERVER), + sessionId: 'chat-1', + agent: 'codex' + }) + await main.settle() + expect(tabsOf(SERVER_WT).map((tab) => tab.id)).toEqual([chat.id]) + + store.getState().closeUnifiedTab(chat.id) + await main.settle() + + expect(tabsOf(SERVER_WT)).toEqual([]) + expect(sentFor(SERVER_WT)).toEqual([]) + // The close still reaches the server that runs the chat. + await vi.waitFor(() => + expect(mocks.hostCalls).toContainEqual({ + target: { kind: 'environment', environmentId: SERVER }, + method: 'session.tabs.close' + }) + ) + }) + + it.skipIf(!ORCA_LAYOUT_OWNER_IS_MAIN)( + "presence: this machine's workspace sends the same opens and closes to main", + async () => { + const browserTab = store.getState().createBrowserTab(LOCAL_WT, 'https://example.com') + await main.settle() + store.getState().closeBrowserTab(browserTab.id) + const chat = openStructuredAgentSessionProvisionalTab({ + worktreeId: LOCAL_WT, + executionHostId: 'local', + sessionId: 'chat-2', + agent: 'codex' + }) + await main.settle() + store.getState().closeUnifiedTab(chat.id) + await main.settle() + + expect(sentFor(LOCAL_WT).map((command) => command.type)).toEqual([ + 'openBrowserTab', + 'closeTabs', + 'openAgentSessionTab', + 'closeTabs' + ]) + } + ) +}) + +describe('an SSH worktree', () => { + it("has no host to run a chat on, where this machine's worktree has its own", () => { + expect(resolveStructuredAgentSessionOwner(store.getState(), SSH_WT)).toBeNull() + expect(resolveStructuredAgentSessionOwner(store.getState(), LOCAL_WT)).toBe('local') + }) +}) diff --git a/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts b/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts index 419099580d3..f382c3e51b1 100644 --- a/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts +++ b/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts @@ -363,6 +363,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('editor tabs drawn from main’s frame expect(store.getState().openFiles.map((file) => file.id)).toContain(a) }) + it('drops a tab’s content when main lost its workspace before the close came in', async () => { + const a = openFile('/a.ts') + await main.settle() + store.getState().setEditorDraft(a, 'draft') + const tabId = tabFor(a)!.id + layoutApplyTestTransport.answerWith((command) => + command.type === 'closeTabs' ? 'pending' : 'apply' + ) + + store.getState().closeFile(a) + // Main drops the tab on its own (its workspace went away), then answers the window's close. + layoutApplyTestTransport.answerWith('apply') + await layoutApplyTestTransport.call({ + command: { type: 'closeTabs', workspace: WT, tabIds: [tabId] } + }) + layoutApplyTestTransport.answerPending({ ok: false, code: 'workspace_not_found' }) + await main.settle() + + expect(tabFor(a)).toBeUndefined() + expect(store.getState().openFiles.map((file) => file.id)).not.toContain(a) + expect(store.getState().editorDrafts[a]).toBeUndefined() + }) + it('reopens a closed tab at its place with one open (index)', async () => { const a = openFile('/a.ts') const b = openFile('/b.ts') diff --git a/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts b/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts index be5d7db397e..f2b6e3c6300 100644 --- a/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts +++ b/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts @@ -1,12 +1,9 @@ -// Editor tab closes in a layout-owner build: main closes the tab and its frame drops it; the window -// changes no tab first, so a close main refuses leaves the tab. Once the frame is in, the group -// shows the tab today's close picks (its previous one). Waiters on the tab learn the outcome. +// Editor tabs whose file record the window dropped, in a layout-owner build: main closes them. +// A single tab's close goes through `closeContentTabThroughLayoutOwner`. -import type { Tab } from '../../../../../../shared/tab-types' import { sendLayoutApply } from '@/runtime/layout-apply-send' import { afterPendingTabs, - beginPendingTab, hasPendingEntityTab, isTabClosePending, markPendingCloseCleansFile, @@ -14,92 +11,8 @@ import { } from '@/runtime/layout-owner-pending-tabs' import type { AppState } from '../../../types' import type { OpenFile } from '../types/open-file' -import { pickNextActiveTab } from '../../tab-group-state' import { isEditorTabContentType } from './editor-tab-content-type' -/** - * Once main's frame dropped the closed tab, its group shows the tab today's close picks (one - * picked before that is itself gone falls back to the group's history), and the workspace's - * surface follows its active group. Nothing while the tab still shows. - */ -function selectAfterClose( - get: () => AppState, - tab: Pick, - nextActiveTabId: string | null -): void { - const state = get() - const worktreeId = tab.worktreeId - if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) { - return - } - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId) - const next = - group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId) - ? nextActiveTabId - : group - ? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id) - : null - if (next && group?.activeTabId !== next) { - state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false }) - } - // Activation focuses the tab's group; the user's focused group stays, and shows its surface. - if (activeGroupId) { - get().focusGroup(worktreeId, activeGroupId) - } -} - -/** - * Sends an editor tab's close; false when it is no editor tab or main is not asked (a paired - * server owns the workspace's layout), and the caller closes it as today. `closeHere` is today's - * close, for a twin main may route to a paired server. - */ -export function closeEditorTabThroughLayoutOwner( - get: () => AppState, - tab: Pick, - /** The tab today's close would show next in the group, picked before the close. */ - nextActiveTabId: string | null, - closeHere: () => void -): boolean { - if (!isEditorTabContentType(tab.contentType)) { - return false - } - // Its waiters (closeFile's cleanup) stay on the close already in flight. - if (isTabClosePending(tab.id)) { - return true - } - const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' }) - const sent = sendLayoutApply( - get(), - tab.worktreeId, - { type: 'closeTabs', tabIds: [tab.id] }, - { - apply: (result) => { - const closed = result.closed?.includes(tab.id) ?? true - // Waiters first: a closeFile's content cleanup must not undo the selection. - settle(closed ? tab.id : null) - if (closed) { - selectAfterClose(get, tab, nextActiveTabId) - } - }, - asToday: () => { - let closedId: string | null = null - try { - closeHere() - closedId = tab.id - } finally { - settle(closedId) - } - }, - refused: () => settle(null) - } - ) - if (!sent) { - settle(null) - } - return sent -} - /** * closeFile's tabs: main closes each, and the file's content goes once every close is in, unless * one was refused or the file reopened meanwhile. A repeat close joins the one in flight; so does diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index ca3c982eeac..349fe77d104 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -6,9 +6,10 @@ * its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep, * the bulk worktree purge, and the single removeWorktree teardown. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -24,6 +25,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = { worktrees: { list: vi.fn().mockResolvedValue([]), @@ -85,10 +98,15 @@ function seedDrafts(store: ReturnType): void { } describe('nativeChatLaunchDraftByTabId teardown', () => { + let main: TabsDrawnFromMain | undefined beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) }) + afterEach(() => { + main?.close() + main = undefined + }) it('closeTab drops the closed tab’s draft only', () => { const store = createTestStore() @@ -241,15 +259,18 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { return chat } - it('closing a structured chat tab keeps its conversation’s unsent draft', () => { + it('closing a structured chat tab keeps its conversation’s unsent draft', async () => { const store = createTestStore() const chat = seedStructuredChat(store) + main = await drawTabsFromMain(store) const conversation = structuredAgentSessionDraftScopeKey('session-1') updateNativeChatComposerDraft(conversation, { text: 'unsent' }, 'immediate') store.getState().closeUnifiedTab(chat.id) + await main.settle() - expect(store.getState().unifiedTabsByWorktree[WT1]).toEqual([]) + // Main's frame drops a workspace with no tabs left; the window keeps an empty list. + expect(store.getState().unifiedTabsByWorktree[WT1] ?? []).toEqual([]) expect(readNativeChatComposerDraft(conversation).text).toBe('unsent') }) diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index 86c391c89d3..22d9d00e494 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -48,6 +48,7 @@ import { restoreRecentlyClosedTabPosition } from './recently-closed-tabs' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture' import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync' import { endLayoutFramePositions } from '@/runtime/layout-frame-position' @@ -440,6 +441,8 @@ describe('restoreRecentlyClosedTabPosition', () => { describe('reopenClosedTab cross-type MRU', () => { it('reopens closed tabs of mixed kinds in most-recent-first order', async () => { const store = makeSeededStore() + // A layout-owner build draws the browser tab from main's frames. + const main = await drawTabsFromMain(store) // Close order: editor → terminal → browser. store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] }) @@ -450,7 +453,9 @@ describe('reopenClosedTab cross-type MRU', () => { store.getState().closeTab(terminal.id) await layoutApplyTestTransport.settle() const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' }) + await main.settle() store.getState().closeBrowserTab(browser.id) + await main.settle() expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([ 'browser', @@ -460,6 +465,7 @@ describe('reopenClosedTab cross-type MRU', () => { // First press: browser comes back. expect(store.getState().reopenClosedTab(WT)).toBe(true) + await main.settle() expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com') expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0) @@ -474,6 +480,7 @@ describe('reopenClosedTab cross-type MRU', () => { // Nothing left. expect(store.getState().reopenClosedTab(WT)).toBe(false) + main.close() }) it('skips kind entries whose per-type snapshot has drained', () => { diff --git a/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts b/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts index ef17ec9deb3..a925e1135a9 100644 --- a/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts @@ -4,6 +4,7 @@ import { installActiveWorktreeRemovalSuccessor } from '@/lib/active-worktree-rem import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { clearSshHostWorkspaces } from '@/components/sidebar/ssh-host-remove-workspaces' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const storeAccess = vi.hoisted(() => ({ getState: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: storeAccess })) @@ -20,6 +21,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -34,10 +46,13 @@ beforeEach(() => { removeForHost.mockReset().mockResolvedValue(undefined) }) let dispose: (() => void) | null = null +let layoutMain: TabsDrawnFromMain | undefined afterEach(() => { dispose?.() dispose = null + layoutMain?.close() + layoutMain = undefined }) const active = makeWorktree({ id: 'repo1::/active', repoId: 'repo1', hostId: 'ssh:host' }) @@ -83,9 +98,12 @@ describe('deleting the active workspace', () => { it('respects a user closing the last browser tab after startup dropped its row', async () => { const store = setup() + layoutMain = await drawTabsFromMain(store) const tabId = store.getState().createBrowserTab(active.id, 'https://example.com') + await layoutMain.settle() store.setState({ worktreesByRepo: { repo1: [sibling] } }) store.getState().closeBrowserTab(tabId.id) + await layoutMain.settle() await Promise.resolve() expect(store.getState().activeWorktreeId).toBeNull() expect(activateAndRevealWorktree).not.toHaveBeenCalled() diff --git a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts index 5f154b41d41..aa5d0fb7e29 100644 --- a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, @@ -12,6 +12,7 @@ import { } from './store-test-helpers' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) @@ -36,8 +37,26 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } }) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = createStoreCascadesMockApi() +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN) const itSwitchOn = it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN) @@ -197,7 +216,7 @@ describe('setActiveWorktree', () => { expect(s.activeTabId).toBe(terminalTabId) }) - it('promotes the next tab in the focused split into the global active surface on close', () => { + it('promotes the next tab in the focused split into the global active surface on close', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' const terminalTabId = 'terminal-1' @@ -268,8 +287,10 @@ describe('setActiveWorktree', () => { }, activeGroupIdByWorktree: { [wt]: groupId } }) + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(browserTabId) + await main.settle() const s = store.getState() expect(s.groupsByWorktree[wt]?.[0]?.activeTabId).toBe(terminalTabId) diff --git a/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts b/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts index 1ccc070b008..361f408cf75 100644 --- a/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, @@ -10,6 +10,7 @@ import { seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) @@ -33,8 +34,31 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } }) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = createStoreCascadesMockApi() +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** Every change is drawn once this resolves; at once with the switch off. */ +async function settled(): Promise { + await main?.settle() +} + describe('setActiveWorktree', () => { beforeEach(() => { vi.clearAllMocks() @@ -394,7 +418,7 @@ describe('setActiveWorktree', () => { expect(s.activeFileId).toBe(fileId) }) - it('does not switch the global surface when creating a browser tab for a background worktree', () => { + it('does not switch the global surface when creating a browser tab for a background worktree', async () => { const store = createTestStore() const activeWt = 'repo1::/path/wt1' const backgroundWt = 'repo1::/path/wt2' @@ -413,10 +437,12 @@ describe('setActiveWorktree', () => { [backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })] } }) + main = await drawTabsFromMain(store) const browserTab = store .getState() .createBrowserTab(backgroundWt, 'https://example.com', { activate: true }) + await settled() const s = store.getState() expect(s.activeTabType).toBe('terminal') @@ -424,7 +450,7 @@ describe('setActiveWorktree', () => { expect(s.activeBrowserTabIdByWorktree[backgroundWt]).toBe(browserTab.id) }) - it('uses unified MRU selection when closing an active browser tab', () => { + it('uses unified MRU selection when closing an active browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -434,20 +460,27 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com') + await settled() store.getState().createBrowserTab(wt, 'https://neighbor.example.com') + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com') + await settled() store.getState().setActiveBrowserTab(previous.id) + await settled() store.getState().setActiveBrowserTab(closing.id) + await settled() store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabId).toBe(previous.id) expect(store.getState().activeBrowserTabIdByWorktree[wt]).toBe(previous.id) }) - it('keeps the unified MRU target when closing the last browser tab', () => { + it('keeps the unified MRU target when closing the last browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -457,18 +490,23 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) store.getState().createTab(wt) + await settled() const previous = store.getState().createTab(wt) + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com') + await settled() store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeTabId).toBe(previous.id) expect(store.getState().activeTabType).toBe('terminal') }) - it('keeps a valid browser target for an inactive worktree after close', () => { + it('keeps a valid browser target for an inactive worktree after close', async () => { const store = createTestStore() const activeWt = 'repo1::/path/active' const backgroundWt = 'repo1::/path/background' @@ -482,23 +520,30 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: activeWt }) + main = await drawTabsFromMain(store) const previous = store .getState() .createBrowserTab(backgroundWt, 'https://previous.example.com', { activate: false }) + await settled() const closing = store.getState().createBrowserTab(backgroundWt, 'https://closing.example.com', { activate: false }) + await settled() store.setState({ activeBrowserTabIdByWorktree: { [backgroundWt]: closing.id } }) + // Main loads the window's edited layout, as it would from a saved session. + main?.close() + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabIdByWorktree[backgroundWt]).toBe(previous.id) }) - it('keeps the global browser target when closing a legacy tab without a unified wrapper', () => { + it('keeps the global browser target when closing a legacy tab without a unified wrapper', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -508,25 +553,32 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com', { activate: false }) + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com', { activate: false }) + await settled() store.setState({ unifiedTabsByWorktree: {}, activeBrowserTabId: closing.id, activeBrowserTabIdByWorktree: { [wt]: closing.id } }) + // Main loads the window's edited layout, as it would from a saved session. + main?.close() + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabId).toBe(previous.id) }) - it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', () => { + it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -540,15 +592,17 @@ describe('setActiveWorktree', () => { [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] } }) + main = await drawTabsFromMain(store) const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true) expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(true) expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(false) }) - it('does not queue address-bar focus for background or already-navigated browser tabs', () => { + it('does not queue address-bar focus for background or already-navigated browser tabs', async () => { const store = createTestStore() const activeWt = 'repo1::/path/wt1' const backgroundWt = 'repo1::/path/wt2' @@ -567,19 +621,22 @@ describe('setActiveWorktree', () => { [backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })] } }) + main = await drawTabsFromMain(store) const backgroundBlankTab = store .getState() .createBrowserTab(backgroundWt, 'about:blank', { activate: true }) + await settled() const activeNavigatedTab = store .getState() .createBrowserTab(activeWt, 'https://example.com', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[backgroundBlankTab.id]).toBeUndefined() expect(store.getState().pendingAddressBarFocusByTabId[activeNavigatedTab.id]).toBeUndefined() }) - it('drops a pending address-bar focus request when the new browser tab closes before mount', () => { + it('drops a pending address-bar focus request when the new browser tab closes before mount', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -593,11 +650,14 @@ describe('setActiveWorktree', () => { [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] } }) + main = await drawTabsFromMain(store) const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true) store.getState().closeBrowserTab(browserTab.id) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts b/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts index a0f994bb0b2..a205aa16d40 100644 --- a/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts +++ b/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts @@ -5,7 +5,8 @@ import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split' -import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { contentTabOpenPlacement } from './tabs/content-tab-open-placement' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -133,11 +134,15 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu split: { sourceGroupId: editorGroup, splitDirection: 'down' } }) const terminalGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id - store.getState().createBrowserTab(WT, 'about:blank', { + // The browser tab and its split are drawn from main's frame. + const browser = store.getState().createBrowserTab(WT, 'about:blank', { split: { sourceGroupId: ROOT, splitDirection: 'left' } }) - const browserGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id - expect(await settledSent()).toEqual([ + const sentNow = await settledSent() + const browserGroup = store + .getState() + .unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === browser.id)!.groupId + expect(sentNow).toEqual([ expect.objectContaining({ type: 'createTerminalTab', tabId: onlyTabOf(terminalGroup), @@ -191,17 +196,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu [ 'chat', () => { - // As openStructuredAgentSessionProvisionalTab does, on this test's store. - const tab = store.getState().createUnifiedTab(WT, 'agent-session', { - entityId: 'chat-1', - agentSessionAgent: 'codex', + // As openAgentSessionTabThroughLayoutOwner does, on this test's store. + const tabId = 'agent-session:chat-1' + const placement = contentTabOpenPlacement(store.getState(), WT, tabId, { targetGroupId: ROOT + })! + openContentTabThroughLayoutOwner(store.getState(), { + worktreeId: WT, + entityId: 'chat-1', + command: { + type: 'openAgentSessionTab', + tabId, + sessionId: 'chat-1', + agent: 'codex', + ...placement + }, + facts: { label: 'Codex' }, + shown: () => {}, + drawHere: () => null }) - recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab) } ] ])( - 'records a %s tab opened into the emptied root after its neighbour split again', + 'opens a %s tab into the emptied root after its neighbour split again', async (_kind, openIntoRoot) => { // The splits beside the emptied root hold tabs, so main has only those. store.getState().createTab(WT, undefined, undefined, { diff --git a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts index fa741e347a5..195ba9177c0 100644 --- a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts +++ b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts @@ -13,6 +13,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { _resetLayoutFramePositionForTests } from '@/runtime/layout-frame-position' import { _resetPendingTabsForTests } from '@/runtime/layout-owner-pending-tabs' +import { _resetPendingTabFactsForTests } from '@/runtime/layout-owner-pending-tab-facts' import { _resetContentRekeysForTests } from '@/runtime/layout-owner-content-rekeys' import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture' import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync' @@ -71,6 +72,7 @@ export async function drawTabsFromMain(store: DrawnStore): Promise AppState, + tab: Pick, + nextActiveTabId: string | null +): void { + const state = get() + const worktreeId = tab.worktreeId + if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) { + return + } + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId) + const next = + group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId) + ? nextActiveTabId + : group + ? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id) + : null + if (next && group?.activeTabId !== next) { + state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false }) + } + // Activation focuses the tab's group; the user's focused group stays, and shows its surface. + if (activeGroupId) { + get().focusGroup(worktreeId, activeGroupId) + } +} + +/** Tabs whose twin close main answered as today, while today's close runs. */ +const closingAsToday = new Set() + +export type ContentTabClose = { + /** The tab today's close would show next in the group, picked before the close. */ + nextActiveTabId: string | null + /** Today's close, for a twin main may route to a paired server. */ + closeHere: () => void + /** Why a browser tab closes: a cleanup close stays off main's reopen list. */ + recordClose?: 'user' | 'cleanup' + /** The window's own cleanup once main's frame dropped the tab, before the selection moves. */ + closed?: () => void + /** Lands an emptied active worktree on the welcome screen; true when it did. */ + landIfEmptied?: () => boolean +} + +/** + * Sends a tab's close; false when main does not decide its kind or is not asked (a paired server + * owns the workspace's layout), and the caller closes it as today. + */ +export function closeContentTabThroughLayoutOwner( + get: () => AppState, + tab: Pick, + close: ContentTabClose +): boolean { + // Today's close of a twin's tab closes its tab-bar entry as today too. + if (!closesThroughLayoutOwner(tab.contentType) || closingAsToday.has(tab.id)) { + return false + } + // A repeat close joins the one in flight; its waiters (closeFile's cleanup) stay on it. + if (isTabClosePending(tab.id)) { + return true + } + // A close of a tab still opening goes once main answered the open. + if (isPendingTab(tab.id)) { + afterPendingTab(tab.id, () => { + const drawn = get().unifiedTabsByWorktree[tab.worktreeId]?.find((e) => e.id === tab.id) + if (drawn) { + closeContentTabThroughLayoutOwner(get, drawn, close) + } + }) + return true + } + const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' }) + const dropped = (): void => { + // Waiters first: a closeFile's content cleanup must not undo the selection. + settle(tab.id) + close.closed?.() + if (!close.landIfEmptied?.()) { + selectAfterClose(get, tab, close.nextActiveTabId) + } + } + const closeAsToday = (): void => { + closingAsToday.add(tab.id) + let closedId: string | null = null + try { + close.closeHere() + closedId = tab.id + } finally { + closingAsToday.delete(tab.id) + settle(closedId) + } + } + const sent = sendLayoutApply( + get(), + tab.worktreeId, + { + type: 'closeTabs', + tabIds: [tab.id], + ...(close.recordClose ? { recordClose: close.recordClose } : {}) + }, + { + apply: (result) => { + if (result.closed?.includes(tab.id) ?? true) { + dropped() + } else { + settle(null) + } + }, + asToday: closeAsToday, + // A tab main no longer holds, or whose workspace it no longer holds, was closed there first + // (a host's chat close, a removed workspace). A workspace main never held draws only here. + refused: (code) => { + const shows = get().unifiedTabsByWorktree[tab.worktreeId]?.some((e) => e.id === tab.id) + if ((code === 'tab_not_found' || code === 'workspace_not_found') && !shows) { + dropped() + } else if (code === 'workspace_not_found') { + closeAsToday() + } else { + settle(null) + } + } + } + ) + if (!sent) { + settle(null) + } + return sent +} diff --git a/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts b/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts new file mode 100644 index 00000000000..30d86399549 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts @@ -0,0 +1,62 @@ +// Where a tab the window asks main to open goes, resolved as today's create resolves it: the +// requested live group, else the anchor's, else the active one, else the workspace's first (a +// workspace with none gets its root group). An open to the side names a new group beside its +// source, and an empty group only the window holds names the split that made it. + +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' +import type { NewGroupSplit } from '../../../../../shared/workspace-layout/workspace-layout-command-types' +import type { AppState } from '../../types' +import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split' +import { selectFolderWorkspaces } from '../../workspace-record-selectors' +import { resolveUnifiedTabCreatePlacement } from './tabs-create-placement' +import type { TabSplitTarget } from './tabs-slice-contract' + +export type ContentTabOpenPlacement = { + groupId: string + afterTabId?: string + split?: NewGroupSplit +} + +export function contentTabOpenPlacement( + state: AppState, + worktreeId: string, + tabId: string, + request: { + targetGroupId?: string + afterTabId?: string + split?: TabSplitTarget + executionHostId?: ExecutionHostId + } +): ContentTabOpenPlacement | null { + if (request.split) { + return { + groupId: createBrowserUuid(), + split: { besideGroupId: request.split.sourceGroupId, direction: request.split.splitDirection } + } + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + const placement = resolveUnifiedTabCreatePlacement({ + groups, + tabs: state.unifiedTabsByWorktree[worktreeId] ?? [], + activeGroupId: state.activeGroupIdByWorktree[worktreeId], + targetGroupId: request.targetGroupId, + afterTabId: request.afterTabId, + executionHostId: request.executionHostId, + lookupWorktrees: () => + [ + ...(state.allWorktrees?.() ?? []), + ...(selectFolderWorkspaces(state) ?? []).map(folderWorkspaceToWorktree) + ].filter((worktree) => worktree.id === worktreeId) + }) + const groupId = placement.groupId ?? groups[0]?.id ?? state.ensureWorktreeRootGroup?.(worktreeId) + if (!groupId) { + return null + } + return { + groupId, + ...(placement.anchorTabId ? { afterTabId: placement.anchorTabId } : {}), + ...newGroupSplitOf(state, worktreeId, groupId, tabId) + } +} diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 6c5045f473a..eb2d74debb5 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -1,6 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) @@ -10,6 +13,17 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -56,9 +70,14 @@ function storeWith(tab: Tab): ReturnType { return store } +let main: TabsDrawnFromMain | undefined beforeEach(() => { mocks.beginClose.mockReset() }) +afterEach(() => { + main?.close() + main = undefined +}) describe('closing a structured chat from outside its workspace', () => { it('stops it on the host stamped on its tab, not on whichever host shares its id', () => { @@ -76,12 +95,24 @@ describe('closing a structured chat from outside its workspace', () => { ) }) - it('names no host for an unstamped chat whose workspace two hosts publish', () => { + // Switch on, main's layout holds only chats this machine's host placed, so the stamp is a fact. + it('an unstamped chat whose workspace two hosts publish: no host off, main’s host on', async () => { const store = storeWith(chatTab()) + main = await drawTabsFromMain(store) store.getState().closeUnifiedTab('agent-session:chat-1') + await main.settle() - expect(mocks.beginClose).not.toHaveBeenCalled() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(mocks.beginClose).toHaveBeenCalledWith( + expect.objectContaining({ target: { kind: 'local' }, sessionId: 'chat-1' }) + ) + expect(layoutApplyTestTransport.sentOfType('closeTabs')).toEqual([ + expect.objectContaining({ workspace: WORKTREE, tabIds: ['agent-session:chat-1'] }) + ]) + } else { + expect(mocks.beginClose).not.toHaveBeenCalled() + } expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index b86dbf5ce75..0269a1db8c1 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -24,9 +24,12 @@ import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' import { removeTabsAttention } from '../workspace-attention/subject-attention-transitions' import { settleAttentionRemovedBy } from '../workspace-attention/attention-removal-follow-up' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { closeEditorTabThroughLayoutOwner } from '../editor/tabs/editor-tab-close-through-layout-owner' -import { recordClosedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' -import { recordClosedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner' +import { closeContentTabThroughLayoutOwner } from './content-tab-close-through-layout-owner' +import { isEditorTabContentType } from '../editor/tabs/editor-tab-content-type' +import type { AppState } from '../../types' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host' export function createTabsCloseActions( set: TabsSliceSet, @@ -75,7 +78,14 @@ export function createTabsCloseActions( worktreeId, sessionId: tab.entityId, provisional, - layoutOwner: state + layoutOwner: state, + // Switch point: main's frames draw this machine's chats, so main opens it again. + ...(ORCA_LAYOUT_OWNER_IS_MAIN && + target.kind === 'local' && + tab.agentSessionAgent && + isTuiAgent(tab.agentSessionAgent) + ? { reopen: reopenChatTab(worktreeId, tab.entityId, tab.agentSessionAgent) } + : {}) }) } else { // Closing still removes the tab; no host can be named to stop its chat on. @@ -148,11 +158,7 @@ export function createTabsCloseActions( // never added a tab, so it must leave the selection exactly as the click found it. const shouldDeactivateWorktree = !opts?.preserveWorktreeSelection && - current.activeWorktreeId === worktreeId && - nextTabs.length === 0 && - (current.tabsByWorktree[worktreeId] ?? []).length === 0 && - (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && - !current.openFiles.some((file) => file.worktreeId === worktreeId) + landsOnEmptiedWorktree(current, worktreeId, nextTabs.length) return { unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, groupsByWorktree: { @@ -167,33 +173,7 @@ export function createTabsCloseActions( : {}), ...structuredAttention, // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. - ...(shouldDeactivateWorktree - ? { - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null, - activeBrowserTabId: null, - activeFileId: null, - activeTabType: 'terminal' as const, - activeTabIdByWorktree: { - ...current.activeTabIdByWorktree, - [worktreeId]: null - }, - activeBrowserTabIdByWorktree: { - ...current.activeBrowserTabIdByWorktree, - [worktreeId]: null - }, - activeFileIdByWorktree: { - ...current.activeFileIdByWorktree, - [worktreeId]: null - }, - activeTabTypeByWorktree: { - ...current.activeTabTypeByWorktree, - [worktreeId]: 'terminal' - } - } - : {}), + ...(shouldDeactivateWorktree ? emptiedWorktreeLandingPatch(current, worktreeId) : {}), ...(!shouldDeactivateWorktree && ownsGlobalSelection(current, worktreeId) ? buildActiveSurfacePatch( { @@ -216,10 +196,29 @@ export function createTabsCloseActions( } }) ) - // Switch point: main closes an editor tab first; its frame drops it. + // Switch point: main closes an editor, browser, chat or simulator tab first; its frame drops it. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - closeEditorTabThroughLayoutOwner(get, tab, nextActiveTabId, closeHere) + closeContentTabThroughLayoutOwner(get, tab, { + nextActiveTabId, + closeHere, + // An editor close lands through closeFile, which owns its content. + ...(isEditorTabContentType(tab.contentType) || opts?.preserveWorktreeSelection + ? {} + : { landIfEmptied: () => landOnEmptiedWorktree(set, get, worktreeId) }), + // A chat tab owns its markers; nothing else retires them. + ...(tab.contentType === 'agent-session' + ? { + closed: () => + settleAttentionRemovedBy( + set, + get, + () => set((current) => removeTabsAttention(current, [tabId])), + { tabsBefore: state } + ) + } + : {}) + }) ) { if (opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -227,12 +226,6 @@ export function createTabsCloseActions( return { closedTabId: tabId, wasLastTab, worktreeId } } closeHere() - // Switch point: main records a chat or simulator tab the window just closed. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordClosedAgentSessionTabInLayoutOwner(get(), tab) - recordClosedSimulatorTabInLayoutOwner(get(), tab) - } - if (opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -240,3 +233,88 @@ export function createTabsCloseActions( } } } + +// "The user emptied this worktree": closing its last tab lands on the welcome screen, unless the +// close keeps the selection (a cleanup that unwinds a create never added a tab). +type LandingState = Pick< + AppState, + | 'activeWorktreeId' + | 'unifiedTabsByWorktree' + | 'tabsByWorktree' + | 'browserTabsByWorktree' + | 'openFiles' + | 'activeTabIdByWorktree' + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeTabTypeByWorktree' +> + +function landsOnEmptiedWorktree( + current: LandingState, + worktreeId: string, + remainingTabs: number +): boolean { + return ( + current.activeWorktreeId === worktreeId && + remainingTabs === 0 && + (current.tabsByWorktree[worktreeId] ?? []).length === 0 && + (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && + !current.openFiles.some((file) => file.worktreeId === worktreeId) + ) +} + +function emptiedWorktreeLandingPatch(current: LandingState, worktreeId: string) { + return { + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null, + activeBrowserTabId: null, + activeFileId: null, + activeTabType: 'terminal' as const, + activeTabIdByWorktree: { ...current.activeTabIdByWorktree, [worktreeId]: null }, + activeBrowserTabIdByWorktree: { ...current.activeBrowserTabIdByWorktree, [worktreeId]: null }, + activeFileIdByWorktree: { ...current.activeFileIdByWorktree, [worktreeId]: null }, + activeTabTypeByWorktree: { + ...current.activeTabTypeByWorktree, + [worktreeId]: 'terminal' as const + } + } +} + +type LandingSet = (update: (state: AppState) => Partial) => void + +/** After main's frame dropped a closed tab: lands when that emptied the active worktree. */ +export function landOnEmptiedWorktree( + set: LandingSet, + get: () => AppState, + worktreeId: string +): boolean { + const state = get() + if ( + !landsOnEmptiedWorktree( + state, + worktreeId, + (state.unifiedTabsByWorktree[worktreeId] ?? []).length + ) + ) { + return false + } + set((current) => emptiedWorktreeLandingPatch(current, worktreeId)) + return true +} + +/** Main opens the chat where it opens any chat this host lists: a host-kept chat comes back. */ +function reopenChatTab(worktreeId: string, sessionId: string, agent: TuiAgent): () => void { + return () => + void import('@/lib/structured-agent-session-launch-surface').then( + ({ openStructuredAgentSessionProvisionalTab }) => + openStructuredAgentSessionProvisionalTab({ + worktreeId, + executionHostId: LOCAL_EXECUTION_HOST_ID, + sessionId, + agent, + activate: false + }) + ) +} diff --git a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts index 73f52c582ed..f53d80ff529 100644 --- a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts +++ b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts @@ -123,9 +123,16 @@ export function settleAttentionRemovedBy( set: WorkspaceAttentionSet, get: WorkspaceAttentionGet, remove: () => void, - options?: { settleFlags?: boolean } + options?: { + settleFlags?: boolean + /** Where the tabs were before a removal main's frame already made. */ + tabsBefore?: Pick< + AttentionRemovalSnapshot, + 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'terminalLayoutsByTabId' + > + } ): void { - const before = get() + const before = { ...get(), ...options?.tabsBefore } remove() const removed = collectRemovedAttention(before, get()) if (!removed) { diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts index 359681cf699..b8f976d57c4 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts @@ -18,6 +18,7 @@ import { type PaneTarget } from './workspace-unread-characterization-fixture' import { completeAgentTurn, ringBell } from './workspace-unread-characterization-driver' +import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture' // Pins what removing a tab or pane does to unread: its markers go, and a workspace whose removed // marker was its last live one is read. @@ -41,6 +42,20 @@ vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) } }) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +let main: TabsDrawnFromMain | undefined + beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers({ toFake: ['Date'] }) @@ -52,6 +67,8 @@ beforeEach(() => { }) afterEach(() => { + main?.close() + main = undefined vi.useRealTimers() }) @@ -124,7 +141,7 @@ describe('closing tabs', () => { `) }) - it('closing a structured chat tab removes its markers', () => { + it('closing a structured chat tab removes its markers', async () => { const { store, persists } = createAttentionStore({ experimental: true }) const chat = store.getState().createUnifiedTab(WS_A, 'agent-session', { id: 'chat-1', @@ -136,9 +153,11 @@ describe('closing tabs', () => { unreadTerminalPanes: { [paneKey]: 'agent-completion' }, unreadAgentCompletionPanes: { [paneKey]: 'agent-completion' } }) + main = await drawTabsFromMain(store) observe(store, persists) store.getState().closeUnifiedTab(chat.id) + await main.settle() expect(observe(store, persists)).toMatchInlineSnapshot(` [ "rows a@local:read:0 b@local:read:0 sortEpoch=0", diff --git a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts index 646e8c9e338..841fa86bc8a 100644 --- a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts +++ b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts @@ -14,10 +14,13 @@ import { seedStore } from '../../store-test-helpers' import { createStoreCascadesMockApi } from '../../store-cascades-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' import { + readStructuredAgentLaunchRecord, resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' +import { wasStructuredAgentLaunchClosedByLocalCancel } from '@/lib/structured-agent-session-local-launch-cancel' import { hasStructuredAgentSessionLaunchCancellationTombstone, resetStructuredAgentLaunchRegistryForTests @@ -35,6 +38,14 @@ const CREATED_ID = `${REPO_ID}::/tmp/wt-created` const SESSION_ID = 'claude_repro_session' const AGENT_TAB_ID = `agent-session:${SESSION_ID}` +// A layout-owner build closes a local launch on its host at once and keeps no tombstone. +function launchWasCancelled(): boolean { + return ORCA_LAYOUT_OWNER_IS_MAIN + ? wasStructuredAgentLaunchClosedByLocalCancel(SESSION_ID) && + readStructuredAgentLaunchRecord(SESSION_ID) === undefined + : hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID) +} + function detected(worktree: Worktree): DetectedWorktree { return { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } } @@ -160,7 +171,7 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [existingRow])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(state.activeWorktreeId).toBeNull() expect(state.unifiedTabsByWorktree[CREATED_ID]).toBeUndefined() expect(state.tabsByWorktree[CREATED_ID]).toBeUndefined() @@ -175,7 +186,7 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [existingRow])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(state.activeWorktreeId).toBeNull() expect(state.unifiedTabsByWorktree[CREATED_ID]).toBeUndefined() }) @@ -188,7 +199,8 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [...rows])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(false) + expect(readStructuredAgentLaunchRecord(SESSION_ID)).toBeDefined() + expect(launchWasCancelled()).toBe(false) expect(state.activeWorktreeId).toBe(CREATED_ID) expect(state.unifiedTabsByWorktree[CREATED_ID]).toHaveLength(2) }) @@ -230,7 +242,8 @@ describe('a listing versioned before an applied create', () => { expect(applyListing(store, [existingRow], before)).toBe('superseded') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(false) + expect(readStructuredAgentLaunchRecord(SESSION_ID)).toBeDefined() + expect(launchWasCancelled()).toBe(false) expect(state.activeWorktreeId).toBe(CREATED_ID) expect(state.unifiedTabsByWorktree[CREATED_ID]).toHaveLength(2) expect(state.worktreesByRepo[REPO_ID]!.map((w) => w.id)).toEqual([EXISTING_ID, CREATED_ID]) @@ -282,7 +295,7 @@ describe('a listing versioned before an applied create', () => { expect(applyListing(store, [existingRow])).toBe('applied') - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(store.getState().activeWorktreeId).toBeNull() }) }) diff --git a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts index 9778043e032..5c78dd9b3ff 100644 --- a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts +++ b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts @@ -1,9 +1,10 @@ // Builds frame slices only and never calls set: the store patch applies what it returns. // Slices rebuilt from a frame, with each tab the window's own command still holds put back as the // store shows it (present, gone, or where the window placed it), and the rest as main has it. A -// group's order keeps main's for the tabs nothing holds. +// group's order keeps main's for the tabs nothing holds, and the window's hidden chat launch slots. import type { Tab, TabGroup } from '../../../shared/tab-types' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type' import type { LayoutFrameSlices } from './workspace-layout-frame-slices' import type { LayoutFrameStoreState } from './workspace-layout-frame-store-patch' @@ -97,6 +98,24 @@ function heldGroups( ] } +/** A chat's place the window reserves until its launch answers: in a group's order, with no row. */ +function launchSlotIds( + state: LayoutFrameStoreState, + built: LayoutFrameSlices, + key: string +): string[] { + const rows = new Set( + [...(state.unifiedTabsByWorktree[key] ?? []), ...(built.unifiedTabsByWorktree[key] ?? [])].map( + idOf + ) + ) + return (state.groupsByWorktree[key] ?? []).flatMap((group) => + group.tabOrder.filter((id) => id.startsWith(LAUNCH_SLOT_ID_PREFIX) && !rows.has(id)) + ) +} + +const LAUNCH_SLOT_ID_PREFIX = structuredAgentSessionTabId('') + function entityIds(tabs: readonly Tab[], kind: (tab: Tab) => boolean): Set { return new Set(tabs.filter(kind).map((tab) => tab.entityId)) } @@ -117,10 +136,11 @@ export function withHeldTabsAsDrawn( ...(built.unifiedTabsByWorktree[key] ?? []) ].filter(isHeld) const layoutHeld = held.isGroupLayoutHeld(key) - if (heldTabs.length === 0 && !layoutHeld) { + const slots = launchSlotIds(state, built, key) + if (heldTabs.length === 0 && slots.length === 0 && !layoutHeld) { continue } - const heldIds = new Set(heldTabs.map((tab) => tab.id)) + const heldIds = new Set([...heldTabs.map((tab) => tab.id), ...slots]) const groups = heldGroups( state.groupsByWorktree[key] ?? [], next.groupsByWorktree[key] ?? [], diff --git a/src/renderer/src/store/workspace-layout-frame-slices.ts b/src/renderer/src/store/workspace-layout-frame-slices.ts index 976f337ba7b..786b5aacdf3 100644 --- a/src/renderer/src/store/workspace-layout-frame-slices.ts +++ b/src/renderer/src/store/workspace-layout-frame-slices.ts @@ -274,7 +274,9 @@ export function buildLayoutFrameSlices({ tabsByWorktree: rows.tabsByWorktree }, session, - workspaceKeys + workspaceKeys, + // A frame redraws the window's own selection: one it never made stays none. + { unchosenStaysUnset: true } ) const editorTabs = { ...tabs, diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 5249471fc93..544dc7f5a5c 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -80,7 +80,8 @@ const MoveTabCommand = z.object({ type: z.literal('moveTab'), ...OnTab, toGroupId: z.string().min(1), - index: z.number().int().nonnegative() + index: z.number().int().nonnegative(), + isPinned: z.boolean().optional() }) /** Main mints the new group's id, or moves a lone tab's group whole. */ diff --git a/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts new file mode 100644 index 00000000000..7075e4d3013 --- /dev/null +++ b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts @@ -0,0 +1,142 @@ +// Editor, diff and markdown-preview tabs as today's `session.tabs.*` rows, built from the model's +// file records and the in-memory views the window publishes. Each row matches the window's +// (buildMobileMarkdownTab / buildMobileFileTab), with its open-file id as `sourceFileId`. + +import type { + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab +} from '../../runtime-mobile-session-tab-contracts' +import type { LayoutEditorView } from '../layout-editor-view' +import type { DesktopLayoutView } from '../workspace-layout-beside' +import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file' +import type { LayoutContentTab, LayoutEditorFile, WorkspaceLayout } from '../workspace-layout-model' + +export type EditorRowScope = { + key: string + workspace: WorkspaceLayout + view: Pick + activeTabId: string | null +} + +type EditorRow = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab + +const fileName = (relativePath: string, fallback: string): string => + relativePath.split(/[\\/]/).pop() || relativePath || fallback + +const tabRowProps = (tab: LayoutContentTab, scope: EditorRowScope) => ({ + color: tab.color ?? null, + isPinned: tab.isPinned === true, + isActive: scope.activeTabId === tab.id +}) + +function hasDraft( + scope: EditorRowScope, + file: Pick +): boolean { + return scope.view.editorDrafts[scope.key]?.[editorFileKey(file)]?.dirtyDraftContent !== undefined +} + +/** An editor tab's row: a markdown preview from its view, else the file record it shows. */ +export function editorTabRow(tab: LayoutContentTab, scope: EditorRowScope): EditorRow | null { + const editorView = scope.workspace.editorViews?.[tab.id] + if (editorView?.view.mode === 'markdown-preview') { + return markdownPreviewRow( + tab, + editorView.file, + editorView.view.markdownPreviewSourceFileId, + scope + ) + } + const file = editorTabFile(scope.workspace, tab.entityId) + if (!file) { + return null + } + const common = { + id: tab.id, + filePath: file.filePath, + relativePath: file.relativePath, + isDirty: hasDraft(scope, file), + ...tabRowProps(tab, scope) + } + if (file.language === 'markdown') { + return { + ...common, + type: 'markdown', + title: fileName(file.relativePath, 'Markdown'), + language: 'markdown', + mode: 'edit', + // The window's open-file id (a path or an owned id), which the tab's entity id is. + sourceFileId: tab.entityId, + sourceFilePath: file.filePath, + sourceRelativePath: file.relativePath, + documentVersion: `file:${tab.entityId}` + } + } + return { + ...common, + type: 'file', + title: fileName(file.relativePath, 'File'), + language: file.language, + mode: 'edit' + } +} + +/** The source is the open edit tab the preview names; with that tab closed, the preview itself. */ +function markdownPreviewRow( + tab: LayoutContentTab, + preview: LayoutEditorView['file'], + sourceFileId: string | undefined, + scope: EditorRowScope +): RuntimeMobileSessionMarkdownTab { + const sourceShown = + sourceFileId !== undefined && + scope.workspace.tabs.some((other) => other.kind === 'editor' && other.entityId === sourceFileId) + const source = sourceShown ? editorTabFile(scope.workspace, sourceFileId) : undefined + const sourceId = source && sourceFileId ? sourceFileId : tab.entityId + const sourceFile = source ?? preview + return { + type: 'markdown', + id: tab.id, + title: fileName(preview.relativePath, 'Markdown'), + filePath: preview.filePath, + relativePath: preview.relativePath, + language: 'markdown', + mode: 'markdown-preview', + // A preview holds no draft of its own; its source's draft makes it dirty. + isDirty: source ? hasDraft(scope, source) : false, + sourceFileId: sourceId, + sourceFilePath: sourceFile.filePath, + sourceRelativePath: sourceFile.relativePath, + documentVersion: `file:${sourceId}`, + ...tabRowProps(tab, scope) + } +} + +/** A diff tab's row from its view; a combined diff (display labels as paths) is not listed. */ +export function diffTabRow( + tab: LayoutContentTab, + scope: EditorRowScope +): RuntimeMobileSessionFileTab | null { + const editorView = scope.workspace.editorViews?.[tab.id] + if (editorView?.view.mode !== 'diff') { + return null + } + const { diffSource } = editorView.view + if (diffSource?.startsWith('combined-')) { + return null + } + const { file } = editorView + return { + type: 'file', + id: tab.id, + title: fileName(file.relativePath, 'File'), + filePath: file.filePath, + relativePath: file.relativePath, + language: file.language, + mode: 'diff', + ...(diffSource === 'staged' || diffSource === 'unstaged' ? { diffSource } : {}), + // The model holds no unsaved edit of a diff's modified side; the window's row it lists wins. + isDirty: false, + ...tabRowProps(tab, scope) + } +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts b/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts new file mode 100644 index 00000000000..21105a0df70 --- /dev/null +++ b/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts @@ -0,0 +1,22 @@ +// The ids today's `session.tabs.*` payload gives its rows. Rows keep today's ids so old clients' +// keys and selections stay valid (wire rule 3). + +import type { LayoutTab } from '../workspace-layout-model' + +export const TERMINAL_ROW_SEPARATOR = '::' +export const AGENT_SESSION_ROW_PREFIX = 'agent-session:' + +/** One row per pane: `::`, as the window and headless publishers write it. */ +export function terminalRowId(entityId: string, leafId: string): string { + return `${entityId}${TERMINAL_ROW_SEPARATOR}${leafId}` +} + +/** The runtime publishes chat tabs under their session id, in rows and in group tab orders. */ +export function agentSessionRowId(sessionId: string): string { + return `${AGENT_SESSION_ROW_PREFIX}${sessionId}` +} + +/** A tab's entry in a published group tab order. */ +export function publishedTabOrderId(tab: LayoutTab): string { + return tab.kind === 'agent-session' ? agentSessionRowId(tab.entityId) : tab.id +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts b/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts index c7a73b9e2f6..b77ad171302 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts @@ -4,24 +4,10 @@ import type { LayoutTab, WorkspaceLayout } from '../workspace-layout-model' import { layoutContainsLeafId } from '../terminal-pane-tree' +import { AGENT_SESSION_ROW_PREFIX, TERMINAL_ROW_SEPARATOR } from './old-client-session-row-ids' -const TERMINAL_ROW_SEPARATOR = '::' -const AGENT_SESSION_ROW_PREFIX = 'agent-session:' - -/** One row per pane: `::`, as the window and headless publishers write it. */ -export function terminalRowId(entityId: string, leafId: string): string { - return `${entityId}${TERMINAL_ROW_SEPARATOR}${leafId}` -} - -/** The runtime publishes chat tabs under their session id, in rows and in group tab orders. */ -export function agentSessionRowId(sessionId: string): string { - return `${AGENT_SESSION_ROW_PREFIX}${sessionId}` -} - -/** A tab's entry in a published group tab order. */ -export function publishedTabOrderId(tab: LayoutTab): string { - return tab.kind === 'agent-session' ? agentSessionRowId(tab.entityId) : tab.id -} +export { agentSessionRowId, publishedTabOrderId, terminalRowId } from './old-client-session-row-ids' +export { projectSessionTabs } from './old-client-session-tabs-projection' export type SessionTabRowTarget = { tab: LayoutTab; leafId?: string } diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts index 8b1eaf2611d..1c20f12625f 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts @@ -9,8 +9,6 @@ import { resolveTerminalTabTitle } from '../../tab-title-resolution' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab } from '../../runtime-mobile-session-tab-contracts' @@ -20,11 +18,8 @@ import type { } from '../../runtime-session-contracts' import type { TerminalLayoutSnapshot } from '../../terminal-tab-types' import { pruneGroupLayout } from '../../workspace-session-terminal-tab-close' -import { - agentSessionRowId, - publishedTabOrderId, - terminalRowId -} from './old-client-session-tab-rows' +import { diffTabRow, editorTabRow } from './old-client-session-editor-rows' +import { agentSessionRowId, publishedTabOrderId, terminalRowId } from './old-client-session-row-ids' import { collectLayoutLeafIdsInOrder } from '../terminal-pane-tree' import { groupsInLayoutOrder } from './old-client-group-order' import { @@ -32,7 +27,6 @@ import { type DesktopLayoutView, type LayoutContentFacts } from '../workspace-layout-beside' -import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file' import type { LayoutContentTab, LayoutTab, @@ -148,7 +142,11 @@ function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotT case 'terminal': return terminalRows(tab, scope) case 'editor': { - const row = editorRow(tab, scope) + const row = editorTabRow(tab, scope) + return row ? [row] : [] + } + case 'diff': { + const row = diffTabRow(tab, scope) return row ? [row] : [] } case 'browser': { @@ -159,9 +157,7 @@ function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotT const row = agentSessionRow(tab, scope) return row ? [row] : [] } - // The model holds no file record for a diff (the window lists it from its own state); the - // others need the desktop to draw them and are not listed today. - case 'diff': + // These need the desktop to draw them. case 'conflict-review': case 'check-details': case 'simulator': @@ -244,41 +240,6 @@ function recordOf( return entries.length > 0 ? Object.fromEntries(entries) : undefined } -/** Only edit-mode files are in the model; other editor tabs are the desktop view's own. */ -function editorRow( - tab: LayoutContentTab, - scope: Scope -): RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab | null { - const file = editorTabFile(scope.workspace, tab.entityId) - if (!file) { - return null - } - const common = { - id: tab.id, - title: file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File', - filePath: file.filePath, - relativePath: file.relativePath, - isDirty: - scope.view.editorDrafts[scope.key]?.[editorFileKey(file)]?.dirtyDraftContent !== undefined, - color: tab.color ?? null, - isPinned: tab.isPinned === true, - isActive: scope.activeTabId === tab.id - } - if (file.language === 'markdown') { - return { - ...common, - type: 'markdown', - language: 'markdown', - mode: 'edit', - sourceFileId: file.filePath, - sourceFilePath: file.filePath, - sourceRelativePath: file.relativePath, - documentVersion: `file:${file.filePath}` - } - } - return { ...common, type: 'file', language: file.language, mode: 'edit' } -} - function browserRow(tab: LayoutContentTab, scope: Scope): RuntimeMobileSessionBrowserTab | null { const record = scope.workspace.browserTabs?.find((entry) => entry.id === tab.entityId) const live = scope.facts.browserTabs[scope.key]?.[tab.entityId] ?? BLANK_BROWSER_TAB_STATE diff --git a/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts index 3e3a256dbfc..075dd6d96df 100644 --- a/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts +++ b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts @@ -83,7 +83,7 @@ describe('openBrowserTab under the window’s ids', () => { ]) }) - it('places a tab whose group main lacks beside its anchor', () => { + it('places a tab whose group main lacks in the workspace’s first group', () => { const { ctx, model } = setup() const split = build( ctx, @@ -107,8 +107,8 @@ describe('openBrowserTab under the window’s ids', () => { model ).model expect(split.workspaces[WS]!.groups.map((group) => group.tabOrder)).toEqual([ - [expect.any(String)], - ['entry-1', 'entry-2'] + [expect.any(String), 'entry-2'], + ['entry-1'] ]) }) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index ada7bc9f969..fb717e4e342 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -89,7 +89,14 @@ export type LayoutCommand = */ recordClose?: 'user' | 'cleanup' }) - | (On & { type: 'moveTab'; tabId: string; toGroupId: string; index: number }) + | (On & { + type: 'moveTab' + tabId: string + toGroupId: string + index: number + /** Pins or unpins the tab where `index` puts it, in the same step. */ + isPinned?: boolean + }) | (On & { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide }) /** `ratios`, or from a window older than them, `groupLayout`: exactly one. */ | (On & { diff --git a/src/shared/workspace-layout/workspace-layout-group-commands.ts b/src/shared/workspace-layout/workspace-layout-group-commands.ts index e5f411aef49..1bcd1c32bc8 100644 --- a/src/shared/workspace-layout/workspace-layout-group-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-group-commands.ts @@ -40,7 +40,9 @@ export function moveTab(model: WorkspaceLayoutModel, command: CommandOf<'moveTab const groups = workspace.groups.map((group) => group.id === source.id ? { ...group, tabOrder } : group ) - return applied(withWorkspace(model, command.workspace, { ...workspace, groups })) + return applied( + withWorkspace(model, command.workspace, withMovedPin({ ...workspace, groups }, command)) + ) } const tab = findTab(workspace, command.tabId) if (tab && groupShowsTabFile(workspace, command.toGroupId, tab)) { @@ -57,14 +59,29 @@ export function moveTab(model: WorkspaceLayoutModel, command: CommandOf<'moveTab return refuse('invalid_params') } return applied( - withWorkspace(model, command.workspace, { - ...workspace, - groups: moved.groups, - groupLayout: moved.layout ?? undefined - }) + withWorkspace( + model, + command.workspace, + withMovedPin( + { ...workspace, groups: moved.groups, groupLayout: moved.layout ?? undefined }, + command + ) + ) ) } +/** The pin a move sets lands with it, where `index` put the tab: no pin re-sort. */ +function withMovedPin(workspace: WorkspaceLayout, command: CommandOf<'moveTab'>): WorkspaceLayout { + const { isPinned } = command + if (isPinned === undefined) { + return workspace + } + return { + ...workspace, + tabs: workspace.tabs.map((tab) => (tab.id === command.tabId ? { ...tab, isPinned } : tab)) + } +} + export function splitGroup( model: WorkspaceLayoutModel, command: CommandOf<'splitGroup'>, diff --git a/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts b/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts new file mode 100644 index 00000000000..b8a8412c0b0 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { build, testContext, WS } from './workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from './workspace-layout-model' + +/** Terminal tabs A, B and C in one group, A and B pinned. */ +function setup() { + const ctx = testContext() + const { model, results } = build(ctx, [ + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS } + ]) + const [a, b, c] = results.map((result) => result!.tabId!) + const pinned = build( + ctx, + [ + { type: 'setTabProps', workspace: WS, tabId: a!, isPinned: true }, + { type: 'setTabProps', workspace: WS, tabId: b!, isPinned: true } + ], + model + ) + return { ctx, model: pinned.model, a: a!, b: b!, c: c! } +} + +const order = (model: WorkspaceLayoutModel) => model.workspaces[WS]!.groups[0]!.tabOrder +const pinOf = (model: WorkspaceLayoutModel, tabId: string) => + model.workspaces[WS]!.tabs.find((tab) => tab.id === tabId)?.isPinned === true + +describe('moveTab with a pin', () => { + it('pins the tab where the move puts it, without the pin moving it again', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: c, toGroupId: groupId, index: 1, isPinned: true }], + model + ).model + + expect(order(moved)).toEqual([a, c, b]) + expect(pinOf(moved, c)).toBe(true) + }) + + it('unpins the tab where the move puts it', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + const unpinnedB = build( + ctx, + [{ type: 'setTabProps', workspace: WS, tabId: b, isPinned: false }], + model + ).model + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: a, toGroupId: groupId, index: 1, isPinned: false }], + unpinnedB + ).model + + expect(order(moved)).toEqual([b, a, c]) + expect(pinOf(moved, a)).toBe(false) + }) + + it('a move without a pin leaves the pin as it was', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: b, toGroupId: groupId, index: 0 }], + model + ).model + + expect(order(moved)).toEqual([b, a, c]) + expect(pinOf(moved, b)).toBe(true) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 53ddd8f2e6c..3aa39f34085 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -276,12 +276,9 @@ export function placeContentTab( context: LayoutContext ): { model: WorkspaceLayoutModel; refused: RefusedClose[] } { const workspace = workspaceOrEmpty(model, key) - const anchor = placement.afterTabId - // A named group this copy lacks falls back to the anchor's. + // A host's open names no group; a window's names one from main's own frame. const group = - workspace.groups.find((entry) => entry.id === placement.groupId) ?? - (anchor ? workspace.groups.find((entry) => entry.tabOrder.includes(anchor)) : undefined) ?? - workspace.groups[0] + workspace.groups.find((entry) => entry.id === placement.groupId) ?? workspace.groups[0] const preview = tab.isPreview ? workspace.tabs.find( (entry) => diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 92cb0939c12..85cc9e7874a 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -1,6 +1,7 @@ import { expect, vi } from 'vitest' import { AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { resetStructuredAgentSessionClosesBeforeCreateForTests } from '../../../src/main/runtime/rpc/methods/structured-agent-session-close-before-create' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { attachParams, @@ -15,6 +16,8 @@ import type { } from './versioned-agent-session-wire' export function runtimeStub(overrides: Record = {}): unknown { + // A fresh runtime remembers no chat closed before its create; scenarios reuse one session id. + resetStructuredAgentSessionClosesBeforeCreateForTests() const subscriptions = new RuntimeSubscriptionRegistry() return { getRuntimeId: () => 'runtime-1', diff --git a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts index 247b99ce2dc..fdad3a07675 100644 --- a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import type * as RuntimeRpcClient from '../../src/renderer/src/runtime/runtime-rpc-client' +import type * as RuntimeLayoutClient from '../../src/renderer/src/runtime/runtime-layout-client' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { useAppStore } from '../../src/renderer/src/store' @@ -24,6 +25,14 @@ import { isWebSessionCloseIntentPending, resetWebSessionCloseIntentForTests } from '../../src/renderer/src/runtime/web-session-close-intent' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../src/shared/layout-owner-switch' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { makeWorktree } from '../../src/renderer/src/store/slices/worktrees-slice-test-fixtures' +import { layoutApplyTestTransport } from '../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '../../src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture' type CloseRequest = { requestId: string; tabId: string; worktreeId: string; expiresAt?: number } @@ -43,11 +52,15 @@ const bridge = vi.hoisted((): ProcessBoundary => ({ pairedClose: null })) +// Switch-on, the window's layout commands reach main's model, which the runtime's own commands share. vi.mock('../../src/renderer/src/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() - return { - ...actual, - callRuntimeRpc: async ( + const { runtimeRpcClientWithLayoutApply } = + await import('../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture') + // An untyped mock: the runtime client's generic result type is the caller's to name. + const callRuntimeRpc = vi.fn() + callRuntimeRpc.mockImplementation( + async ( target: RuntimeClientTarget, method: string, params: { worktree: string; tabId: string; reason?: 'user' } @@ -72,8 +85,32 @@ vi.mock('../../src/renderer/src/runtime/runtime-rpc-client', async (importOrigin reason: params.reason }) } - } + ) + return runtimeRpcClientWithLayoutApply({ ...actual, callRuntimeRpc }) }) +vi.mock('../../src/renderer/src/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +let main: TabsDrawnFromMain | undefined + +/** Main takes the window's current layout and answers its own layout commands from that model too. */ +async function mainHoldsWindowLayout(runtime: OrcaRuntimeService): Promise { + main?.close() + main = await drawTabsFromMain(useAppStore) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + Object.assign(runtime, { + applyLayoutCommand: (command: unknown) => layoutApplyTestTransport.call({ command }) + }) + } +} + +function closeMain(): void { + main?.close() + main = undefined +} vi.mock('../../src/renderer/src/runtime/structured-agent-session-close', () => ({ closeStructuredAgentSession: async (_target: unknown, sessionId: string) => { @@ -168,17 +205,16 @@ describe('reopened structured session close', () => { hostSessionCloses.push(sessionId) }, setSessionTabVisibility: async () => undefined, + // The host's tab index, which main opens chat tabs under: the current chat holds its first + // session's tab id, so the reopened one has its own (an id names one chat there). + getSessionTabId: (sessionId: string) => + sessionId === CURRENT_SESSION ? CURRENT_TAB : REOPENED_TAB, listSessionTabs: () => [ { sessionId: CURRENT_SESSION, workspaceId: WORKTREE, agent: 'claude' }, { sessionId: REOPENED_SESSION, workspaceId: WORKTREE, agent: 'claude' } ] } as never ) - await runtime.restoreStructuredAgentSessionTabs() - const hostSnapshot: RuntimeMobileSessionTabsResult = await runtime.listMobileSessionTabs( - `id:${WORKTREE}` - ) - // The current chat after /clear: first session's tab id, new session's entity. useAppStore.setState({ activeWorktreeId: WORKTREE }) useAppStore.getState().createUnifiedTab(WORKTREE, 'agent-session', { @@ -186,7 +222,13 @@ describe('reopened structured session close', () => { entityId: CURRENT_SESSION, label: 'Claude Chat' }) + await mainHoldsWindowLayout(runtime) + await runtime.restoreStructuredAgentSessionTabs() + const hostSnapshot: RuntimeMobileSessionTabsResult = await runtime.listMobileSessionTabs( + `id:${WORKTREE}` + ) applyStructuredSessionTabSnapshots([hostSnapshot]) + await main?.settle() const agentTabs = () => (useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []) @@ -203,6 +245,7 @@ describe('reopened structured session close', () => { }) afterEach(() => { + closeMain() setStructuredAgentSessionHost(null) bridge.runtime = null bridge.closeHeld = null @@ -238,7 +281,7 @@ describe('reopened structured session close', () => { expect(agentTabs()).toEqual([{ id: CURRENT_TAB, entityId: CURRENT_SESSION }]) expect(bridge.rendererSessionCloses).toEqual([REOPENED_SESSION]) expect(hostSessionCloses).toEqual([REOPENED_SESSION]) - // The window already removed the tab, so main's relay is acknowledged, not reported missing. + // The window started this close, so main's relay is acknowledged, not reported missing. expect(closeResponses).toEqual([undefined]) }) @@ -285,6 +328,8 @@ describe('in-flight structured session close', () => { async function setup(options: { hostFailsClose?: boolean } = {}) { let closeRequest: ((request: CloseRequest) => void) | undefined const pendingResponses = new Map void>() + // A failing close answers when the test says, so its frame can be read first. + const failingCloses: (() => void)[] = [] const runtime = new OrcaRuntimeService() bridge.runtime = runtime vi.stubGlobal('api', { @@ -308,7 +353,7 @@ describe('in-flight structured session close', () => { closeSessionTab: (tabId: string, worktreeId: string) => new Promise((resolve, reject) => { if (options.hostFailsClose) { - reject(new Error('window relay failed')) + failingCloses.push(() => reject(new Error('window relay failed'))) return } const requestId = `close-${++nextRequest}` @@ -346,16 +391,21 @@ describe('in-flight structured session close', () => { [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) } as never ) - await runtime.restoreStructuredAgentSessionTabs() - useAppStore.setState({ activeWorktreeId: WORKTREE }) + await mainHoldsWindowLayout(runtime) + await runtime.restoreStructuredAgentSessionTabs() applyStructuredSessionTabSnapshots([await runtime.listMobileSessionTabs(`id:${WORKTREE}`)]) + await main?.settle() // The window's live subscription to the host's tab list. runtime.onMobileSessionTabsChanged((snapshot) => applyStructuredSessionTabSnapshots([snapshot])) const strip = (): string[] => (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - return { runtime, strip, hostSessionCloses } + const failHostCloses = async (): Promise => { + await vi.waitFor(() => expect(failingCloses.length).toBeGreaterThan(0)) + failingCloses.splice(0).forEach((fail) => fail()) + } + return { runtime, strip, hostSessionCloses, failHostCloses } } const initialStoreState = useAppStore.getState() @@ -365,6 +415,7 @@ describe('in-flight structured session close', () => { }) afterEach(() => { + closeMain() setStructuredAgentSessionHost(null) bridge.runtime = null bridge.rendererSessionCloses = [] @@ -400,6 +451,8 @@ describe('in-flight structured session close', () => { try { useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) retirement = captureRetirement() + // Switch-on, the strip drops the chat once main's frame for the close lands. + await main?.settle() expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) // The host has not handled the close yet, so its next frame still lists chat-b. @@ -427,10 +480,12 @@ describe('in-flight structured session close', () => { }) it('shows the chat again when the host could not close it', async () => { - const { strip } = await setup({ hostFailsClose: true }) + const { strip, failHostCloses } = await setup({ hostFailsClose: true }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) + await main?.settle() expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + await failHostCloses() // The host still has the chat, so once the close fails the window re-reads and shows it. await vi.waitFor(() => expect(strip()).toContain(tabIdOf('chat-b'))) @@ -473,7 +528,19 @@ describe('in-flight structured session close', () => { ) const strip = (): string[] => (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - useAppStore.setState({ activeWorktreeId: WORKTREE }) + // The server owns the workspace, so its layout is mirrored, never drawn by main's frames. + useAppStore.setState({ + activeWorktreeId: WORKTREE, + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: WORKTREE, + repoId: 'repo-1', + hostId: toRuntimeExecutionHostId(SERVER) + }) + ] + } + }) applyServerFrame(frame(1)) const chatB = useAppStore .getState() @@ -501,6 +568,7 @@ describe('in-flight structured session close', () => { try { useAppStore.getState().closeUnifiedTab(chatB!.id) + await main?.settle() const after = before.filter((id) => id !== chatB!.id) expect(strip()).toEqual(after)