mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +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,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'])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user