From fa962dd1cdec9e545fd3e4baf10fae3cfd9aa1df Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:57:12 -0700 Subject: [PATCH] Record chat tab opens and closes in main (dark, layout-owner build only) (#30019) * Record chat tab opens, closes and host-listed chats in main (dark, layout-owner build only) A chat tab the window opens, closes, or adds and removes from a host listing of this machine's chats now reaches main as openAgentSessionTab, closeTabs or setTabProps under the window's ids, so the window's save no longer teaches main a chat tab. openAgentSessionTab takes the window's place, creation time and new split group, and the same open again changes nothing. Switch-off builds are unchanged. * test(layout): a paired server's chat tabs send nothing to main (dark) * fix(layout): keep a windowless host's chat-tab saves as they were; send synced chat opens in tab order (dark) Only a window's openAgentSessionTab (main marks it drawn on layout.apply) takes the new rules: a repeat changes nothing, a taken id is refused, and a workspace with no group takes the window's. A windowless host's own saves mint a fresh group and keep a chat whose id another tab holds, as before. A host listing's new chats are recorded in tab order, so each open's anchor already reached main. * refactor(layout): one rule set for a chat tab open; the windowless caller names only a group it holds (dark) Drops the per-sender drawn flag. The windowless save passes its remembered active group only when the workspace still holds it, so it mints a fresh group as before. Refusing a taken chat tab id is gated on the build switch: shipped builds still save the duplicate id. --- .../runtime/agent-session-tab-layout.test.ts | 56 +++ src/main/runtime/agent-session-tab-layout.ts | 6 +- .../methods/workspace-layout-apply.test.ts | 27 ++ ...tructured-agent-session-provisional-tab.ts | 6 + .../agent-session-tab-layout-owner.test.ts | 339 ++++++++++++++++++ .../runtime/agent-session-tab-layout-owner.ts | 108 ++++++ src/renderer/src/runtime/layout-apply-send.ts | 11 + .../snapshot-apply.ts | 12 + .../store/slices/tabs/tabs-close-actions.ts | 4 +- .../rpc-contract/workspace-layout-params.ts | 21 +- ...workspace-layout-agent-session-tab.test.ts | 96 +++++ .../workspace-layout-command-types.ts | 6 + .../workspace-layout-commands.ts | 2 +- .../workspace-layout-content-commands.ts | 91 +++-- .../workspace-layout-group-commands.ts | 6 +- 15 files changed, 760 insertions(+), 31 deletions(-) create mode 100644 src/main/runtime/agent-session-tab-layout.test.ts create mode 100644 src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts create mode 100644 src/renderer/src/runtime/agent-session-tab-layout-owner.ts create mode 100644 src/shared/workspace-layout/workspace-layout-agent-session-tab.test.ts diff --git a/src/main/runtime/agent-session-tab-layout.test.ts b/src/main/runtime/agent-session-tab-layout.test.ts new file mode 100644 index 00000000000..5b6c4adc330 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { withChatTabChanges, type ChatTabChange } from './agent-session-tab-layout' + +const WT = 'repo-1::/tmp/worktree-a' + +const EMPTY: WorkspaceSessionState = { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} +} + +function open(sessionId: string, tabId = `structured-agent-session-${sessionId}`): ChatTabChange { + return { open: { sessionId, agent: 'codex', tabId } } +} + +function change(session: WorkspaceSessionState, changes: ChatTabChange[]): WorkspaceSessionState { + const next = withChatTabChanges('local', session, WT, changes) + if (!next) { + throw new Error('nothing changed') + } + return next +} + +describe('a windowless host’s chat tab saves', () => { + it('open a chat in a freshly minted group once the last one closed', () => { + const first = change(EMPTY, [open('chat-a')]) + const firstGroup = first.tabGroups?.[WT]?.[0]?.id + const closed = change(first, [{ close: 'chat-a' }]) + // The workspace keeps no group, but its saved view still names the closed chat's group. + const remembering = { + ...closed, + activeGroupIdByWorktree: { ...closed.activeGroupIdByWorktree, [WT]: firstGroup! } + } + expect(remembering.tabGroups?.[WT] ?? []).toEqual([]) + const reopened = change(remembering, [open('chat-b')]) + const groups = reopened.tabGroups?.[WT] ?? [] + expect(firstGroup).toBeTruthy() + expect(groups).toHaveLength(1) + expect(groups[0]?.id).not.toBe(firstGroup) + }) + + // Preserved legacy behaviour: with the build switch off a duplicate chat tab id is still saved. + it('save a chat whose tab id another tab already holds, as they always have', () => { + const saved = change(EMPTY, [open('chat-a', 'structured-agent-session-shared')]) + const both = change(saved, [open('chat-b', 'structured-agent-session-shared')]) + expect( + (both.unifiedTabs?.[WT] ?? []) + .filter((tab) => tab.contentType === 'agent-session') + .map((tab) => tab.entityId) + .sort() + ).toEqual(['chat-a', 'chat-b']) + }) +}) diff --git a/src/main/runtime/agent-session-tab-layout.ts b/src/main/runtime/agent-session-tab-layout.ts index b3f9d7c6f47..404a801b522 100644 --- a/src/main/runtime/agent-session-tab-layout.ts +++ b/src/main/runtime/agent-session-tab-layout.ts @@ -28,10 +28,14 @@ export function withChatTabChanges( changes: readonly ChatTabChange[] ): WorkspaceSessionState | null { const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) - const groupId = loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] + const activeGroupId = loaded.desktopView.activeGroupIdByWorktree?.[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) diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 7ad369278b6..57217e2f206 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -254,6 +254,33 @@ describe('layout.apply', () => { ).toBe(true) }) + it('takes a chat tab under the window’s id, with its place and creation time', async () => { + const apply = eraseRpcMethods(LAYOUT_APPLY_METHODS).find( + (method) => method.name === 'layout.apply' + ) + const parse = (command: Record) => apply?.params?.safeParse({ command }) + const open = { + type: 'openAgentSessionTab', + workspace: WORKTREE, + tabId: 'structured-agent-session-chat-1', + sessionId: 'chat-1', + agent: 'codex', + groupId: 'g1', + afterTabId: 'tab-1', + createdAt: 5, + split: { besideGroupId: 'g0', direction: 'right' } + } + + expect(parse(open)?.success).toBe(true) + expect(parse({ ...open, tabId: undefined })?.success).toBe(false) + expect(parse({ ...open, sessionId: '' })?.success).toBe(false) + expect(parse({ ...open, index: -1 })?.success).toBe(false) + // Only main's own restore and activation ask for a first surface. + expect(apply?.params?.parse({ command: { ...open, initial: 'restore' } })).toEqual({ + command: open + }) + }) + it('applies a browser tab’s record change to the workspace’s routed partition', async () => { const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') const page = { 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 8235e0a2ba2..78a27c25e67 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -25,6 +25,8 @@ import { } from '@/runtime/structured-agent-session-owner' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -67,6 +69,10 @@ export function openStructuredAgentSessionProvisionalTab(args: { ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), 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) + } if (args.activate !== false) { state.setActiveTabType('agent-session', args.worktreeId) } 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 new file mode 100644 index 00000000000..8fca9dfa97c --- /dev/null +++ b/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts @@ -0,0 +1,339 @@ +// @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 new file mode 100644 index 00000000000..705e2b31465 --- /dev/null +++ b/src/renderer/src/runtime/agent-session-tab-layout-owner.ts @@ -0,0 +1,108 @@ +// 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 { newGroupSplitOf } from './layout-owner-new-group-split' + +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 + } + const order = state.groupsByWorktree[tab.worktreeId]?.find( + (group) => group.id === tab.groupId + )?.tabOrder + const position = order?.indexOf(tab.id) ?? -1 + const afterTabId = position > 0 ? order?.[position - 1] : undefined + recordLayoutApply(state, tab.worktreeId, { + type: 'openAgentSessionTab', + tabId: tab.id, + sessionId: tab.entityId, + agent: tab.agentSessionAgent, + groupId: tab.groupId, + ...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {}), + createdAt: tab.createdAt, + ...newGroupSplitOf(state, tab.worktreeId, tab.groupId, tab.id) + }) + 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 f17da4623f9..2b63442724e 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -88,6 +88,17 @@ export type WindowLayoutCommand = createdAt: number split?: NewGroupSplit } + | { + type: 'openAgentSessionTab' + tabId: string + sessionId: string + agent: string + groupId: string + afterTabId?: string + index?: number + createdAt: number + split?: NewGroupSplit + } | { type: 'promotePreviewTab'; tabId: string } | { type: 'moveTabToWorkspace' 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 691e78e4cf6..3f15e082a42 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,6 +33,9 @@ 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' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -65,6 +68,7 @@ export function applyStructuredSessionTabSnapshots( options: StructuredSessionSnapshotApplyOptions = {} ): void { const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] + const before = ORCA_LAYOUT_OWNER_IS_MAIN ? useAppStore.getState() : null const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, { @@ -77,6 +81,14 @@ export function applyStructuredSessionTabSnapshots( { 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) + ) + } markStructuredAgentSessionLaunchesPublished(LOCAL_EXECUTION_HOST_ID, acceptedAgentSessions) if (options.authoritative) { startStructuredAgentLaunchCancellationCleanup(LOCAL_EXECUTION_HOST_ID, (sessionId) => 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 2139a17f7bc..b26447a10ed 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -25,6 +25,7 @@ import { removeTabsAttention } from '../workspace-attention/subject-attention-tr import { settleAttentionRemovedBy } from '../workspace-attention/attention-removal-follow-up' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import { recordClosedEditorTabInLayoutOwner } from '../editor/tabs/editor-tab-layout-owner' +import { recordClosedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' export function createTabsCloseActions( set: TabsSliceSet, @@ -207,9 +208,10 @@ export function createTabsCloseActions( } }) ) - // Switch point: main records an editor tab the window just closed. + // Switch point: main records an editor or chat tab the window just closed. if (ORCA_LAYOUT_OWNER_IS_MAIN) { recordClosedEditorTabInLayoutOwner(get(), tab) + recordClosedAgentSessionTabInLayoutOwner(get(), tab) } if (opts?.recordInteraction !== false) { diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 2e0ccbafa5c..02a5970a88f 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -228,6 +228,22 @@ const OpenBrowserTabCommand = z.object({ split: NewGroupSplit }) +/** + * A chat tab under the id the window drew it with: status and attention address it at once. The + * same open again changes nothing; an id another tab holds is refused. + */ +const OpenAgentSessionTabCommand = z.object({ + type: z.literal('openAgentSessionTab'), + ...OnTab, + sessionId: z.string().min(1), + agent: z.string().min(1), + groupId: z.string().min(1).optional(), + afterTabId: z.string().min(1).optional(), + index: z.number().int().nonnegative().optional(), + createdAt: z.number().int().nonnegative().optional(), + split: NewGroupSplit +}) + const OnBrowserPage = { workspace: OnPane.workspace, browserTabId: z.string().min(1), @@ -338,8 +354,8 @@ const WakeCommand = z * A window's layout command (`layout.apply`, registered only in a layout-owner build). The window * names what it acts on; main mints every new id but a created terminal tab's * (see CreateTerminalTabCommand), a browser tab's and page's, which the window already shows - * (see OpenBrowserTabCommand), an editor tab's (see OpenEditorTabCommand), and the group an open - * into a new split makes (see NewGroupSplit). + * (see OpenBrowserTabCommand), an editor tab's (see OpenEditorTabCommand), a chat tab's (see + * OpenAgentSessionTabCommand), and the group an open into a new split makes (see NewGroupSplit). */ export const LayoutApplyParams = z.object({ command: z.discriminatedUnion('type', [ @@ -363,6 +379,7 @@ export const LayoutApplyParams = z.object({ ReplaceBrowserPageCommand, SetBrowserTabProfileCommand, OpenEditorTabCommand, + OpenAgentSessionTabCommand, PromotePreviewTabCommand, MoveTabToWorkspaceCommand, RenameEditorFilesCommand, diff --git a/src/shared/workspace-layout/workspace-layout-agent-session-tab.test.ts b/src/shared/workspace-layout/workspace-layout-agent-session-tab.test.ts new file mode 100644 index 00000000000..a881d4d3c5f --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-agent-session-tab.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { applyLayoutCommand } from './workspace-layout-commands' +import type { CommandOf } from './workspace-layout-command-types' +import { build, emptyModel, testContext, WS } from './workspace-layout-command.test-fixture' + +const switchState = vi.hoisted(() => ({ layoutOwnerIsMain: true })) + +vi.mock('../layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return switchState.layoutOwnerIsMain + } +})) + +beforeEach(() => { + switchState.layoutOwnerIsMain = true +}) + +const drawn = ( + fields: Partial> = {} +): CommandOf<'openAgentSessionTab'> => ({ + type: 'openAgentSessionTab', + workspace: WS, + tabId: 'structured-agent-session-chat-1', + sessionId: 'chat-1', + agent: 'codex', + ...fields +}) + +describe('a chat tab a window drew', () => { + it('keeps the window’s id, group and creation time in a workspace main holds no group of', () => { + const { model, results } = build(testContext(), [drawn({ groupId: 'g1', createdAt: 7 })]) + expect(results[0]?.tabId).toBe('structured-agent-session-chat-1') + expect(model.workspaces[WS]?.groups).toEqual([ + { id: 'g1', tabOrder: ['structured-agent-session-chat-1'] } + ]) + expect(model.workspaces[WS]?.tabs[0]).toMatchObject({ createdAt: 7, entityId: 'chat-1' }) + }) + + it('changes nothing when sent again, and refuses an id another tab holds', () => { + const ctx = testContext() + const { model } = build(ctx, [drawn()]) + const again = applyLayoutCommand(model, drawn(), ctx) + expect(again.ok && again.model).toEqual(model) + const other = applyLayoutCommand(model, drawn({ sessionId: 'chat-2' }), ctx) + expect(other.ok ? 'applied' : other.code).toBe('tab_exists') + }) + + it('takes its place after the tab the window drew it beside', () => { + const ctx = testContext() + const { model, results } = build(ctx, [ + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS } + ]) + const first = results[0]!.tabId! + const placed = build(ctx, [drawn({ afterTabId: first })], model).model + expect(placed.workspaces[WS]?.groups[0]?.tabOrder).toEqual([ + first, + 'structured-agent-session-chat-1', + results[1]!.tabId! + ]) + }) + + it('opens into the new split group the window made for it', () => { + const ctx = testContext() + const { model } = build(ctx, [{ type: 'createTerminalTab', workspace: WS }]) + const besideGroupId = model.workspaces[WS]!.groups[0]!.id + const split = build( + ctx, + [drawn({ groupId: 'g2', split: { besideGroupId, direction: 'right' } })], + model + ).model + expect(split.workspaces[WS]?.groups.map((group) => group.id)).toEqual([besideGroupId, 'g2']) + expect(split.workspaces[WS]?.groupLayout).toMatchObject({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: besideGroupId }, + second: { type: 'leaf', groupId: 'g2' } + }) + }) + + it('mints its own group still when the open names none', () => { + const { model } = build(testContext(), [drawn({ tabId: undefined })], emptyModel()) + expect(model.workspaces[WS]?.groups).toHaveLength(1) + }) + + it('with the switch off, still takes a taken id as shipped builds do (legacy duplicate)', () => { + switchState.layoutOwnerIsMain = false + const ctx = testContext() + const { model } = build(ctx, [drawn()]) + const again = applyLayoutCommand(model, drawn({ sessionId: 'chat-2' }), ctx) + expect(again.ok && again.model.workspaces[WS]?.tabs.map((tab) => tab.entityId)).toEqual([ + 'chat-1', + 'chat-2' + ]) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 12de6571b3e..0cfa7754d61 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -174,7 +174,13 @@ export type LayoutCommand = sessionId: string agent: AgentType groupId?: string + /** Placement in the group: after this tab, else at this index, else at its end. */ + afterTabId?: string + index?: number + /** When the window drew the tab, so a later save of it matches main's copy. */ + createdAt?: number initial?: InitialSurfaceReason + split?: NewGroupSplit }) | (On & { type: 'startPane'; paneKey: string }) | (On & { type: 'restartPane'; paneKey: string }) diff --git a/src/shared/workspace-layout/workspace-layout-commands.ts b/src/shared/workspace-layout/workspace-layout-commands.ts index 0ea3b6a6bd6..d31fbb7bbca 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.ts @@ -123,7 +123,7 @@ function applyCommand( case 'setBrowserTabProfile': return setBrowserTabProfile(model, command) case 'openAgentSessionTab': - return openAgentSessionTab(model, command, context) + return openInNewGroup(model, command, (next) => openAgentSessionTab(next, command, context)) case 'startPane': return startPane(model, command, context) case 'restartPane': diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index 60be819eece..cd0b3a259f5 100644 --- a/src/shared/workspace-layout/workspace-layout-content-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -1,6 +1,7 @@ // Opening editor, browser and agent-session tabs: the tab and its content record in one apply. import type { EditorTabView } from '../editor-tab-view-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../layout-owner-switch' import { applied, refuse, workspaceOrEmpty, type Applied } from './workspace-layout-command-steps' import type { CommandOf, LayoutContext } from './workspace-layout-command-types' import { admitsInitialSurface } from './workspace-layout-initial-surface' @@ -35,31 +36,19 @@ export function openEditorTab( const workspace = model.workspaces[key] const sameFile = (tab: LayoutTab): boolean => tab.entityId === command.fileId && tab.kind === command.contentType - if (command.tabId) { - // The same open again (a tab this window drew, then sent twice or saved first) changes nothing. - const drawn = workspace?.tabs.find((tab) => tab.id === command.tabId) - if (drawn && sameFile(drawn)) { - return applied(model, { tabId: drawn.id }) - } - const holds = Object.values(model.workspaces).some((entry) => - entry.tabs.some((tab) => tab.id === command.tabId) - ) - if (holds) { - return refuse('tab_exists') - } + const drawn = drawnTabOpen(model, key, command.tabId, sameFile, true) + if (drawn) { + return drawn } - let target = model - if (command.groupId && !workspace?.groups.some((entry) => entry.id === command.groupId)) { - if (workspace?.groups.length) { - // Another group would put the tab where its sender did not. - return refuse('group_not_found') - } - // A workspace with no group takes the sender's, as the window keeps its emptied root group. - target = withWorkspace(model, key, { - ...workspaceOrEmpty(model, key), - groups: [{ id: command.groupId, tabOrder: [] }] - }) + if ( + command.groupId && + workspace?.groups.length && + !workspace.groups.some((entry) => entry.id === command.groupId) + ) { + // Another group would put the tab where its sender did not. + return refuse('group_not_found') } + const target = withSendersGroup(model, key, command.groupId) const group = target.workspaces[key]?.groups.find((entry) => entry.id === command.groupId) ?? target.workspaces[key]?.groups[0] @@ -200,13 +189,67 @@ export function openAgentSessionTab( if (command.initial && !admitsInitialSurface(model, command.workspace, command.initial)) { return applied(model) } + const drawn = drawnTabOpen( + model, + command.workspace, + command.tabId, + (tab) => tab.kind === 'agent-session' && tab.entityId === command.sessionId, + // Switch point: shipped builds still save a chat tab whose id another tab holds. + ORCA_LAYOUT_OWNER_IS_MAIN + ) + if (drawn) { + return drawn + } const tab = contentTab(context, { ...(command.tabId ? { id: command.tabId } : {}), + ...(command.createdAt !== undefined ? { createdAt: command.createdAt } : {}), kind: 'agent-session', entityId: command.sessionId, agentSessionAgent: command.agent }) - return applied(placeContentTab(model, command.workspace, tab, command, context).model, { + const target = withSendersGroup(model, command.workspace, command.groupId) + return applied(placeContentTab(target, command.workspace, tab, command, context).model, { tabId: tab.id }) } + +/** + * An open naming a tab id: the same open again (a tab a window drew, then sent twice or saved + * first) changes nothing; with `refuseTaken`, an id another tab holds refuses it. Null when the + * open goes ahead. + */ +function drawnTabOpen( + model: WorkspaceLayoutModel, + key: string, + tabId: string | undefined, + sameContent: (tab: LayoutTab) => boolean, + refuseTaken: boolean +): Applied | null { + if (!tabId) { + return null + } + const drawn = model.workspaces[key]?.tabs.find((tab) => tab.id === tabId) + if (drawn && sameContent(drawn)) { + return applied(model, { tabId: drawn.id }) + } + const holds = Object.values(model.workspaces).some((entry) => + entry.tabs.some((tab) => tab.id === tabId) + ) + return refuseTaken && holds ? refuse('tab_exists') : null +} + +/** A workspace with no group takes the sender's, as the window keeps its emptied root group. */ +function withSendersGroup( + model: WorkspaceLayoutModel, + key: string, + groupId: string | undefined +): WorkspaceLayoutModel { + const workspace = model.workspaces[key] + if (!groupId || workspace?.groups.length) { + return model + } + return withWorkspace(model, key, { + ...workspaceOrEmpty(model, key), + groups: [{ id: groupId, tabOrder: [] }] + }) +} diff --git a/src/shared/workspace-layout/workspace-layout-group-commands.ts b/src/shared/workspace-layout/workspace-layout-group-commands.ts index 932027ea2f0..f9295797829 100644 --- a/src/shared/workspace-layout/workspace-layout-group-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-group-commands.ts @@ -134,7 +134,9 @@ export function setGroupRatios( ) } -type NewGroupOpen = CommandOf<'createTerminalTab' | 'openEditorTab' | 'openBrowserTab'> +type NewGroupOpen = CommandOf< + 'createTerminalTab' | 'openEditorTab' | 'openBrowserTab' | 'openAgentSessionTab' +> /** * `open` into a new group `command.groupId` beside `split.besideGroupId`, as the window's split @@ -152,7 +154,7 @@ export function openInNewGroup( return open(model) } const workspaces = Object.values(model.workspaces) - // The same open again finds its tab; `open` decides between no change and a taken id. + // A tab id already held goes to `open`, which decides what a repeat or a taken id does. if ( command.tabId && workspaces.some((entry) => entry.tabs.some((tab) => tab.id === command.tabId))