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.
This commit is contained in:
Jinwoo Hong
2026-10-17 00:57:12 -07:00
committed by GitHub
parent 6c7ed18e20
commit fa962dd1cd
15 changed files with 760 additions and 31 deletions
@@ -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'])
})
})
+5 -1
View File
@@ -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)
@@ -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<string, unknown>) => 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 = {
@@ -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)
}
@@ -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<typeof loadWorkspaceLayoutWithoutReport>
type Mocks = {
layoutOwnerIsMain: boolean
store: ReturnType<typeof createTestStore> | 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<typeof createTestStore> {
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<void> {
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([])
})
})
@@ -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<typeof recordLayoutApply>[0] &
Pick<AppState, 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'layoutByWorktree'>
/** 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<Tab, 'id' | 'worktreeId' | 'contentType'>
): 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<string>
): 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<string, number> {
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<string, Tab> {
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<Tab, 'color'> & Partial<Pick<Tab, 'isPinned'>>
): 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 })
}
}
@@ -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'
@@ -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) =>
@@ -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) {
@@ -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,
@@ -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'>> = {}
): 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'
])
})
})
@@ -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 })
@@ -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':
@@ -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: [] }]
})
}
@@ -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))