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,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))