mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user