mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Browser, chat and simulator tabs main-first: the window asks main for each change and draws only main's update (dark, layout-owner build only) (#31516)
* The window shows each layout records frame record by record, without reading its session back (dark, layout-owner build only)
A records frame no longer re-serializes the window's whole session and runs the
Loader. Panes' terminal bindings and live titles apply alone, to the tabs the
frame names; any other record builds only its own workspace's slices, with the
view and facts read from the store. Only a workspace the store does not show
from frames yet (startup, a reopened stream, a first visit, one sent whole)
takes the full build.
Tests install an apply check that, after every flush, compares the store with
the full build for the same cache and pre-flush store; the shared layout-apply
transport now sends main's records frames and fails a flush that differs.
* A frame of only tab rows shows without reading the window's session back or running the Loader (test, layout-owner build only)
* The account-restart test's fake binding runtime carries per-pane binding generations, so it passes with the switch on (test only)
* The account-restart test types its store writes, so its five old e2e type errors are gone (test only)
* A workspace build reads the editor selection and groups through the save's own writers, as the full build does (dark, layout-owner build only)
* A tab released from the window's hold shows the binding main wrote meanwhile (test, layout-owner build only)
* The frame-apply check still checks a flush's other workspaces when it builds one in full, and the shared transport keeps a test's own check (test only)
* The frame-apply check is proven to catch a mismatch beside a full build, and the shared transport restores only the check it installed (test only)
* wip(d3a-2): a windowless host lists a file main opens on session.tabs at once
* wip(d3a-2): the record key's fields are declared where the key is made
* wip(d3a-2): main publishes an editor tab's view, and a window rebuilds the open file it lacks from it
Main's in-memory editor view now carries the file facts an open file needs; it rides the tab row
(PublishedTab.editorView) and a frame build rebuilds a diff, conflict, preview or check-details tab.
Inert with the switch off: views are set only by layout.apply (switch builds) and read only in frame-sync.
* wip(d3a-2): a switch-on window sends editor opens, closes and promotions to main and draws the tabs from its frames
Layout-owner build only; the switch-off path is unchanged. An open proposes the tab id and sends
openEditorTab with the edit file's record or the tab's view (a reopen also its index); the open
file is written at once and the tab is selected once its frame draws it. Callers that act on the
tab (focus request, link reveal, group focus) wait for that frame through layout-owner-pending-tabs.
A close sends closeTabs; the tab goes with the frame and the file's content after it, and a
refusal keeps both. A promotion sends promotePreviewTab and the frame clears the flag. Deleted:
the drawn editor senders, their before/after captures, the editor drawn-tab holds and the
window-record keep rule (a transient tab is kept by its frame view).
* wip(d3a-2): a switch-on close keeps its selection and a reopen, a link names its group, and main stamps an open's time
Layout-owner build only. After main's frame drops a closed editor tab, the window selects the
group's previous tab and its surface through activateTab and focusGroup, after closeFile's
content cleanup rather than before it; a reopen sent while the close was in flight keeps the
file. openFileForLink's proposed view names the group the open asked for. The window's
openEditorTab sends no createdAt: main stamps the tab's time (ledger L186). Preview promotion
writes no tab itself: the window ends a preview only where a paired server owns the layout.
* wip(d3a-2): editor tests assert switch-on tabs through main's command and frame (test only)
Each store and hook test that assumed an immediate editor-tab draw now runs with main's model
through the T1 transport in apply mode and its frames (tabs-drawn-from-main.test-fixture), and
asserts the tab, its selection and its content after main's frame. The fixture fills the frame
slices a partial editor-and-tabs store lacks.
* wip(d3a-2): main takes a restored move as a command, renames a view tab's path, and keeps one tab per file per group
Layout-owner build only (the switch gates every change; shipped builds unchanged). moveTabToWorkspace no
longer skips a tab "the window's save got there first" (L160): window saves no longer carry the move, so
a repeat is tab_not_found; tab_exists and invalid_params stay. A move or rename may carry a non-plain
tab's new view (its file under the new path or owner), which replaces editorViews[tab] (L180). Main
refuses a moveTab or moveTabToWorkspace that would put a second tab for a file into a group (tab_exists),
and its loader folds a saved session's duplicate into the first, as the window's restore does
(dedupeEditorTabsWithinGroups): the window's frame dedupe hid the second, which no close could reach.
* wip(d3a-2): a switch-on restored move and disk rename are main's commands, and the frame that shows them rekeys the content
Layout-owner build only; the switch-off path is unchanged. A restored editor tab re-owned by its
file's workspace (L157, L158) sends moveTabToWorkspace (a re-own in place sends renameEditorFiles)
under the ids and group the window proposes, with each view tab's new view; a disk rename (L180)
sends renameEditorFiles per workspace and draws nothing first. The window's content rekey (open
file records, drafts, buffers, the editorBuffers echo gate, pendingLiveDiskVerification and
pendingSelfMoveEcho) waits in layout-owner-content-rekeys and the frame flush that first shows main's
change applies it, in the same store update as the frame (a flush now writes one patch). A change no
tab shows is rekeyed on main's answer; a refusal drops it and changes nothing. The move coordinator
and the restored-owner loader wait for that outcome; a refused rename rolls the disk move back.
A rename picks a runtime-owned or floating file's owned id, as main's frames restore it.
Deleted: recordRestoredEditorMoveInLayoutOwner, recordRenamedEditorFilesInLayoutOwner, the
reparent's before/after capture and the drawn-tab holds for moves and renames. Tests that assumed
the window moves or renames first now run through main's T1 transport in apply mode and its frames.
* wip(d3a-2): the static gate's casts and layout-writer ratchet hold for the switch-on move, rename and gap fixes
Layout-owner build only. The content rekey's pure store change stays in rekey-open-files-action
(its writer count drops 4 -> 2, pruned); a twin tab main routes to a paired server ends its
preview through patchTab, the existing tab writer, instead of a new one; a frame-only editor tab
takes its label through the frame build's tab-label facts, read from its view or file record; the
moved file's activation names the moved tab, writing no surface field. Test casts are typed or carry
a SAFETY note.
* wip(d3a-2): the switch-on rename takes the window's pure rekey as an argument, so the two modules do not import each other
Layout-owner build only; no behaviour change. Clears the static gate's no-cycle finding.
* wip(d3b): a switch-on window sends browser, chat and simulator opens, closes and page changes to main and draws them from its frames
Main places the chats its host lists (twin h), a local cancel keeps no tombstone (L155), and the
listing no longer writes chat tab structure where frames draw it (L153).
Co-Authored-By: Claude <noreply@anthropic.com>
* wip(d3b): a switch-on browser, chat or simulator close waits for main's frame, lands an emptied workspace as today, and its tests read main's frames
* wip(d3b): the stale-listing and cross-version chat tests check a switch-on local cancel through main, with a presence precondition and per-test resets
* wip(d3b): main places a tab whose group it lacks in the workspace's first group, not beside the window's anchor (L150)
* wip(d3b): tests for main's chat tab commands from a host's chat changes
* wip(d3b): main's windowless chat tab and chat fork tests apply a switch-on host's chat tab through main's layout
* wip(d3b): the switch-on close and open keep their layout writes in the files the writer ratchet counts them in, and a drawn browser close carries its position instead of a rebuilt layout
* Editor tabs main-first: the window sends each editor action to main and draws editor tabs only from main's frames (dark, layout-owner build only)
With ORCA_LAYOUT_OWNER_IS_MAIN on, editor open (preview replace, open to the side), reopen at
its position, close, close-all, promote, the resolved conflict placeholder, a restored-editor
move and a disk rename are layout commands the window sends before it draws anything. Window
content (open file records, drafts, buffers, the autosave gate) is written at once for an open,
and rekeyed in the same store update as the frame for a move or rename. Main publishes each
non-edit tab's view, keeps views in memory across applies and partition changes, stamps the
tab's time, folds same-file duplicates, and republishes a windowless host's session.tabs after
its own file open. The switch-off path is unchanged.
* wip(d3b): a switch-on close main answers workspace_not_found drops the tab's content as tab_not_found does, and placement reads a missing active-group map safely
* wip(d3b): switch-on browser and chat tests open and close through main's command and draw from its frame (test only)
* wip(d3b): a switch-on chat listing writes no tab structure of a workspace main's frames draw; a host chat title changes only the drawn tab's label
* wip(d3b): main's phone rows show a diff tab and a markdown preview as the window's do, with the preview's source named by the window's open-file id
* wip(d3b): the cleanup close's page-read guard counts its named linear passes, so a per-entry rescan still fails switch-on (test only)
* wip(d3b): the static gate's compat entry, focus-read ratchet and formatting hold for the switch-on test conversions and phone rows
* test(d3a-2): the buffer view index follows a rename main answers
* wip(d3b): placement reads the active-group map the real store always holds; the browser test store seeds it
* wip(d3b): a switch-on background browser open leaves the worktree's remembered browser tab unset, as with the switch off
* wip(d3b): a switch-on chat open selects the chat in its group without moving the focused group, as the window's own open does; its test counts the chats main draws
* wip(d3b): the converted simulator, launch-cancel, cleanup-close and chat-listing tests assert what the window's draws asserted, through main's command and frame (test only)
* wip(d3b): main fences a chat's create on its close even when the close fails for want of the chat host, so a cancelled launch never shows
* wip(d3b): a frame keeps the window's hidden chat launch slot after its neighbour, so a resumed chat keeps its saved place through main's frames
* wip(d3b): a declined chat's terminal is placed after the chat and pinned as it is in one main move, and shown before main closes the chat
* wip(d3b): a switch-on chat close the host refuses asks main to open the chat again, as the window's re-read shows it with the switch off
* wip(d3b): a paired server's workspace, its row naming the server, opens and closes browser and chat tabs without main's layout (test only)
* wip(d3b): a refused chat close comes back only while this machine's host still lists the chat, and a close main answers workspace_not_found removes the window-only tab
* wip(d3b): the twin chat close test names what each build stops: no host off, main's host on (test only)
* wip(d3b): the refused-close reopen takes only a chat agent it can launch
* wip(d3b): a frame's browser selection keeps an unchosen workspace's unset through the restore projection's own rule, not a second write
* wip(d3b): the simulator and paired-server tests type their mock stores without assertions (test only)
* wip(d3b): the switch-off declined-chat placement runs as before, with no wait; only the switch-on move waits for main
* chore(d3b): drop the frame-binding helper the main merge left without a caller
* chore(d3b): drop the import the deleted helper used
* wip(d3b): a chat this machine's window creates is placed only by the window's own open, so a reserved chat slot keeps its place (switch-on)
* wip(d3b): main reads a chat tab change against its layout when its queue reaches it, through the one rule the switch-off save uses, so a close right after an open closes it
* wip(d3b): a declined chat's terminal moves to its index among the tabs main draws, not past a hidden launch slot, so it still replaces the chat (switch-on)
* wip(d3b): main's phone-row comments say which rows the window's own row wins, and a test shows a window-listed diff keeps its dirty state (comments and test only)
* wip(d3b): a switch-on chat open returns only the tab's id and chat, the fields main's frame keeps; no caller reads more
* chore(d3b): the unit-routing census registers the browser suites' drawn-from-main store fixture as a support bridge (test only)
* chore(d3b): the chat tab rule's comment wraps and names its two callers (comment only)
* wip(d3b): main's chat tab queue asks a worktree's host only once it has a saved session, and one failed change no longer stops the ones after it (switch-on)
* wip(d3b): a chat tab batch whose changes cannot be read is logged, and the batches after it still land (switch-on)
* test(d3b): a CLI-chat resume's tab is opened once, by the window through main, after the tab before its hidden slot (test only)
* test(d3b): an SSH worktree has no host to run a chat on, so none of the chat open, close or fix paths reach it (test only)
* chore(d3b): the content tab placement reads folder workspaces through main's workspace-record selector, as the new ratchet requires
* fix(merge): put back main's f47f632 files that the merge took from a later main by mistake (no D3b change)
---------
Co-authored-by: E2E Test <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
E2E Test
Claude
parent
5793b05f39
commit
99d18f7a2f
@@ -25,7 +25,9 @@ const supportBridges = new Set([
|
||||
'src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts',
|
||||
'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts',
|
||||
// Autosave suites' store rig; it takes the editor harness's tab-layout maps.
|
||||
'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts'
|
||||
'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts',
|
||||
// Browser suites' store with main drawing their tabs; it takes the browser harness's store.
|
||||
'src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts'
|
||||
])
|
||||
|
||||
function productConsumers(file, graph, unitFiles) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Tab } from '../../shared/tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { chatTabLayoutCommand, loadChatTabLayout } from './agent-session-tab-layout'
|
||||
|
||||
const WT = 'repo-1::/tmp/worktree-a'
|
||||
|
||||
function chatTab(sessionId: string, id: string): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: sessionId,
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'agent-session',
|
||||
label: 'Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function session(tabs: Tab[], activeGroupId?: string): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: WT,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
unifiedTabs: { [WT]: tabs },
|
||||
tabGroups: {
|
||||
[WT]: [{ id: 'group-1', worktreeId: WT, activeTabId: null, tabOrder: tabs.map((t) => t.id) }]
|
||||
},
|
||||
...(activeGroupId ? { activeGroupIdByWorktree: { [WT]: activeGroupId } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The command for `change` against `stored` as main loads it. */
|
||||
function commandOn(
|
||||
stored: WorkspaceSessionState,
|
||||
change: Parameters<typeof chatTabLayoutCommand>[3]
|
||||
) {
|
||||
const { loaded, activeGroupId } = loadChatTabLayout('local', stored, WT)
|
||||
return chatTabLayoutCommand(loaded.layout.workspaces[WT], activeGroupId, WT, change)
|
||||
}
|
||||
|
||||
const open = (sessionId: string) => ({
|
||||
open: { sessionId, agent: 'codex' as const, tabId: `agent-session:${sessionId}` }
|
||||
})
|
||||
|
||||
describe("a host's chat tab change as main's layout command", () => {
|
||||
it("opens a listed chat in the workspace's remembered active group", () => {
|
||||
expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('b'))).toEqual({
|
||||
type: 'openAgentSessionTab',
|
||||
workspace: WT,
|
||||
tabId: 'agent-session:b',
|
||||
sessionId: 'b',
|
||||
agent: 'codex',
|
||||
groupId: 'group-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('names no group the workspace no longer holds, nor one with no layout loaded', () => {
|
||||
expect(commandOn(session([chatTab('a', 'saved-tab')], 'gone'), open('b'))).toEqual(
|
||||
expect.not.objectContaining({ groupId: expect.anything() })
|
||||
)
|
||||
expect(chatTabLayoutCommand(undefined, 'group-1', WT, open('b'))).toEqual(
|
||||
expect.not.objectContaining({ groupId: expect.anything() })
|
||||
)
|
||||
})
|
||||
|
||||
it('opens nothing for a chat a tab already shows', () => {
|
||||
expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('a'))).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the tab that shows the chat, under its saved id, forced', () => {
|
||||
expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'a' })).toEqual({
|
||||
type: 'closeTabs',
|
||||
workspace: WT,
|
||||
tabIds: ['saved-tab'],
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('closes nothing for a chat no tab shows', () => {
|
||||
expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'b' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { createChatTabLayoutQueue } from './agent-session-tab-layout-queue'
|
||||
import type { ChatTabLayoutCommand } from './agent-session-tab-layout'
|
||||
|
||||
const open = (sessionId: string) => ({
|
||||
worktreeId: 'wt',
|
||||
change: { open: { sessionId, agent: 'codex' as const, tabId: `tab-${sessionId}` } }
|
||||
})
|
||||
|
||||
it('asks no host of a worktree with no saved session, and goes on past a change that fails', async () => {
|
||||
const applied: string[] = []
|
||||
const hostIdOf = vi.fn((): never => {
|
||||
throw new Error('folder_workspace_not_found')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const queue = createChatTabLayoutQueue({
|
||||
sessionOf: () => null,
|
||||
hostIdOf,
|
||||
apply: async (command: ChatTabLayoutCommand) => {
|
||||
if (command.type === 'openAgentSessionTab' && command.sessionId === 'a') {
|
||||
throw new Error('layout_store_unavailable')
|
||||
}
|
||||
applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close')
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
})
|
||||
|
||||
queue(() => [open('a')])
|
||||
queue(() => [open('b')])
|
||||
await vi.waitFor(() => expect(applied).toEqual(['b']))
|
||||
|
||||
expect(hostIdOf).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('goes on past a batch whose changes cannot be read', async () => {
|
||||
const applied: string[] = []
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const queue = createChatTabLayoutQueue({
|
||||
sessionOf: () => null,
|
||||
hostIdOf: () => 'local',
|
||||
apply: async (command: ChatTabLayoutCommand) => {
|
||||
applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close')
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
})
|
||||
|
||||
queue(() => {
|
||||
throw new Error('session store unreadable')
|
||||
})
|
||||
queue(() => [open('b')])
|
||||
await vi.waitFor(() => expect(applied).toEqual(['b']))
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// A layout-owner build's chat tab changes from this host (a chat it publishes, retires or
|
||||
// restores): main's own layout commands, with or without a window.
|
||||
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
chatTabLayoutCommand,
|
||||
loadChatTabLayout,
|
||||
type ChatTabChange,
|
||||
type ChatTabLayoutCommand
|
||||
} from './agent-session-tab-layout'
|
||||
|
||||
export type QueuedChatTabChange = { worktreeId: string; change: ChatTabChange }
|
||||
|
||||
/**
|
||||
* Runs each batch in order, every change read against the layout as it stands when the queue
|
||||
* reaches it, so a close follows the open it undoes. A refusal is logged, never thrown.
|
||||
*/
|
||||
export function createChatTabLayoutQueue(deps: {
|
||||
sessionOf: (worktreeId: string) => WorkspaceSessionState | null
|
||||
/** Read only for a worktree with a saved session; it throws for one the host cannot place. */
|
||||
hostIdOf: (worktreeId: string) => ExecutionHostId
|
||||
apply: (command: ChatTabLayoutCommand) => Promise<LayoutApplyReply>
|
||||
}): (changesNow: () => readonly QueuedChatTabChange[]) => void {
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const commandNow = ({ worktreeId, change }: QueuedChatTabChange) => {
|
||||
const session = deps.sessionOf(worktreeId)
|
||||
if (!session) {
|
||||
return chatTabLayoutCommand(undefined, undefined, worktreeId, change)
|
||||
}
|
||||
const hostId = deps.hostIdOf(worktreeId)
|
||||
const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, worktreeId)
|
||||
return chatTabLayoutCommand(
|
||||
loaded.layout.workspaces[worktreeId],
|
||||
activeGroupId,
|
||||
worktreeId,
|
||||
change
|
||||
)
|
||||
}
|
||||
return (changesNow) => {
|
||||
queue = queue.then(async () => {
|
||||
// A batch or change that fails is logged and the queue goes on: a later one must still land.
|
||||
let changes: readonly QueuedChatTabChange[]
|
||||
try {
|
||||
changes = changesNow()
|
||||
} catch (error) {
|
||||
console.warn('[agent-session-tabs] reading chat tab changes failed:', error)
|
||||
return
|
||||
}
|
||||
for (const queued of changes) {
|
||||
try {
|
||||
const command = commandNow(queued)
|
||||
const reply = command ? await deps.apply(command) : null
|
||||
if (command && reply && !reply.ok) {
|
||||
console.warn(`[agent-session-tabs] ${command.type} refused:`, reply.code)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[agent-session-tabs] a chat tab change failed:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { applyLayoutCommand } from '../../shared/workspace-layout/workspace-layout-commands'
|
||||
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-model'
|
||||
import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -17,36 +18,66 @@ export type ChatTabChange =
|
||||
| { open: { sessionId: string; agent: AgentType; tabId: string } }
|
||||
| { close: string }
|
||||
|
||||
/** A chat tab change as main's layout command: a window's, which routes by the chat's tab. */
|
||||
export type ChatTabLayoutCommand =
|
||||
| (CommandOf<'openAgentSessionTab'> & { tabId: string })
|
||||
| (CommandOf<'closeTabs'> & { tabIds: [string] })
|
||||
|
||||
/**
|
||||
* The partition with the worktree's chat tabs changed in one layout load: a chat opens in the active
|
||||
* group unless a saved tab shows it, and a close drops the chat's tab. Null when nothing changed.
|
||||
* One change against the workspace's layout as loaded now: a chat opens in the active group (while
|
||||
* the workspace still holds it) unless a tab shows it, and a close drops the chat's tab. Null when
|
||||
* nothing changes. Main's queue and the windowless save both decide through it.
|
||||
*/
|
||||
export function chatTabLayoutCommand(
|
||||
workspace: WorkspaceLayout | undefined,
|
||||
activeGroupId: string | undefined,
|
||||
worktreeId: string,
|
||||
change: ChatTabChange
|
||||
): ChatTabLayoutCommand | null {
|
||||
// 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)?.id ??
|
||||
null
|
||||
if ('close' in change) {
|
||||
return tabId ? { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true } : null
|
||||
}
|
||||
if (tabId) {
|
||||
return null
|
||||
}
|
||||
const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const
|
||||
return groupId ? { ...open, groupId } : open
|
||||
}
|
||||
|
||||
/** The layout loaded from a partition, with the worktree's remembered active group. */
|
||||
export function loadChatTabLayout(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string
|
||||
) {
|
||||
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
|
||||
return { loaded, activeGroupId: loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] }
|
||||
}
|
||||
|
||||
/** The partition with the worktree's chat tabs changed in one layout load; null when unchanged. */
|
||||
export function withChatTabChanges(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
changes: readonly ChatTabChange[]
|
||||
): WorkspaceSessionState | null {
|
||||
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
|
||||
const activeGroupId = loaded.desktopView.activeGroupIdByWorktree?.[worktreeId]
|
||||
const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, 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)
|
||||
?.id ?? null
|
||||
let command: LayoutCommand | null = null
|
||||
if ('close' in change && tabId) {
|
||||
command = { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true }
|
||||
} else if ('open' in change && !tabId) {
|
||||
const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const
|
||||
command = groupId ? { ...open, groupId } : open
|
||||
}
|
||||
const command = chatTabLayoutCommand(
|
||||
layout.workspaces[worktreeId],
|
||||
activeGroupId,
|
||||
worktreeId,
|
||||
change
|
||||
)
|
||||
const applied = command
|
||||
? applyLayoutCommand(layout, command, {
|
||||
mintId: randomUUID,
|
||||
|
||||
@@ -157,6 +157,15 @@ describe('a close that reaches main before its create', () => {
|
||||
expect(rig.host.hasSession(SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
it('a close main fails for want of its chat host still refuses the create that follows', async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
expect(await close()).toMatchObject({ ok: false })
|
||||
setStructuredAgentSessionHost(rig.host)
|
||||
|
||||
expect(await create()).toMatchObject(refusedAsClosed)
|
||||
expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switch off: a close before the create is not remembered', async () => {
|
||||
mocks.layoutOwnerIsMain = false
|
||||
await close()
|
||||
|
||||
@@ -118,6 +118,7 @@ export async function handleStructuredAgentSessionCreate(
|
||||
runtime: ctx.runtime,
|
||||
caller: callerFor(ctx),
|
||||
prepared,
|
||||
activate: true
|
||||
activate: true,
|
||||
placedByWindow: ctx.caller?.kind === 'desktop'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// A chat this machine's window creates is placed by the window's own open, where it reserved the
|
||||
// chat's place; main's publish of it places nothing. Any other caller's chat main places itself.
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities'
|
||||
import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create'
|
||||
import {
|
||||
createRestTestRig,
|
||||
type RestTestRig
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_NOW as NOW
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { DESKTOP_RPC_CALLER, type RpcCallerIdentity } from '../rpc-caller-identity'
|
||||
import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture'
|
||||
|
||||
const CLIENT = {
|
||||
clientId: 'device-1',
|
||||
clientKind: 'runtime' as const,
|
||||
clientCapabilities: [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
|
||||
let rig: RestTestRig
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } })
|
||||
setStructuredAgentSessionHost(rig.host)
|
||||
})
|
||||
afterEach(async () => {
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rig.dispose()
|
||||
})
|
||||
|
||||
// The transport's caller stamp, which the fixture forwards with the rest of the client.
|
||||
const create = (client: typeof CLIENT & { caller?: RpcCallerIdentity }) =>
|
||||
call(
|
||||
'agentSession.create',
|
||||
structuredAgentSessionCreateParams({
|
||||
sessionId: SESSION,
|
||||
worktree: 'id:workspace-1',
|
||||
agent: 'codex',
|
||||
randomUuid: () => '00000000-0000-0000-0000-0000000000fb',
|
||||
now: NOW
|
||||
}),
|
||||
client
|
||||
)
|
||||
|
||||
it.each([
|
||||
["this machine's window", { ...CLIENT, caller: DESKTOP_RPC_CALLER }, true],
|
||||
['a paired device', { ...CLIENT, caller: { kind: 'paired-device', deviceId: 'phone-1' } }, false],
|
||||
['a caller the transport did not name', CLIENT, false]
|
||||
] as const)(
|
||||
'a create from %s leaves its chat to the window only when the window made it',
|
||||
async (_, client, byWindow) => {
|
||||
expect(await create(client)).toMatchObject({ ok: true, result: { ok: true } })
|
||||
|
||||
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce()
|
||||
const published = runtimeCalls.publishStructuredAgentSessionTab?.mock.calls[0]?.[0]
|
||||
expect(published).toMatchObject({ sessionId: SESSION, activate: true })
|
||||
expect(published?.placedByWindow).toBe(byWindow ? true : undefined)
|
||||
}
|
||||
)
|
||||
@@ -195,9 +195,12 @@ export async function commitStructuredAgentSessionCreate(args: {
|
||||
caller: StructuredAgentSessionCaller
|
||||
prepared: PreparedStructuredAgentSessionCreate
|
||||
activate: boolean
|
||||
/** This machine's window made the create and opens the chat's tab where it reserved it. */
|
||||
placedByWindow?: boolean
|
||||
}): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
const { prepared } = args
|
||||
const tab = prepared.tab
|
||||
const placedByWindow = args.placedByWindow ? { placedByWindow: true } : {}
|
||||
if (prepared.atRest) {
|
||||
return prepared.host.create(args.caller, prepared.attachParams, {
|
||||
...prepared.atRest,
|
||||
@@ -217,6 +220,7 @@ export async function commitStructuredAgentSessionCreate(args: {
|
||||
sessionId: prepared.attachParams.envelope.sessionId,
|
||||
agent: tab.agent,
|
||||
activate: args.activate,
|
||||
...placedByWindow,
|
||||
...(prepared.attachParams.surfaceTabId
|
||||
? { tabId: prepared.attachParams.surfaceTabId }
|
||||
: {})
|
||||
@@ -246,6 +250,7 @@ export async function commitStructuredAgentSessionCreate(args: {
|
||||
sessionId: result.value.sessionId,
|
||||
agent: prepared.tab.agent,
|
||||
activate: args.activate,
|
||||
...placedByWindow,
|
||||
...(surfaceTabId ? { tabId: surfaceTabId } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -153,13 +153,15 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: OptionsParams,
|
||||
handler: async (params, ctx) => {
|
||||
const host = requireStructuredCleanupHost(ctx)
|
||||
requireStructuredCapability(ctx)
|
||||
// Noted before the host is required: a close that fails still refuses the create it raced.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
noteStructuredAgentSessionClose(
|
||||
params.sessionId,
|
||||
host.deps.store.getRecord(params.sessionId) !== null
|
||||
(getStructuredAgentSessionHost()?.deps.store.getRecord(params.sessionId) ?? null) !== null
|
||||
)
|
||||
}
|
||||
const host = requireStructuredCleanupHost(ctx)
|
||||
// Terminal-disposal closes use this RPC without the session-tabs retirement RPC.
|
||||
if (typeof host.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(params.sessionId, false)
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function applyWindowLayoutCommand(
|
||||
) {
|
||||
markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId: terminalTabId })
|
||||
}
|
||||
// Why: a workspace main lacks has no partition record to write; the window already removed the tab.
|
||||
// Why: a workspace main lacks has no tab to close; the window drops the tab as for tab_not_found.
|
||||
return reply.ok && reply.result.alreadyClosed
|
||||
? { ok: false, code: 'workspace_not_found' }
|
||||
: reply
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Main's `layout.apply` on a stored partition, as the Store applies it, for runtime tests whose
|
||||
// store is a plain session map (a layout-owner build saves a chat's tab through it).
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
|
||||
|
||||
export function layoutApplyOn(
|
||||
get: (hostId: string) => WorkspaceSessionState | null,
|
||||
set: (next: WorkspaceSessionState, hostId: string) => void
|
||||
) {
|
||||
return async (command: LayoutCommand, hostIds?: readonly string[]) => {
|
||||
const hostId = resolveHostId(hostIds?.[0])
|
||||
const session = get(hostId)
|
||||
if (!session) {
|
||||
return { ok: false as const, code: 'workspace_not_found' as const }
|
||||
}
|
||||
const change = applyCommandToSession(hostId, session, command)
|
||||
if (change.session) {
|
||||
set(change.session, hostId)
|
||||
}
|
||||
return change.value
|
||||
}
|
||||
}
|
||||
|
||||
/** Main queues a host's chat tab commands; this lets them land. */
|
||||
export const chatTabCommandsSettled = (): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, 0))
|
||||
@@ -84,7 +84,7 @@ function mainProjection(
|
||||
return rest
|
||||
}
|
||||
|
||||
describe('session.tabs rows from main (layout-owner build, before D3b)', () => {
|
||||
describe('session.tabs rows from main (layout-owner build)', () => {
|
||||
it("takes main's terminal rows and name, with the window's theme, status and draft", () => {
|
||||
const theme = { mode: 'dark' as const, theme: {} }
|
||||
const agentStatus = null
|
||||
@@ -125,6 +125,18 @@ describe('session.tabs rows from main (layout-owner build, before D3b)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Main's diff row is never dirty (its drafts are keyed by path); the window's edit is.
|
||||
it("shows a diff the window lists with the window's dirty state, over main's row", () => {
|
||||
const edited = { ...file('d1', 'diff'), isDirty: true }
|
||||
const merged = withMainSessionTabRows(
|
||||
windowSnapshot([edited], ['d1'], 'd1'),
|
||||
mainProjection([file('d1', 'diff')], ['d1'], 'd1'),
|
||||
NONE_EXITED
|
||||
)
|
||||
|
||||
expect(merged.tabs).toEqual([{ ...edited, isActive: true }])
|
||||
})
|
||||
|
||||
it('keeps the rows main does not hold (browser, diff) in the window’s place', () => {
|
||||
const merged = withMainSessionTabRows(
|
||||
windowSnapshot(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// Layout-owner builds, before main holds browser and chat records (D3b): a workspace main owns
|
||||
// publishes its terminal and editor rows, tab order and groups from main's layout
|
||||
// (projectSessionTabs). The window's live publication supplies what the user sees: selection, the
|
||||
// focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced
|
||||
// save), the rows main's model does not hold (browser, chat, diff and other view-only editor rows)
|
||||
// and the facts only the window has (terminal theme, agent status, launch drafts, a markdown
|
||||
// view's mode, file id and draft version).
|
||||
// Layout-owner builds: a workspace main owns publishes its rows, tab order and groups from main's
|
||||
// layout (projectSessionTabs). The window's live publication supplies what the user sees: selection,
|
||||
// the focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced
|
||||
// save), the rows main's layout does not list yet, and the facts only the window has (terminal
|
||||
// theme, agent status, launch drafts, a markdown view's mode, file id and draft version).
|
||||
|
||||
import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts'
|
||||
import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement'
|
||||
@@ -22,8 +20,9 @@ export type MainSessionTabRows = Omit<
|
||||
>
|
||||
|
||||
/**
|
||||
* Rows main's layout answers for: terminals and edit-mode files (the model holds no diff or
|
||||
* preview-only tab). Any other row is the window's until D3b.
|
||||
* Rows main's layout answers for, with the window's facts: terminals and edit-mode files. Main also
|
||||
* lists browser, chat, diff and markdown-preview rows, but the window's row of the same id stands
|
||||
* whole while the window lists it (its draft is what makes a diff dirty).
|
||||
*/
|
||||
function isMainRow(row: Row): boolean {
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,10 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w
|
||||
import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime'
|
||||
import { retireStructuredAgentSessionTabFrom } from '../structured-agent-session-tab-retirement'
|
||||
import { chatTabId, withChatTabChanges, type ChatTabChange } from '../agent-session-tab-layout'
|
||||
import { createChatTabLayoutQueue } from '../agent-session-tab-layout-queue'
|
||||
import type { WindowLayoutCommand } from '../runtime-layout-apply'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { savedSessionId } from '../saved-structured-agent-session-restoration'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { structuredSessionTabRetired } from '../structured-worker-custody'
|
||||
@@ -33,6 +37,8 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
|
||||
...args: Parameters<PruneMobileSessionTabGroupLayoutMethods['getMobileTerminalPaneKey']>
|
||||
): ReturnType<PruneMobileSessionTabGroupLayoutMethods['getMobileTerminalPaneKey']>
|
||||
|
||||
abstract applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply>
|
||||
|
||||
protected abstract persistHeadlessTabGroups(
|
||||
...args: Parameters<HeadlessTabGroupsMethods['persistHeadlessTabGroups']>
|
||||
): ReturnType<HeadlessTabGroupsMethods['persistHeadlessTabGroups']>
|
||||
@@ -286,16 +292,36 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
|
||||
if (!host || !structuredSessionTabRetired(host, sessionId)) {
|
||||
return
|
||||
}
|
||||
const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}
|
||||
for (const [worktreeId, tabs] of Object.entries(saved)) {
|
||||
if (tabs.some((tab) => savedSessionId(tab) === sessionId)) {
|
||||
this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }])
|
||||
}
|
||||
// Switch point: main reads which worktrees show the chat when its queue reaches the close, so
|
||||
// an open still queued ahead of it is closed too.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
this.queueChatTabChanges(() =>
|
||||
this.worktreesShowingSavedChat(sessionId).map((worktreeId) => ({
|
||||
worktreeId,
|
||||
change: { close: sessionId }
|
||||
}))
|
||||
)
|
||||
return
|
||||
}
|
||||
for (const worktreeId of this.worktreesShowingSavedChat(sessionId)) {
|
||||
this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }])
|
||||
}
|
||||
}
|
||||
|
||||
private worktreesShowingSavedChat(sessionId: string): string[] {
|
||||
const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}
|
||||
return Object.entries(saved).flatMap(([worktreeId, tabs]) =>
|
||||
tabs.some((tab) => savedSessionId(tab) === sessionId) ? [worktreeId] : []
|
||||
)
|
||||
}
|
||||
|
||||
// A window saves its own layout whole, chat tabs included; only a host with none saves them here.
|
||||
protected changeWindowlessChatTabs(worktreeId: string, changes: readonly ChatTabChange[]): void {
|
||||
// Switch point: main places a chat's tab in its own layout, with or without a window.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
this.queueChatTabChanges(() => changes.map((change) => ({ worktreeId, change })))
|
||||
return
|
||||
}
|
||||
const session = this.getAvailableAuthoritativeWindow()
|
||||
? null
|
||||
: this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
@@ -311,4 +337,10 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, next)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly queueChatTabChanges = createChatTabLayoutQueue({
|
||||
sessionOf: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
|
||||
hostIdOf: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId),
|
||||
apply: (command) => this.applyLayoutCommand(command)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { seedStructuredAgentSessionTabIndex } from '../structured-agent-session-tab-index-seed'
|
||||
import { restoredChatTabChanges } from './restored-chat-tab-changes'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type {
|
||||
RuntimeMobileSessionAgentTab,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
@@ -102,6 +103,8 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten
|
||||
notify?: boolean
|
||||
/** The host tab id a create reserved; a session that already has a tab keeps its own. */
|
||||
tabId?: string
|
||||
/** This machine's window made the create; its own open places the tab. */
|
||||
placedByWindow?: boolean
|
||||
}): Promise<void> {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (typeof host?.setSessionTabVisibility === 'function') {
|
||||
@@ -117,7 +120,10 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten
|
||||
})
|
||||
})
|
||||
}
|
||||
this.saveWindowlessAgentSessionTab(input.workspaceId, input)
|
||||
// Switch point: main leaves a window's chat to the window's open, the one placement decision.
|
||||
if (!(ORCA_LAYOUT_OWNER_IS_MAIN && input.placedByWindow)) {
|
||||
this.saveWindowlessAgentSessionTab(input.workspaceId, input)
|
||||
}
|
||||
this.projectStructuredAgentSessionTab(input)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-sess
|
||||
import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { withChatTabChanges } from './agent-session-tab-layout'
|
||||
import {
|
||||
chatTabCommandsSettled as settled,
|
||||
layoutApplyOn
|
||||
} from './session-layout-apply.test-fixture'
|
||||
import {
|
||||
ensureStructuredAgentSessionHost,
|
||||
stopStructuredAgentSessionRuntime
|
||||
@@ -85,7 +89,11 @@ async function runtimeWithSavedChats(
|
||||
} else {
|
||||
partitions.set(hostId, next)
|
||||
}
|
||||
}
|
||||
},
|
||||
applyLayoutCommand: layoutApplyOn(
|
||||
(hostId) => (hostId === 'local' ? state.session : (partitions.get(hostId) ?? null)),
|
||||
(next, hostId) => store.setWorkspaceSession(next, hostId)
|
||||
)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths.
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
@@ -97,6 +105,7 @@ async function runtimeWithSavedChats(
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
await settled()
|
||||
return { runtime, state }
|
||||
}
|
||||
|
||||
@@ -152,6 +161,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
expect(host.deps.store.getRecord(CHAT_A)).not.toBeNull()
|
||||
|
||||
await host.setSessionTabVisibility(CHAT_A, false)
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([CHAT_B])
|
||||
})
|
||||
@@ -166,6 +176,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([CHAT_B])
|
||||
})
|
||||
@@ -189,6 +200,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([CHAT_B])
|
||||
})
|
||||
@@ -203,6 +215,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual(['short', CHAT_B])
|
||||
})
|
||||
@@ -225,6 +238,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([CHAT_B, CHAT_SSH])
|
||||
})
|
||||
@@ -252,6 +266,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(partitions.get('runtime:env-y')?.unifiedTabs?.[WT]?.map((tab) => tab.entityId)).toEqual([
|
||||
CHAT_REMOTE
|
||||
@@ -264,6 +279,7 @@ describe('a windowless chat tab against the chat host', () => {
|
||||
restoringRuntime(runtime)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([CHAT_GONE, CHAT_B])
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { withChatTabChanges } from './agent-session-tab-layout'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { chatTabCommandsSettled, layoutApplyOn } from './session-layout-apply.test-fixture'
|
||||
|
||||
const WT = 'repo-1::/tmp/worktree-a'
|
||||
|
||||
@@ -34,10 +36,18 @@ function runtimeStore(state: { session: WorkspaceSessionState }) {
|
||||
getWorkspaceSessionHostIds: () => ['local' as const],
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
state.session = next
|
||||
}
|
||||
},
|
||||
applyLayoutCommand: layoutApplyOn(
|
||||
() => state.session,
|
||||
(next) => {
|
||||
state.session = next
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const settled = chatTabCommandsSettled
|
||||
|
||||
/** A chat host with only the members a test's path reads. */
|
||||
function useChatHost(fake: Record<string, unknown>): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each test supplies every host member its path reads.
|
||||
@@ -72,6 +82,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
|
||||
await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true })
|
||||
await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true })
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual(['chat-1'])
|
||||
})
|
||||
@@ -84,6 +95,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
useChatHost({ getSessionTabId: (sessionId: string) => `reserved-${sessionId}` })
|
||||
await runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: WT,
|
||||
@@ -91,6 +103,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(state.session.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual([
|
||||
'structured-agent-session-chat-1',
|
||||
@@ -106,6 +119,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
|
||||
// A window opening the same chat reuses the tab with this id, content type and entity.
|
||||
expect(state.session.unifiedTabs?.[WT]).toEqual([
|
||||
@@ -125,6 +139,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
expect(savedChatIds(state)).toEqual(['chat-1'])
|
||||
// A fresh runtime lists no chats, as when the close RPC or a worker stop runs before a restore.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths.
|
||||
@@ -132,11 +147,12 @@ describe('a chat tab on a host with no window', () => {
|
||||
useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) })
|
||||
|
||||
fresh.onStructuredSessionTabHidden('chat-1')
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([])
|
||||
})
|
||||
|
||||
it("closes this host's chat tab in the local partition and leaves every other partition alone", () => {
|
||||
it("closes this host's chat tab in the local partition and leaves every other partition alone", async () => {
|
||||
const empty: WorkspaceSessionState = {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: WT,
|
||||
@@ -169,7 +185,11 @@ describe('a chat tab on a host with no window', () => {
|
||||
getWorkspaceSession: (hostId = 'local') => sessions.get(hostId) ?? null,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState, hostId = 'local') => {
|
||||
sessions.set(hostId, next)
|
||||
}
|
||||
},
|
||||
applyLayoutCommand: layoutApplyOn(
|
||||
(hostId) => sessions.get(hostId) ?? null,
|
||||
(next, hostId) => sessions.set(hostId, next)
|
||||
)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths.
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
@@ -179,6 +199,7 @@ describe('a chat tab on a host with no window', () => {
|
||||
runtime.onStructuredSessionTabHidden('chat-x')
|
||||
runtime.onStructuredSessionTabHidden('chat-ssh')
|
||||
runtime.onStructuredSessionTabHidden('chat-y')
|
||||
await settled()
|
||||
|
||||
expect(sessions.get('local')?.unifiedTabs?.[WT] ?? []).toEqual([])
|
||||
expect(sessions.get('runtime:env-x')).toBe(remote)
|
||||
@@ -198,7 +219,9 @@ describe('a chat tab on a host with no window', () => {
|
||||
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['chat-1'] })
|
||||
})
|
||||
|
||||
await settled()
|
||||
runtime.onStructuredSessionTabHidden('chat-1')
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual(['chat-1'])
|
||||
})
|
||||
@@ -222,11 +245,13 @@ describe('a chat tab on a host with no window', () => {
|
||||
})
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual(['chat-1'])
|
||||
})
|
||||
|
||||
it('is left to the window when one saves the layout', async () => {
|
||||
// A layout-owner build's main places the chat's tab itself, window or not.
|
||||
it('is left to the window when one saves the layout, unless main owns the layout', async () => {
|
||||
const { runtime, state } = createRuntime()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected member; any non-null value is an attached window.
|
||||
const withWindow = runtime as unknown as { getAvailableAuthoritativeWindow(): unknown }
|
||||
@@ -238,6 +263,77 @@ describe('a chat tab on a host with no window', () => {
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual(ORCA_LAYOUT_OWNER_IS_MAIN ? ['chat-1'] : [])
|
||||
})
|
||||
|
||||
// Switch-on, a window's own launch reserves the chat's place and opens it there once the create
|
||||
// answers; main's publish leaves the placing to that open, in either order.
|
||||
it.each(['publish first', 'window open first'])(
|
||||
"puts a window's chat where the window opens it: %s",
|
||||
async (order) => {
|
||||
const { runtime, state } = createRuntime()
|
||||
for (const sessionId of ['chat-a', 'chat-b']) {
|
||||
await runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: WT,
|
||||
sessionId,
|
||||
agent: 'codex',
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
await settled()
|
||||
const publish = () =>
|
||||
runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: WT,
|
||||
sessionId: 'chat-w',
|
||||
agent: 'codex',
|
||||
activate: true,
|
||||
placedByWindow: true
|
||||
})
|
||||
const windowOpen = () =>
|
||||
runtime.applyLayoutCommand({
|
||||
type: 'openAgentSessionTab',
|
||||
workspace: WT,
|
||||
tabId: structuredAgentSessionTabId('chat-w'),
|
||||
sessionId: 'chat-w',
|
||||
agent: 'codex',
|
||||
afterTabId: structuredAgentSessionTabId('chat-a')
|
||||
})
|
||||
|
||||
if (order === 'publish first') {
|
||||
await publish()
|
||||
await settled()
|
||||
await windowOpen()
|
||||
} else {
|
||||
await windowOpen()
|
||||
await publish()
|
||||
}
|
||||
await settled()
|
||||
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return
|
||||
}
|
||||
expect(state.session.tabGroups?.[WT]?.map((group) => group.tabOrder)).toEqual([
|
||||
['chat-a', 'chat-w', 'chat-b'].map(structuredAgentSessionTabId)
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('closes a chat whose open is still queued when its close arrives', async () => {
|
||||
const { runtime, state } = createRuntime()
|
||||
useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) })
|
||||
|
||||
// Not awaited between: the close reaches main while the open is still in its queue.
|
||||
void runtime.publishStructuredAgentSessionTab({
|
||||
workspaceId: WT,
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex',
|
||||
activate: true
|
||||
})
|
||||
runtime.onStructuredSessionTabHidden('chat-1')
|
||||
await settled()
|
||||
await settled()
|
||||
|
||||
expect(savedChatIds(state)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat-
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import { PROJECT_RUNTIME_REPAIR_REQUIRED_CODE } from '../../../../shared/project-runtime-repair-message'
|
||||
import '../../orca-runtime-test-lifecycle.spec'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
|
||||
vi.mock('../../../ai-vault/structured-chat-cli-fork', () => ({
|
||||
prepareStructuredChatCliFork: vi.fn(),
|
||||
isStructuredChatCliForkSourceCurrent: vi.fn(() => true)
|
||||
@@ -200,6 +202,11 @@ it.each([true, false])(
|
||||
expect(fixture.getSession().unifiedTabs?.[wt]).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: canonical, entityId: source })])
|
||||
)
|
||||
} else if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main placed the published chat's tab itself, so the fork opens beside it.
|
||||
expect(admit.mock.calls[0]?.[0]).toMatchObject({
|
||||
afterTabId: structuredAgentSessionTabId(source)
|
||||
})
|
||||
} else {
|
||||
expect(admit.mock.calls[0]?.[0]).not.toHaveProperty('afterTabId')
|
||||
expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('afterTabId')
|
||||
|
||||
+33
-11
@@ -1,15 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX } from '../../../../../shared/browser-annotation-viewport-bridge'
|
||||
import { createBrowserMockApi, createTestStore } from '@/store/slices/browser-slice-test-harness'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createBrowserMockApi } from '@/store/slices/browser-slice-test-harness'
|
||||
import { createTestStoreDrawnFromMain } from '@/store/slices/browser-slice-drawn-from-main.test-fixture'
|
||||
import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture'
|
||||
import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge'
|
||||
import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers'
|
||||
import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function createHarness() {
|
||||
async function createHarness() {
|
||||
const setAnnotationViewportBridge = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -17,11 +31,19 @@ function createHarness() {
|
||||
browser: { ...createBrowserMockApi(vi.fn()).browser, setAnnotationViewportBridge }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const pageId = store.getState().createBrowserTab('wt-1', 'https://example.com').activePageId
|
||||
await main.settle()
|
||||
if (!pageId) {
|
||||
throw new Error('Expected browser page')
|
||||
}
|
||||
// A layout-owner build's main opened the page's tab, and its frame drew the page.
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId })] : []
|
||||
)
|
||||
expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toEqual([
|
||||
expect.objectContaining({ id: pageId })
|
||||
])
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId))
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
const cancelCapture = vi.fn()
|
||||
@@ -106,8 +128,8 @@ function navigationEvent(
|
||||
}
|
||||
|
||||
describe('browser annotation document boundaries', () => {
|
||||
it('still accepts token-authenticated live target reports', () => {
|
||||
const h = createHarness()
|
||||
it('still accepts token-authenticated live target reports', async () => {
|
||||
const h = await createHarness()
|
||||
const report = JSON.stringify({
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
@@ -134,8 +156,8 @@ describe('browser annotation document boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', () => {
|
||||
const h = createHarness()
|
||||
it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', async () => {
|
||||
const h = await createHarness()
|
||||
const saved = h.store.getState().browserAnnotationsByPageId[h.pageId]
|
||||
h.loading.handleDidStartLoading()
|
||||
expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved)
|
||||
@@ -147,8 +169,8 @@ describe('browser annotation document boundaries', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('retires geometry for full and same-document main-frame navigation but not subframes', () => {
|
||||
const h = createHarness()
|
||||
it('retires geometry for full and same-document main-frame navigation but not subframes', async () => {
|
||||
const h = await createHarness()
|
||||
h.navigation.handleDidStartNavigation(
|
||||
navigationEvent('https://example.com/frame', false, false)
|
||||
)
|
||||
@@ -162,8 +184,8 @@ describe('browser annotation document boundaries', () => {
|
||||
expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps geometry retired after redirects and away/back commits, including missed start events', () => {
|
||||
const h = createHarness()
|
||||
it('keeps geometry retired after redirects and away/back commits, including missed start events', async () => {
|
||||
const h = await createHarness()
|
||||
const saved = h.store.getState().browserAnnotationsByPageId[h.pageId]
|
||||
h.loading.handleDidStartLoading()
|
||||
h.navigation.handleDidRedirectNavigation(navigationEvent('https://example.org/redirect'))
|
||||
|
||||
+10
-1
@@ -79,6 +79,7 @@ vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
})
|
||||
|
||||
import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
|
||||
import {
|
||||
@@ -361,7 +362,15 @@ describe('a chat whose start was never confirmed', () => {
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
expect(recreated(CLAUDE_SESSION)).toBe(0)
|
||||
expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled')
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Switch-on, main's host is told to close the chat instead of the window keeping a tombstone.
|
||||
expect(mocks.history).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.close', {
|
||||
sessionId: CLAUDE_SESSION
|
||||
})
|
||||
expect(lifecycle(CLAUDE_SESSION)).toBeNull()
|
||||
} else {
|
||||
expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled')
|
||||
}
|
||||
})
|
||||
|
||||
it('re-checks once per return of its own host: one still unknown waits for the next', async () => {
|
||||
|
||||
+42
-1
@@ -8,6 +8,8 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau
|
||||
import type { AgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
@@ -78,7 +80,22 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) =>
|
||||
structuredSession: { sessionId: string; workspaceId: string }
|
||||
}) => {
|
||||
const tabs = useAppStore.getState().unifiedTabsByWorktree[s.workspaceId] ?? []
|
||||
if (!tabs.some((tab) => tab.entityId === s.sessionId)) {
|
||||
if (tabs.some((tab) => tab.entityId === s.sessionId)) {
|
||||
return true
|
||||
}
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Switch-on, the host's republished chat reaches the window as main's frame.
|
||||
await layoutApplyTestTransport.call({
|
||||
command: {
|
||||
type: 'openAgentSessionTab',
|
||||
workspace: s.workspaceId,
|
||||
tabId: s.sessionId,
|
||||
sessionId: s.sessionId,
|
||||
agent: 'claude'
|
||||
}
|
||||
})
|
||||
await layoutApplyTestTransport.settle()
|
||||
} else {
|
||||
useAppStore.getState().createUnifiedTab(s.workspaceId, 'agent-session', {
|
||||
id: s.sessionId,
|
||||
entityId: s.sessionId,
|
||||
@@ -91,7 +108,23 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) =>
|
||||
)
|
||||
}))
|
||||
|
||||
// Switch-on, main opens and closes the chat tabs and its frames draw them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe(): void {}
|
||||
@@ -222,7 +255,11 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
@@ -270,6 +307,7 @@ describe('Session History keyboard selection', () => {
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
}
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
const order = () => store().groupsByWorktree[workspaceId]?.[0]?.tabOrder
|
||||
const resumed = structuredAgentSessionTabId('new')
|
||||
const confirm = vi.fn<(options: unknown) => Promise<boolean>>()
|
||||
@@ -278,6 +316,7 @@ describe('Session History keyboard selection', () => {
|
||||
const settle = async () =>
|
||||
act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await main?.settle()
|
||||
})
|
||||
const pick = async (settlement: StructuredAgentLaunchSettlement, enter = press) => {
|
||||
launch.settlement = settlement
|
||||
@@ -339,6 +378,7 @@ describe('Session History keyboard selection', () => {
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
}
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
const order = () => useAppStore.getState().groupsByWorktree[workspaceId]?.[0]?.tabOrder
|
||||
const dismiss = vi.fn()
|
||||
const { press } = renderList({
|
||||
@@ -357,6 +397,7 @@ describe('Session History keyboard selection', () => {
|
||||
press('Enter')
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await main?.settle()
|
||||
})
|
||||
}
|
||||
await refusedBy('old')
|
||||
|
||||
@@ -36,6 +36,17 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toast } }))
|
||||
vi.mock('@/lib/activate-ai-vault-structured-session', () => ({
|
||||
activateAiVaultStructuredSession: mocks.activate
|
||||
}))
|
||||
// Switch-on, main opens the chat's tab and its frame draws it.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan'
|
||||
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
|
||||
@@ -62,6 +73,8 @@ import { buildHydratedTabState } from '@/store/slices/tabs-hydration'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { runCliChatResume } from './cli-chat-resume-run'
|
||||
import {
|
||||
resetStructuredAgentLaunchRegistryForTests,
|
||||
@@ -75,6 +88,10 @@ import {
|
||||
} from '@/runtime/web-session-focus-intent'
|
||||
import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner'
|
||||
import { registerSessionTabIpcBridge } from '@/hooks/ipc-events/session-tab-ipc-bridge'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const SOURCE: CliChatResumeSource = {
|
||||
tabId: 'tab-1',
|
||||
@@ -85,7 +102,12 @@ const SOURCE: CliChatResumeSource = {
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const tabs = () => useAppStore.getState().unifiedTabsByWorktree['wt-1'] ?? []
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((done) => {
|
||||
@@ -102,7 +124,7 @@ function refuse(reason: 'transcriptNotFound' | 'transcriptUnreadable'): AgentSes
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
resetLocalStructuredSessionVersionForTests()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
@@ -122,6 +144,7 @@ beforeEach(() => {
|
||||
return { ok: true, value: { sessionId: params.envelope.sessionId, fence: 1 } }
|
||||
}
|
||||
)
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
})
|
||||
|
||||
function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult {
|
||||
@@ -147,10 +170,18 @@ function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult {
|
||||
}
|
||||
}
|
||||
|
||||
function seedTabs() {
|
||||
return ['E', 'S', 'B'].map((id) =>
|
||||
/** Main takes the store's current layout, as after a restart; seeding after it is invisible to main. */
|
||||
async function redrawFromMain(): Promise<void> {
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
}
|
||||
|
||||
async function seedTabs() {
|
||||
const seeded = ['E', 'S', 'B'].map((id) =>
|
||||
useAppStore.getState().createUnifiedTab('wt-1', 'editor', { id, entityId: id })
|
||||
)
|
||||
await redrawFromMain()
|
||||
return seeded
|
||||
}
|
||||
|
||||
describe('CLI identity resume through the existing launch owner', () => {
|
||||
@@ -325,7 +356,7 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
])(
|
||||
'keeps canonical membership through registered IPC (%s first, move %s to %s)',
|
||||
async (first, mover, requested, expected) => {
|
||||
const [e, source, browser] = seedTabs()
|
||||
const [e, source, browser] = await seedTabs()
|
||||
let move: Parameters<typeof window.api.ui.onMoveSessionTab>[0] = () => {}
|
||||
vi.stubGlobal('api', {
|
||||
...window.api,
|
||||
@@ -352,6 +383,7 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
const later = useAppStore
|
||||
.getState()
|
||||
.createUnifiedTab('wt-1', 'editor', { id: 'R', entityId: 'R' })
|
||||
await redrawFromMain()
|
||||
const beforeNoop = useAppStore.getState()
|
||||
move({
|
||||
kind: 'reorder',
|
||||
@@ -379,6 +411,7 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
entityId: 'other',
|
||||
split: { splitDirection: 'right', sourceGroupId: e.groupId }
|
||||
})
|
||||
await redrawFromMain()
|
||||
clearWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1')
|
||||
if (first === 'inventory') {
|
||||
applyStructuredSessionTabSnapshots([snapshot(sessionId)])
|
||||
@@ -398,10 +431,26 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Main's publish of a window's create places nothing switch-on, so the resume's own open must.
|
||||
it('opens a resumed chat once, through main, after the tab before its hidden slot', async () => {
|
||||
const [, , browser] = await seedTabs()
|
||||
await runCliChatResume(SOURCE)
|
||||
await main?.settle()
|
||||
const sessionId: string = mocks.call.mock.calls[0][2].envelope.sessionId
|
||||
const slotId = `structured-agent-session-${sessionId}`
|
||||
expect(tabs().find((tab) => tab.entityId === sessionId)?.id).toBe(slotId)
|
||||
expect(useAppStore.getState().groupsByWorktree['wt-1'][0].tabOrder.at(-1)).toBe(slotId)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([
|
||||
expect.objectContaining({ tabId: slotId, sessionId, afterTabId: browser.id })
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps overlapping requests in their canonical order (second finishes first: %s)',
|
||||
async (reverse) => {
|
||||
const initial = seedTabs()
|
||||
const initial = await seedTabs()
|
||||
const replies = [deferred<unknown>(), deferred<unknown>()]
|
||||
mocks.call
|
||||
.mockImplementationOnce(() => replies[0].promise)
|
||||
@@ -424,7 +473,7 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('restores a pending-only group as unknown without sending create or restoring focus', () => {
|
||||
it('restores a pending-only group as unknown without sending create or restoring focus', async () => {
|
||||
const record = {
|
||||
sessionId: 'reload-pending',
|
||||
executionHostId: 'local' as const,
|
||||
@@ -461,9 +510,11 @@ describe('CLI identity resume through the existing launch owner', () => {
|
||||
...buildHydratedTabState(session, new Set(['wt-1'])),
|
||||
worktreesByRepo: { repo: [makeWorktree({ id: 'wt-1', repoId: 'repo', hostId: 'local' })] }
|
||||
})
|
||||
await redrawFromMain()
|
||||
restoreStructuredLaunchSurfaces()
|
||||
restoreStructuredLaunchSurfaces()
|
||||
applyStructuredSessionTabSnapshots([snapshot()])
|
||||
await main?.settle()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
expect(
|
||||
peekWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1')
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock('@/store', () => ({
|
||||
tabsByWorktree: {},
|
||||
closedTerminalTabTombstonesByTabId: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
// A switch-on chat open places the tab in the workspace's root group.
|
||||
ensureWorktreeRootGroup: () => 'group-1',
|
||||
createUnifiedTab: mocks.createUnifiedTab,
|
||||
setActiveTabType: vi.fn()
|
||||
})
|
||||
@@ -47,11 +51,20 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
: new Promise(() => undefined)
|
||||
}))
|
||||
|
||||
// Main answers the window's chat-tab open (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
|
||||
import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects'
|
||||
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
|
||||
import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims'
|
||||
import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
let root: Root | undefined
|
||||
@@ -61,8 +74,40 @@ afterEach(async () => {
|
||||
root = undefined
|
||||
mocks.answers.length = 0
|
||||
vi.clearAllMocks()
|
||||
layoutApplyTestTransport.reset()
|
||||
})
|
||||
|
||||
/** No shell created here or (switch-on) asked of main. */
|
||||
function expectNoShell(): void {
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([])
|
||||
}
|
||||
|
||||
/** The watcher's shell create: here, or (switch-on) sent to main, whose frame draws it. */
|
||||
async function expectShellCreatedOnce(): Promise<void> {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await layoutApplyTestTransport.settle()
|
||||
expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([
|
||||
expect.objectContaining({ workspace: 'wt-1' })
|
||||
])
|
||||
return
|
||||
}
|
||||
expect(mocks.createTab).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
/** The chat opened: drawn here, or (switch-on) its open sent to main, whose frame draws it. */
|
||||
async function expectChatOpenedOnce(): Promise<void> {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await layoutApplyTestTransport.settle()
|
||||
expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([
|
||||
expect.objectContaining({ workspace: 'wt-1', sessionId: 'claude_1' })
|
||||
])
|
||||
expect(mocks.createUnifiedTab).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
expect(mocks.createUnifiedTab).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
function Watcher(): null {
|
||||
useTerminalWatcherEffects({
|
||||
activeWorktreeId: 'wt-1',
|
||||
@@ -142,12 +187,12 @@ describe('a local chat launch waiting on its host', () => {
|
||||
mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty')
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
expectNoShell()
|
||||
|
||||
await act(async () => root?.unmount())
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
expect(mocks.createTab).toHaveBeenCalledOnce()
|
||||
await expectShellCreatedOnce()
|
||||
})
|
||||
|
||||
it('keeps a watched create free of a stray shell until its chat opens', async () => {
|
||||
@@ -163,11 +208,11 @@ describe('a local chat launch waiting on its host', () => {
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
await act(async () => finish())
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
expectNoShell()
|
||||
|
||||
await act(async () => mocks.answers[0]?.({ supported: true }))
|
||||
expect(mocks.createUnifiedTab).toHaveBeenCalledOnce()
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
await expectChatOpenedOnce()
|
||||
expectNoShell()
|
||||
})
|
||||
|
||||
it("keeps an empty workspace's default chat free of a shell seeded by the shared gate", async () => {
|
||||
@@ -181,11 +226,11 @@ describe('a local chat launch waiting on its host', () => {
|
||||
await act(async () => root?.render(createElement(Watcher)))
|
||||
await act(async () => finish())
|
||||
expect(mocks.begin).not.toHaveBeenCalled()
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
expectNoShell()
|
||||
|
||||
await act(async () => mocks.answers[0]?.({ supported: true }))
|
||||
expect(mocks.createUnifiedTab).toHaveBeenCalledOnce()
|
||||
expect(mocks.createTab).not.toHaveBeenCalled()
|
||||
await expectChatOpenedOnce()
|
||||
expectNoShell()
|
||||
})
|
||||
|
||||
it('stops owning the surface once the host has answered and its surface opened', async () => {
|
||||
|
||||
@@ -21,11 +21,27 @@ import {
|
||||
makeWorktree
|
||||
} from './worktree-jump-palette-test-fixtures'
|
||||
import type { QuickActionPaletteItem } from './worktree-jump-palette-model'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({
|
||||
useSettingsNavigationMetadata: () => []
|
||||
}))
|
||||
|
||||
// Main answers the window's layout commands; its frames draw the browser tab.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const { openWorkspaceBrowserTab } = vi.hoisted(() => ({ openWorkspaceBrowserTab: vi.fn() }))
|
||||
vi.mock(import('@/lib/workspace-browser-tab-open'), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
@@ -111,6 +127,8 @@ function usePalette() {
|
||||
}
|
||||
|
||||
describe('Cmd+J website selection', () => {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
openWorkspaceBrowserTab.mockReset().mockResolvedValue(undefined)
|
||||
useAppStore.setState(
|
||||
@@ -130,6 +148,8 @@ describe('Cmd+J website selection', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
main?.close()
|
||||
main = undefined
|
||||
vi.restoreAllMocks()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
@@ -220,9 +240,11 @@ describe('Cmd+J website selection', () => {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
useAppStore.getState().createBrowserTab('wt-beta', 'https://example.com/', {
|
||||
title: name
|
||||
})
|
||||
await main.settle()
|
||||
}
|
||||
const { result } = renderHook(usePalette)
|
||||
|
||||
|
||||
@@ -1,12 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import type { Tab, TabGroup } from '../../../shared/tab-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
|
||||
|
||||
// Switch on, the open goes to main as `layout.apply` and main's frame draws the tab.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
type DrawnStore = { getState: () => AppState }
|
||||
const drawn = vi.hoisted((): { store: DrawnStore | null; main: TabsDrawnFromMain | null } => ({
|
||||
store: null,
|
||||
main: null
|
||||
}))
|
||||
|
||||
/** Switch on: a real store seeded like the mock (whose tabs it keeps), drawn from main's frames. */
|
||||
async function drawFromMain(): Promise<{
|
||||
store: DrawnStore
|
||||
sent: () => readonly { type: string }[]
|
||||
}> {
|
||||
createStoreCascadesMockApi()
|
||||
const store = createTestStore()
|
||||
const unifiedTabsByWorktree = Object.fromEntries(
|
||||
Object.entries(mockStoreState.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.map((tab): Tab => ({
|
||||
entityId: tab.id,
|
||||
label: 'Mobile Emulator',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
worktreeId,
|
||||
...tab,
|
||||
contentType: 'simulator'
|
||||
}))
|
||||
])
|
||||
)
|
||||
const groupsByWorktree = Object.fromEntries(
|
||||
Object.entries(mockStoreState.groupsByWorktree).map(([worktreeId, groups]) => [
|
||||
worktreeId,
|
||||
groups.map(({ id }): TabGroup => ({
|
||||
id,
|
||||
worktreeId,
|
||||
activeTabId: null,
|
||||
tabOrder: (unifiedTabsByWorktree[worktreeId] ?? [])
|
||||
.filter((tab) => tab.groupId === id)
|
||||
.map((tab) => tab.id)
|
||||
}))
|
||||
])
|
||||
)
|
||||
store.setState({
|
||||
activeWorktreeId: mockStoreState.activeWorktreeId,
|
||||
activeWorkspaceExecutionHostId: mockStoreState.activeWorkspaceExecutionHostId,
|
||||
// One repo per row, so same-id rows on two hosts stay apart.
|
||||
worktreesByRepo: Object.fromEntries(
|
||||
mockStoreState
|
||||
.allWorktrees()
|
||||
.map((row, index) => [
|
||||
`repo-${index}`,
|
||||
[makeWorktree({ ...row, repoId: `repo-${index}`, path: `/path/${index}` })]
|
||||
])
|
||||
),
|
||||
folderWorkspaces: [],
|
||||
groupsByWorktree,
|
||||
layoutByWorktree: Object.fromEntries(
|
||||
Object.entries(groupsByWorktree).map(([worktreeId, [first, second]]) => [
|
||||
worktreeId,
|
||||
second
|
||||
? {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: first!.id },
|
||||
second: { type: 'leaf', groupId: second.id }
|
||||
}
|
||||
: { type: 'leaf', groupId: first!.id }
|
||||
])
|
||||
),
|
||||
activeGroupIdByWorktree: { ...mockStoreState.activeGroupIdByWorktree },
|
||||
unifiedTabsByWorktree
|
||||
})
|
||||
drawn.main = await drawTabsFromMain(store)
|
||||
drawn.store = store
|
||||
return { store, sent: () => layoutApplyTestTransport.sent }
|
||||
}
|
||||
|
||||
/** Switch on: opens through main, draws main's answer, and returns the drawn tab and the open
|
||||
* main got (over this machine's `layout.apply`, the only target the transport answers). */
|
||||
async function openThroughMain(open: () => string | null): Promise<{
|
||||
tabId: string | null
|
||||
tab: Tab | undefined
|
||||
sent: { type: string; groupId?: string; split?: unknown }[]
|
||||
store: DrawnStore
|
||||
}> {
|
||||
const { store, sent } = await drawFromMain()
|
||||
const tabId = open()
|
||||
await drawn.main!.settle()
|
||||
return {
|
||||
tabId,
|
||||
tab: store.getState().unifiedTabsByWorktree['wt-1']?.find((entry) => entry.id === tabId),
|
||||
sent: sent().filter((command) => command.type === 'openSimulatorTab'),
|
||||
store
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A background open for this machine's host, in group-1, drawn local and never selected.
|
||||
* `viaMain` false: a paired server owns the active twin, so the window draws it as today.
|
||||
*/
|
||||
async function expectBackgroundLocalOpen(viaMain: boolean): Promise<void> {
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
const { tabId, tab, sent, store } = await openThroughMain(() =>
|
||||
ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })
|
||||
)
|
||||
expect(sent).toEqual(viaMain ? [expect.objectContaining({ tabId, groupId: 'group-1' })] : [])
|
||||
expect(tab).toMatchObject({
|
||||
contentType: 'simulator',
|
||||
label: 'Mobile Emulator',
|
||||
groupId: 'group-1',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
expectNotSurfaced(store, tab!)
|
||||
}
|
||||
|
||||
/** The window selected the tab, its group and the simulator view, as a surfaced open does. */
|
||||
function expectSurfaced(store: DrawnStore, tab: Tab): void {
|
||||
const state = store.getState()
|
||||
const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId)
|
||||
expect(group?.activeTabId).toBe(tab.id)
|
||||
expect(group?.recentTabIds).toContain(tab.id)
|
||||
expect(state.activeGroupIdByWorktree['wt-1']).toBe(tab.groupId)
|
||||
expect(state.activeTabType).toBe('simulator')
|
||||
}
|
||||
|
||||
/** No focus: not in the group's recents, no focus time, the view type unchanged. */
|
||||
function expectNotSurfaced(store: DrawnStore, tab: Tab): void {
|
||||
const state = store.getState()
|
||||
const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId)
|
||||
expect(group?.recentTabIds ?? []).not.toContain(tab.id)
|
||||
expect(tab.lastFocusedAt).toBeUndefined()
|
||||
expect(state.activeTabType).not.toBe('simulator')
|
||||
}
|
||||
|
||||
type MockWorktreeRow = { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }
|
||||
type MockTabRow = {
|
||||
id: string
|
||||
groupId: string
|
||||
worktreeId?: string
|
||||
executionHostId?: ExecutionHostId
|
||||
contentType: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const mockStoreState = vi.hoisted(() => ({
|
||||
activeGroupIdByWorktree: {} as Record<string, string>,
|
||||
activeWorkspaceExecutionHostId: null as string | null,
|
||||
activeWorkspaceExecutionHostId: ((): ExecutionHostId | null => null)(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
allWorktrees: vi.fn(
|
||||
() => [] as { id: string; hostId?: string; runtimeOwnerEnvironmentId?: string }[]
|
||||
),
|
||||
allWorktrees: vi.fn((): MockWorktreeRow[] => []),
|
||||
activateTab: vi.fn(),
|
||||
createEmptySplitGroup: vi.fn(),
|
||||
createUnifiedTab: vi.fn(),
|
||||
@@ -30,17 +195,7 @@ const mockStoreState = vi.hoisted(() => ({
|
||||
settings: { mobileEmulatorEnabled: true },
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
unifiedTabsByWorktree: {} as Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
groupId: string
|
||||
worktreeId?: string
|
||||
executionHostId?: string
|
||||
contentType: string
|
||||
label?: string
|
||||
}[]
|
||||
>
|
||||
unifiedTabsByWorktree: ((): Record<string, MockTabRow[]> => ({}))()
|
||||
}))
|
||||
|
||||
function stubCreatedSimulatorTab(groupId: string): void {
|
||||
@@ -60,7 +215,7 @@ function createdSimulatorTabId(): string {
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState
|
||||
getState: () => drawn.store?.getState() ?? mockStoreState
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -89,7 +244,16 @@ describe('ensureSimulatorTab', () => {
|
||||
mockStoreState.focusGroup.mockReset()
|
||||
mockStoreState.setActiveTab.mockReset()
|
||||
mockStoreState.setActiveTabType.mockReset()
|
||||
vi.resetModules()
|
||||
// Switch on, main's transport and frames live in modules a reset would orphan.
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
drawn.main?.close()
|
||||
drawn.main = null
|
||||
drawn.store = null
|
||||
})
|
||||
|
||||
it('activates an existing simulator tab through unified tab state', async () => {
|
||||
@@ -123,6 +287,18 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-1')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// The active twin is host-b's, a paired server's: the window draws it as today.
|
||||
const { tabId, tab, sent } = await openThroughMain(() => ensureSimulatorTab('wt-1'))
|
||||
expect(tabId).not.toBe('sim-local')
|
||||
expect(sent).toEqual([])
|
||||
expect(tab).toMatchObject({
|
||||
contentType: 'simulator',
|
||||
groupId: 'group-1',
|
||||
executionHostId: 'runtime:host-b'
|
||||
})
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1')).toBe(createdSimulatorTabId())
|
||||
expect(mockStoreState.createUnifiedTab).toHaveBeenCalled()
|
||||
})
|
||||
@@ -232,6 +408,19 @@ describe('ensureSimulatorTab', () => {
|
||||
})
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const { tab, sent, store } = await openThroughMain(() =>
|
||||
ensureSimulatorTab('wt-1', { placement: 'rightSplit' })
|
||||
)
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({ split: { besideGroupId: 'group-1', direction: 'right' } })
|
||||
])
|
||||
expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' })
|
||||
expect(tab!.groupId).not.toBe('group-1')
|
||||
expect(sent[0]).toMatchObject({ groupId: tab!.groupId })
|
||||
expectSurfaced(store, tab!)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe('sim-2')
|
||||
|
||||
expect(mockStoreState.createEmptySplitGroup).not.toHaveBeenCalled()
|
||||
@@ -268,6 +457,16 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-2')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const { tab, sent, store } = await openThroughMain(() =>
|
||||
ensureSimulatorTab('wt-1', { placement: 'rightSplit' })
|
||||
)
|
||||
expect(sent).toEqual([expect.objectContaining({ groupId: 'group-2' })])
|
||||
expect(sent[0]).not.toHaveProperty('split')
|
||||
expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-2' })
|
||||
expectSurfaced(store, tab!)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId())
|
||||
|
||||
expect(mockStoreState.createUnifiedTabInSplit).not.toHaveBeenCalled()
|
||||
@@ -288,6 +487,18 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-1')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main makes the split with the tab in one apply: no half-made split to fall back from.
|
||||
const { tabId, tab, sent, store } = await openThroughMain(() =>
|
||||
ensureSimulatorTab('wt-1', { placement: 'rightSplit' })
|
||||
)
|
||||
expect(sent).toEqual([
|
||||
expect.objectContaining({ tabId, split: { besideGroupId: 'group-1', direction: 'right' } })
|
||||
])
|
||||
expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' })
|
||||
expectSurfaced(store, tab!)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId())
|
||||
|
||||
expect(mockStoreState.createUnifiedTabInSplit).toHaveBeenCalledWith(
|
||||
@@ -317,6 +528,16 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-1')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const { tabId, tab, sent, store } = await openThroughMain(() =>
|
||||
ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false })
|
||||
)
|
||||
expect(sent).toEqual([expect.objectContaining({ tabId, groupId: 'group-1' })])
|
||||
expect(sent[0]).not.toHaveProperty('split')
|
||||
expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-1' })
|
||||
expectNotSurfaced(store, tab!)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false })).toBe(
|
||||
createdSimulatorTabId()
|
||||
)
|
||||
@@ -345,6 +566,11 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-1')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// This machine's twin goes to main; its frame names the tab's host.
|
||||
await expectBackgroundLocalOpen(true)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe(
|
||||
createdSimulatorTabId()
|
||||
)
|
||||
@@ -374,6 +600,10 @@ describe('ensureSimulatorTab', () => {
|
||||
stubCreatedSimulatorTab('group-1')
|
||||
const { ensureSimulatorTab } = await import('./ensure-simulator-tab')
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await expectBackgroundLocalOpen(false)
|
||||
return
|
||||
}
|
||||
expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe(
|
||||
createdSimulatorTabId()
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias'
|
||||
import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { recordOpenedSimulatorTabInLayoutOwner } from './simulator-tab-layout-owner'
|
||||
import {
|
||||
openingSimulatorTabId,
|
||||
openSimulatorTabThroughLayoutOwner
|
||||
} from './simulator-tab-layout-owner'
|
||||
import { selectFolderWorkspaces } from '@/store/workspace-record-selectors'
|
||||
|
||||
type EnsureSimulatorTabOptions = {
|
||||
@@ -102,7 +105,9 @@ export function ensureSimulatorTab(
|
||||
const sourceGroupId =
|
||||
options?.targetGroupId ??
|
||||
store.activeGroupIdByWorktree[worktreeId] ??
|
||||
store.groupsByWorktree[worktreeId]?.[0]?.id
|
||||
store.groupsByWorktree[worktreeId]?.[0]?.id ??
|
||||
// Main's frames draw no empty group: a layout-owner build opens into the workspace's root one.
|
||||
(ORCA_LAYOUT_OWNER_IS_MAIN ? store.ensureWorktreeRootGroup(worktreeId) : undefined)
|
||||
if (!sourceGroupId) {
|
||||
return null
|
||||
}
|
||||
@@ -124,74 +129,69 @@ export function ensureSimulatorTab(
|
||||
return existing.id
|
||||
}
|
||||
|
||||
if (options?.placement === 'rightSplit' && shouldSurface) {
|
||||
const reusableRightGroupId = findReusableRightSplitGroupId(
|
||||
store.layoutByWorktree[worktreeId],
|
||||
sourceGroupId
|
||||
)
|
||||
if (reusableRightGroupId) {
|
||||
const tabId = createBrowserUuid()
|
||||
store.createUnifiedTab(worktreeId, 'simulator', {
|
||||
id: tabId,
|
||||
label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'),
|
||||
targetGroupId: reusableRightGroupId,
|
||||
activate: true,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordOpenedSimulatorTab(tabId)
|
||||
}
|
||||
surfaceCreatedSimulatorTab(worktreeId, tabId)
|
||||
return tabId
|
||||
}
|
||||
|
||||
const label = translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator')
|
||||
const reusableRightGroupId =
|
||||
options?.placement === 'rightSplit' && shouldSurface
|
||||
? findReusableRightSplitGroupId(store.layoutByWorktree[worktreeId], sourceGroupId)
|
||||
: null
|
||||
const rightSplit = options?.placement === 'rightSplit' && shouldSurface && !reusableRightGroupId
|
||||
const groupId = reusableRightGroupId ?? sourceGroupId
|
||||
const drawHere = (tabId?: string): string | null => {
|
||||
// Why: publish the simulator directly in its split group. A two-step
|
||||
// create-then-move can persist the midpoint during dev reload/HMR.
|
||||
const splitTab = store.createUnifiedTabInSplit(
|
||||
worktreeId,
|
||||
'simulator',
|
||||
{
|
||||
sourceGroupId,
|
||||
splitDirection: 'right'
|
||||
},
|
||||
{
|
||||
label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'),
|
||||
activate: true,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
}
|
||||
)
|
||||
const splitTab = rightSplit
|
||||
? store.createUnifiedTabInSplit(
|
||||
worktreeId,
|
||||
'simulator',
|
||||
{ sourceGroupId, splitDirection: 'right' },
|
||||
{ ...(tabId ? { id: tabId } : {}), label, activate: true, ...hostOf(executionHostId) }
|
||||
)
|
||||
: null
|
||||
if (splitTab) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordOpenedSimulatorTab(splitTab.id)
|
||||
}
|
||||
return splitTab.id
|
||||
}
|
||||
const id = tabId ?? createBrowserUuid()
|
||||
store.createUnifiedTab(worktreeId, 'simulator', {
|
||||
id,
|
||||
label,
|
||||
targetGroupId: groupId,
|
||||
activate: reusableRightGroupId ? true : shouldSurface,
|
||||
...hostOf(executionHostId)
|
||||
})
|
||||
if (reusableRightGroupId || shouldSurface) {
|
||||
surfaceCreatedSimulatorTab(worktreeId, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const tabId = createBrowserUuid()
|
||||
store.createUnifiedTab(worktreeId, 'simulator', {
|
||||
id: tabId,
|
||||
label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'),
|
||||
targetGroupId: sourceGroupId,
|
||||
activate: shouldSurface,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
// Switch point: main opens the tab and its frame draws it; the window surfaces it after.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordOpenedSimulatorTab(tabId)
|
||||
const opening = openingSimulatorTabId(worktreeId)
|
||||
if (opening) {
|
||||
return opening
|
||||
}
|
||||
const sent = openSimulatorTabThroughLayoutOwner(store, {
|
||||
worktreeId,
|
||||
label,
|
||||
groupId,
|
||||
rightSplit,
|
||||
surface: (tabId) => {
|
||||
if (shouldSurface) {
|
||||
surfaceCreatedSimulatorTab(worktreeId, tabId)
|
||||
}
|
||||
},
|
||||
drawHere
|
||||
})
|
||||
if (sent) {
|
||||
return sent
|
||||
}
|
||||
}
|
||||
if (shouldSurface) {
|
||||
surfaceCreatedSimulatorTab(worktreeId, tabId)
|
||||
}
|
||||
return tabId
|
||||
return drawHere()
|
||||
}
|
||||
|
||||
/** Switch point: main records the simulator tab the window just drew. */
|
||||
function recordOpenedSimulatorTab(tabId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
const tab = state.getTab(tabId)
|
||||
if (tab) {
|
||||
recordOpenedSimulatorTabInLayoutOwner(state, tab)
|
||||
}
|
||||
function hostOf(executionHostId: ExecutionHostId | undefined): {
|
||||
executionHostId?: ExecutionHostId
|
||||
} {
|
||||
return executionHostId ? { executionHostId } : {}
|
||||
}
|
||||
|
||||
function surfaceCreatedSimulatorTab(worktreeId: string, tabId: string): void {
|
||||
|
||||
+400
-167
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredLaunchSlotOwner } from '@/lib/structured-agent-session-launch-slot'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
|
||||
@@ -15,6 +15,28 @@ import {
|
||||
import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from '../structured-agent-session-launch-persistence'
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { useAppStore } from '@/store'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
import { makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
|
||||
// Switch on, main draws the window's tabs: `layout.apply` and its frames are the test's main.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const layoutOwner: StructuredLaunchSlotOwner = {
|
||||
unifiedTabsByWorktree: {},
|
||||
@@ -24,6 +46,10 @@ const layoutOwner: StructuredLaunchSlotOwner = {
|
||||
|
||||
const WORKTREE_ID = 'repo-1::worktree-1'
|
||||
const SESSION_ID = 'session-close-race'
|
||||
// A paired server keeps the window's tombstone in both builds; this machine keeps one only with
|
||||
// the switch off (with it on, main hears the cancel: structured-launch-cancel-layout-owner.test.ts).
|
||||
const SERVER_HOST = 'runtime:server-1'
|
||||
const SERVER = { kind: 'environment', environmentId: 'server-1' } as const
|
||||
|
||||
function latePublication(): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
@@ -46,6 +72,157 @@ function latePublication(): RuntimeMobileSessionTabsResult {
|
||||
}
|
||||
}
|
||||
|
||||
/** A launch whose create is on its way to `executionHostId`. */
|
||||
function setLaunch(
|
||||
executionHostId: ExecutionHostId,
|
||||
target: RuntimeClientTarget,
|
||||
promise: Promise<{ sessionId: string; fence: number }>,
|
||||
operation = 'close-race'
|
||||
): void {
|
||||
setStructuredLaunchState({
|
||||
identity: `codex:${WORKTREE_ID}`,
|
||||
intent: {
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId,
|
||||
target,
|
||||
sessionId: SESSION_ID,
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: `operation-${operation}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${operation}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
},
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
},
|
||||
promise,
|
||||
visibilityUnknown: false,
|
||||
cancelled: false,
|
||||
selection: { held: {} }
|
||||
} satisfies StructuredLaunchState)
|
||||
}
|
||||
|
||||
/** The cancelled launch's late publication stays hidden until the launch settles, on `host`. */
|
||||
async function expectLatePublicationSuppressed(
|
||||
host: ExecutionHostId,
|
||||
target: RuntimeClientTarget
|
||||
): Promise<void> {
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
setLaunch(
|
||||
host,
|
||||
target,
|
||||
new Promise((resolve) => {
|
||||
resolveLaunch = resolve
|
||||
})
|
||||
)
|
||||
const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host)
|
||||
).toBe(false)
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, host, layoutOwner)
|
||||
const afterCancel = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterCancel, host)
|
||||
).toBe(false)
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
|
||||
resolveLaunch({ sessionId: SESSION_ID, fence: 1 })
|
||||
await Promise.resolve()
|
||||
expect(suppressCancelledStructuredSessionTabs(latePublication(), target).tabs).toEqual([])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host)
|
||||
).toBe(false)
|
||||
|
||||
const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterSettlement, host)
|
||||
).toBe(true)
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(false)
|
||||
}
|
||||
|
||||
const TERMINAL = 'terminal-1'
|
||||
let main: TabsDrawnFromMain | null = null
|
||||
|
||||
/** Switch on: the worktree, with one terminal, drawn from main's frames. */
|
||||
async function drawWorktreeFromMain(): Promise<void> {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { 'repo-1': [makeWorktree({ id: WORKTREE_ID, repoId: 'repo-1' })] },
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TERMINAL,
|
||||
ptyId: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TERMINAL,
|
||||
entityId: TERMINAL,
|
||||
groupId: 'group-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal 1',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{ id: 'group-1', worktreeId: WORKTREE_ID, activeTabId: TERMINAL, tabOrder: [TERMINAL] }
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' },
|
||||
layoutByWorktree: { [WORKTREE_ID]: { type: 'leaf', groupId: 'group-1' } }
|
||||
})
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
}
|
||||
|
||||
/** What the window shows of the chat, and what main's layout holds of it. */
|
||||
async function shownChat(): Promise<{ drawn: boolean; inMain: boolean; opened: boolean }> {
|
||||
await main?.settle()
|
||||
return {
|
||||
drawn: (useAppStore.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).some(
|
||||
(tab) => tab.entityId === SESSION_ID
|
||||
),
|
||||
inMain: (layoutApplyTestTransport.mainModel?.workspaces[WORKTREE_ID]?.tabs ?? []).some(
|
||||
(tab) => tab.entityId === SESSION_ID
|
||||
),
|
||||
opened: layoutApplyTestTransport
|
||||
.sentOfType('openAgentSessionTab')
|
||||
.some((command) => command.sessionId === SESSION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
const NOT_SHOWN = { drawn: false, inMain: false, opened: false }
|
||||
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = null
|
||||
})
|
||||
|
||||
describe('structured launch cancellation retirement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -62,182 +239,238 @@ describe('structured launch cancellation retirement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a late publication suppressed until the cancelled launch settles', async () => {
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
const launchPromise = new Promise<{ sessionId: string; fence: number }>((resolve) => {
|
||||
resolveLaunch = resolve
|
||||
})
|
||||
setStructuredLaunchState({
|
||||
identity: `codex:${WORKTREE_ID}`,
|
||||
intent: {
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
sessionId: SESSION_ID,
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: 'operation-close-race',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'fingerprint-close-race'
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
},
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
},
|
||||
promise: launchPromise,
|
||||
visibilityUnknown: false,
|
||||
cancelled: false,
|
||||
selection: { held: {} }
|
||||
} satisfies StructuredLaunchState)
|
||||
|
||||
const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
|
||||
new Set(),
|
||||
beforeCancel,
|
||||
'local'
|
||||
)
|
||||
).toBe(false)
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
const afterCancel = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
|
||||
new Set(),
|
||||
afterCancel,
|
||||
'local'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
|
||||
resolveLaunch({ sessionId: SESSION_ID, fence: 1 })
|
||||
await Promise.resolve()
|
||||
const suppressed = suppressCancelledStructuredSessionTabs(latePublication(), { kind: 'local' })
|
||||
expect(suppressed.tabs).toEqual([])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
|
||||
new Set(),
|
||||
beforeCancel,
|
||||
'local'
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory()
|
||||
expect(
|
||||
retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
|
||||
new Set(),
|
||||
afterSettlement,
|
||||
'local'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
it('keeps a paired server’s late publication suppressed until the cancelled launch settles', async () => {
|
||||
await expectLatePublicationSuppressed(SERVER_HOST, SERVER)
|
||||
})
|
||||
|
||||
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'keeps a late publication suppressed until the cancelled launch settles',
|
||||
async () => {
|
||||
await expectLatePublicationSuppressed('local', { kind: 'local' })
|
||||
}
|
||||
)
|
||||
|
||||
// A runtime refused its chats answers with no chat rows; that absence is not the host's answer.
|
||||
it('keeps a tombstone through an inventory that cannot list chats', async () => {
|
||||
setStructuredLaunchState({
|
||||
identity: `codex:${WORKTREE_ID}`,
|
||||
intent: {
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
sessionId: SESSION_ID,
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: 'operation-unverifiable',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'fingerprint-unverifiable'
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
// Switch on, this machine keeps no tombstone: main's fence is in its close-before-create test.
|
||||
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'keeps a tombstone through an inventory that cannot list chats',
|
||||
async () => {
|
||||
setStructuredLaunchState({
|
||||
identity: `codex:${WORKTREE_ID}`,
|
||||
intent: {
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
sessionId: SESSION_ID,
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: 'operation-unverifiable',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'fingerprint-unverifiable'
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
},
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
},
|
||||
promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }),
|
||||
visibilityUnknown: false,
|
||||
cancelled: false,
|
||||
selection: { held: {} }
|
||||
} satisfies StructuredLaunchState)
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({
|
||||
...latePublication(),
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [],
|
||||
...(unverifiable ? { agentSessionsUnverifiable: true as const } : {})
|
||||
})
|
||||
const call = vi.fn()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } })
|
||||
|
||||
call.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { snapshots: [frame(true)], authoritative: true }
|
||||
})
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
call.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { snapshots: [frame(false)], authoritative: true }
|
||||
})
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'drains a restored cancellation before a newer inventory retires it',
|
||||
async () => {
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
|
||||
const close = Promise.withResolvers<void>()
|
||||
const call = vi.fn(({ method }: { method: string }) => {
|
||||
if (method === 'agentSession.close') {
|
||||
return close.promise.then(() => ({ ok: true, result: { ok: true } }))
|
||||
}
|
||||
},
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
},
|
||||
promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }),
|
||||
visibilityUnknown: false,
|
||||
cancelled: false,
|
||||
selection: { held: {} }
|
||||
} satisfies StructuredLaunchState)
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({
|
||||
...latePublication(),
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [],
|
||||
...(unverifiable ? { agentSessionsUnverifiable: true as const } : {})
|
||||
})
|
||||
const call = vi.fn()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } })
|
||||
return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } })
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { runtime: { call } }
|
||||
})
|
||||
|
||||
call.mockResolvedValue({ ok: true, result: { snapshots: [frame(true)], authoritative: true } })
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
|
||||
|
||||
call.mockResolvedValue({ ok: true, result: { snapshots: [frame(false)], authoritative: true } })
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
expect(call.mock.calls.map(([request]) => request.method)).toEqual([
|
||||
'agentSession.close',
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
it('drains a restored cancellation before a newer inventory retires it', async () => {
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
// The close shares the host's session lane with create, so settlement drains a late attach.
|
||||
close.resolve()
|
||||
await close.promise
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
|
||||
const close = Promise.withResolvers<void>()
|
||||
const call = vi.fn(({ method }: { method: string }) => {
|
||||
if (method === 'agentSession.close') {
|
||||
return close.promise.then(() => ({ ok: true, result: { ok: true } }))
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } })
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { runtime: { call } }
|
||||
})
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
call.mock.calls.filter(([request]) => request.method === 'agentSession.close')
|
||||
).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
|
||||
// Switch on, main holds this machine's cancel: neither inventory brings the chat back.
|
||||
it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'keeps a local cancel through an inventory that cannot list chats',
|
||||
async () => {
|
||||
await drawWorktreeFromMain()
|
||||
setLaunch('local', { kind: 'local' }, Promise.resolve({ sessionId: SESSION_ID, fence: 1 }))
|
||||
const call = vi.fn((_request: { method: string }): Promise<unknown> =>
|
||||
Promise.resolve({ ok: true, result: { ok: true } })
|
||||
)
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } })
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const closes = () =>
|
||||
call.mock.calls.filter(([request]) => request.method === 'agentSession.close')
|
||||
expect(closes()).toHaveLength(1)
|
||||
|
||||
expect(call.mock.calls.map(([request]) => request.method)).toEqual([
|
||||
'agentSession.close',
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true)
|
||||
const listing = (unverifiable: boolean) => ({
|
||||
ok: true,
|
||||
result: {
|
||||
snapshots: [
|
||||
unverifiable
|
||||
? { ...latePublication(), tabs: [], agentSessionsUnverifiable: true as const }
|
||||
: latePublication()
|
||||
],
|
||||
authoritative: true
|
||||
}
|
||||
})
|
||||
call.mockImplementation(() => Promise.resolve(listing(true)))
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(await shownChat()).toEqual(NOT_SHOWN)
|
||||
|
||||
// The close shares the host's session lane with create, so settlement drains a late attach.
|
||||
close.resolve()
|
||||
await close.promise
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
// A later inventory that lists the chat (its create landed late) does not show it either.
|
||||
call.mockImplementation(() => Promise.resolve(listing(false)))
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(await shownChat()).toEqual(NOT_SHOWN)
|
||||
expect(closes()).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
call.mock.calls.filter(([request]) => request.method === 'agentSession.close')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
// An older switch-off build saved the tombstone; this build drains it on its first inventory.
|
||||
it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'drains a tombstone an older build saved: one close, and main shows no chat',
|
||||
async () => {
|
||||
localStorage.setItem(
|
||||
'orca:structuredAgentLaunchCancelledSessions:v1',
|
||||
JSON.stringify([SESSION_ID])
|
||||
)
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
await drawWorktreeFromMain()
|
||||
|
||||
const close = Promise.withResolvers<void>()
|
||||
const call = vi.fn(({ method }: { method: string }) => {
|
||||
if (method === 'agentSession.close') {
|
||||
return close.promise.then(() => ({ ok: true, result: { ok: true } }))
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } })
|
||||
})
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } })
|
||||
|
||||
await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
|
||||
expect(call.mock.calls.map(([request]) => request.method)).toEqual([
|
||||
'agentSession.close',
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
close.resolve()
|
||||
await close.promise
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await refreshLocalStructuredSessionTabs()
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
call.mock.calls.filter(([request]) => request.method === 'agentSession.close')
|
||||
).toHaveLength(1)
|
||||
expect(await shownChat()).toEqual(NOT_SHOWN)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
'tells main of a local cancel at once, so the next inventory has nothing to drain',
|
||||
async () => {
|
||||
const call = vi.fn(({ method }: { method: string }) =>
|
||||
Promise.resolve(
|
||||
method === 'session.tabs.listAll'
|
||||
? { ok: true, result: { snapshots: [], authoritative: true } }
|
||||
: { ok: true, result: { ok: true } }
|
||||
)
|
||||
)
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } })
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
await refreshLocalStructuredSessionTabs(undefined, { authoritative: true })
|
||||
expect(call.mock.calls.map(([request]) => request.method)).toEqual([
|
||||
'agentSession.close',
|
||||
'session.tabs.close',
|
||||
'session.tabs.listAll'
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* driven for real below, and every absence is paired with a presence check through the same
|
||||
* oracle so an empty result can never pass for a proof.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { buildWorkspaceSessionPayload } from '../workspace-session'
|
||||
import { buildWorkspaceSessionPatch } from '../workspace-session-patch'
|
||||
@@ -17,17 +17,37 @@ import {
|
||||
getClientHostedBrowserRows,
|
||||
hydrateClientHostedBrowserRows
|
||||
} from './client-hosted-browser-row-state'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
// Main answers the window's layout commands; its frames draw the local browser tab.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const WT = 'wt-1'
|
||||
const CLIENT_HOSTED_PAGE_ID = 'client-hosted-page-1'
|
||||
|
||||
let initialState: AppState
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
function seedLocalBrowserTab(): string {
|
||||
/** A layout-owner build draws the tab once main's frame is in. */
|
||||
async function seedLocalBrowserTab(): Promise<string> {
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
const workspace = useAppStore.getState().createBrowserTab(WT, 'https://local.test/', {
|
||||
title: 'Local page',
|
||||
activate: true
|
||||
})
|
||||
await main.settle()
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
@@ -59,13 +79,15 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
useAppStore.setState(initialState, true)
|
||||
hydrateClientHostedBrowserRows([])
|
||||
})
|
||||
|
||||
describe('client-hosted rows and the persisted workspace session', () => {
|
||||
it('persists a local browser tab but never a client-hosted row', () => {
|
||||
const localWorkspaceId = seedLocalBrowserTab()
|
||||
it('persists a local browser tab but never a client-hosted row', async () => {
|
||||
const localWorkspaceId = await seedLocalBrowserTab()
|
||||
pushClientHostedRow()
|
||||
// Guard the oracle itself: the row has to be live for its absence downstream to mean anything.
|
||||
expect(getClientHostedBrowserRows(WT)).toHaveLength(1)
|
||||
@@ -77,8 +99,8 @@ describe('client-hosted rows and the persisted workspace session', () => {
|
||||
expect(serialize(payload)).not.toContain('https://client-hosted.test/')
|
||||
})
|
||||
|
||||
it('keeps a client-hosted row out of incremental session patches too', () => {
|
||||
const localWorkspaceId = seedLocalBrowserTab()
|
||||
it('keeps a client-hosted row out of incremental session patches too', async () => {
|
||||
const localWorkspaceId = await seedLocalBrowserTab()
|
||||
pushClientHostedRow()
|
||||
|
||||
const patch = buildWorkspaceSessionPatch(useAppStore.getState(), [
|
||||
@@ -93,8 +115,8 @@ describe('client-hosted rows and the persisted workspace session', () => {
|
||||
expect(serialize(patch)).not.toContain(CLIENT_HOSTED_PAGE_ID)
|
||||
})
|
||||
|
||||
it('leaves the store tab model untouched when a row arrives', () => {
|
||||
seedLocalBrowserTab()
|
||||
it('leaves the store tab model untouched when a row arrives', async () => {
|
||||
await seedLocalBrowserTab()
|
||||
const before = {
|
||||
browserTabs: useAppStore.getState().browserTabsByWorktree[WT],
|
||||
unifiedTabs: useAppStore.getState().unifiedTabsByWorktree[WT],
|
||||
@@ -110,8 +132,8 @@ describe('client-hosted rows and the persisted workspace session', () => {
|
||||
})
|
||||
|
||||
describe('client-hosted rows and the runtime window graph', () => {
|
||||
it('publishes a local browser tab to the host graph but never a client-hosted row', () => {
|
||||
const localWorkspaceId = seedLocalBrowserTab()
|
||||
it('publishes a local browser tab to the host graph but never a client-hosted row', async () => {
|
||||
const localWorkspaceId = await seedLocalBrowserTab()
|
||||
pushClientHostedRow()
|
||||
expect(getClientHostedBrowserRows(WT)).toHaveLength(1)
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// With ORCA_LAYOUT_OWNER=main, a simulator 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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands first and
|
||||
// the window draws them from main's frames, so its next save changes nothing in main's model.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { Tab, TabGroup, TabGroupLayoutNode } 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 { WorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import {
|
||||
createTestStore,
|
||||
makeRuntimeOwnedWorktree,
|
||||
@@ -20,22 +18,21 @@ import {
|
||||
seedStore
|
||||
} from '@/store/slices/store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
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: []
|
||||
}))
|
||||
const mocks = vi.hoisted((): Mocks => ({ layoutOwnerIsMain: true, store: null }))
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({
|
||||
get ORCA_LAYOUT_OWNER_IS_MAIN() {
|
||||
@@ -50,30 +47,16 @@ vi.mock('@/store', () => ({
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/runtime/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 }
|
||||
}
|
||||
)
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
@@ -90,11 +73,8 @@ function store(): ReturnType<typeof createTestStore> {
|
||||
return mocks.store
|
||||
}
|
||||
|
||||
function mainLoad(): LayoutLoad {
|
||||
if (!mocks.main) {
|
||||
throw new Error('main not loaded')
|
||||
}
|
||||
return mocks.main
|
||||
function sent(): readonly LayoutCommand[] {
|
||||
return layoutApplyTestTransport.sent
|
||||
}
|
||||
|
||||
function loadWindowSave(): LayoutLoad {
|
||||
@@ -107,18 +87,17 @@ function loadWindowSave(): LayoutLoad {
|
||||
|
||||
/** The workspace as the window's save would give it to main, beside main's stored copy. */
|
||||
function expectSaveTeachesMainNothing(worktreeId = WORKTREE): void {
|
||||
const stored = saveWorkspaceLayout(mainLoad())
|
||||
const main = loadWorkspaceLayoutWithoutReport(
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
stored,
|
||||
nameBasedLoadContext()
|
||||
).layout
|
||||
expect(loadWindowSave().layout.workspaces[worktreeId]).toEqual(main.workspaces[worktreeId])
|
||||
// A lone group's tree is implied: the window may store its leaf, main none.
|
||||
const withoutLoneLeaf = (workspace: WorkspaceLayout | undefined) =>
|
||||
workspace && { ...workspace, groupLayout: split(workspace.groupLayout) }
|
||||
const split = (tree: TabGroupLayoutNode | undefined) => (tree?.type === 'split' ? tree : null)
|
||||
expect(withoutLoneLeaf(loadWindowSave().layout.workspaces[worktreeId])).toEqual(
|
||||
withoutLoneLeaf(layoutApplyTestTransport.mainModel!.workspaces[worktreeId])
|
||||
)
|
||||
}
|
||||
|
||||
async function settled(): Promise<void> {
|
||||
await vi.waitFor(() => Promise.resolve())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await main.settle()
|
||||
}
|
||||
|
||||
function terminalTab(id: string, groupId: string, worktreeId = WORKTREE): Tab {
|
||||
@@ -160,23 +139,31 @@ function seed(groups: TabGroup[], layout: TabGroupLayoutNode): void {
|
||||
activeGroupIdByWorktree: { [WORKTREE]: groups[0]!.id },
|
||||
layoutByWorktree: { [WORKTREE]: layout }
|
||||
})
|
||||
// Main starts from the window's last save, as it does today.
|
||||
mocks.main = loadWindowSave()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
/** Main takes the window's layout and draws it from here on. */
|
||||
async function seedDrawnFromMain(groups: TabGroup[], layout: TabGroupLayoutNode): Promise<void> {
|
||||
main?.close()
|
||||
seed(groups, layout)
|
||||
main = await drawTabsFromMain(store())
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.layoutOwnerIsMain = true
|
||||
mocks.sent = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.store = createTestStore()
|
||||
seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], {
|
||||
type: 'leaf',
|
||||
groupId: GROUP
|
||||
})
|
||||
await seedDrawnFromMain(
|
||||
[{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }],
|
||||
{ type: 'leaf', groupId: GROUP }
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main.close()
|
||||
})
|
||||
|
||||
function sentTypes(): string[] {
|
||||
return mocks.sent.map((command) => command.type)
|
||||
return sent().map((command) => command.type)
|
||||
}
|
||||
|
||||
describe('simulator tabs in a layout-owner build', () => {
|
||||
@@ -184,7 +171,7 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
expectSaveTeachesMainNothing()
|
||||
})
|
||||
|
||||
it('records a simulator tab opened in the active group, and its close', async () => {
|
||||
it('opens a simulator tab in the active group through main, and closes it there', async () => {
|
||||
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' })
|
||||
expect(tabId).toBeTruthy()
|
||||
await settled()
|
||||
@@ -196,12 +183,12 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
expect(sentTypes()).toEqual(['openSimulatorTab', 'closeTabs'])
|
||||
})
|
||||
|
||||
it('records the split group a right-side open makes, and its collapse on close', async () => {
|
||||
it('makes the right split with the tab in main, and collapses it on close', async () => {
|
||||
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' })
|
||||
await settled()
|
||||
expect(store().getState().groupsByWorktree[WORKTREE]).toHaveLength(2)
|
||||
expectSaveTeachesMainNothing()
|
||||
expect(mainLoad().layout.workspaces[WORKTREE]?.groupLayout).toMatchObject({
|
||||
expect(layoutApplyTestTransport.mainModel!.workspaces[WORKTREE]?.groupLayout).toMatchObject({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: GROUP }
|
||||
@@ -213,9 +200,9 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
expectSaveTeachesMainNothing()
|
||||
})
|
||||
|
||||
it('records a right-side open into the right group the workspace already has', async () => {
|
||||
it('opens a right-side tab into the right group the workspace already has', async () => {
|
||||
const right = 'group-2'
|
||||
seed(
|
||||
await seedDrawnFromMain(
|
||||
[
|
||||
{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] },
|
||||
{ id: right, worktreeId: WORKTREE, activeTabId: 'terminal-2', tabOrder: ['terminal-2'] }
|
||||
@@ -233,26 +220,33 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
expectSaveTeachesMainNothing()
|
||||
})
|
||||
|
||||
it('records an open into the window’s emptied root group', async () => {
|
||||
seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], {
|
||||
type: 'leaf',
|
||||
groupId: GROUP
|
||||
})
|
||||
it('opens into the root group of a workspace whose tabs all closed', async () => {
|
||||
await seedDrawnFromMain(
|
||||
[{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }],
|
||||
{
|
||||
type: 'leaf',
|
||||
groupId: GROUP
|
||||
}
|
||||
)
|
||||
// Main keeps no empty group, so its frame drops GROUP; the open makes the root group again.
|
||||
expect(store().getState().groupsByWorktree[WORKTREE] ?? []).toEqual([])
|
||||
const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' })
|
||||
await settled()
|
||||
expect(mocks.sent).toMatchObject([
|
||||
{ type: 'openSimulatorTab', tabId, groupId: GROUP, index: 0 }
|
||||
])
|
||||
const groups = store().getState().groupsByWorktree[WORKTREE] ?? []
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(store().getState().getTab(tabId!)?.groupId).toBe(groups[0]!.id)
|
||||
expect(groups[0]!.tabOrder).toEqual([tabId])
|
||||
expect(sent()).toMatchObject([{ type: 'openSimulatorTab', tabId, groupId: groups[0]!.id }])
|
||||
expectSaveTeachesMainNothing()
|
||||
})
|
||||
|
||||
it('sends nothing when the workspace already has its simulator tab', async () => {
|
||||
const tabId = ensureSimulatorTab(WORKTREE)
|
||||
await settled()
|
||||
mocks.sent = []
|
||||
const before = sent().length
|
||||
expect(ensureSimulatorTab(WORKTREE)).toBe(tabId)
|
||||
await settled()
|
||||
expect(mocks.sent).toEqual([])
|
||||
expect(sent()).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('leaves a paired server’s simulator tab to that server', async () => {
|
||||
@@ -285,7 +279,7 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
expect(tabId).toBeTruthy()
|
||||
store().getState().closeUnifiedTab(tabId!)
|
||||
await settled()
|
||||
expect(mocks.sent).toEqual([])
|
||||
expect(sent()).toEqual([])
|
||||
})
|
||||
|
||||
it('sends nothing with the switch off', async () => {
|
||||
@@ -295,6 +289,6 @@ describe('simulator tabs in a layout-owner build', () => {
|
||||
const split = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' })
|
||||
store().getState().closeUnifiedTab(split!)
|
||||
await settled()
|
||||
expect(mocks.sent).toEqual([])
|
||||
expect(sent()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
// Simulator tabs in a layout-owner build. The window still draws the tab first (the emulator pane
|
||||
// attaches to it by id); main records each open and close under the window's ids, so a window save
|
||||
// no longer teaches main a simulator tab. Nothing to undo on any answer.
|
||||
// Simulator tab opens in a layout-owner build: the window sends the open under a proposed tab id
|
||||
// (the emulator pane attaches by it); main's frame draws the tab, and only then is it surfaced.
|
||||
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { createBrowserUuid } from './browser-uuid'
|
||||
import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open'
|
||||
import { afterPendingTab, isPendingTab } from '@/runtime/layout-owner-pending-tabs'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement'
|
||||
|
||||
type SimulatorTabState = Parameters<typeof recordLayoutApply>[0] &
|
||||
Pick<AppState, 'groupsByWorktree' | 'layoutByWorktree'>
|
||||
export type SimulatorTabOpen = {
|
||||
worktreeId: string
|
||||
label: string
|
||||
/** The group the tab joins, or the group a new right split goes beside. */
|
||||
groupId: string
|
||||
rightSplit: boolean
|
||||
/** Runs once main's frame drew the tab. */
|
||||
surface: (tabId: string) => void
|
||||
/** Today's draw under the proposed id, for a twin main may route to a paired server. */
|
||||
drawHere: (tabId: string) => string | null
|
||||
}
|
||||
|
||||
/** A simulator tab the window just drew, placed where the window drew it. */
|
||||
export function recordOpenedSimulatorTabInLayoutOwner(state: SimulatorTabState, tab: Tab): void {
|
||||
if (tab.contentType !== 'simulator') {
|
||||
return
|
||||
}
|
||||
recordLayoutApply(state, tab.worktreeId, {
|
||||
type: 'openSimulatorTab',
|
||||
tabId: tab.id,
|
||||
// Opened to the right: main makes the window's new split with the tab.
|
||||
...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id),
|
||||
createdAt: tab.createdAt
|
||||
/** A worktree's simulator tab main is still opening: one emulator tab per worktree. */
|
||||
const opening = new Map<string, string>()
|
||||
|
||||
export function openingSimulatorTabId(worktreeId: string): string | undefined {
|
||||
const tabId = opening.get(worktreeId)
|
||||
return tabId && isPendingTab(tabId) ? tabId : undefined
|
||||
}
|
||||
|
||||
/** The proposed tab id, or null when main is not asked and the caller draws as today. */
|
||||
export function openSimulatorTabThroughLayoutOwner(
|
||||
state: AppState,
|
||||
open: SimulatorTabOpen
|
||||
): string | null {
|
||||
const tabId = createBrowserUuid()
|
||||
const placement = open.rightSplit
|
||||
? {
|
||||
groupId: createBrowserUuid(),
|
||||
split: { besideGroupId: open.groupId, direction: 'right' as const }
|
||||
}
|
||||
: { groupId: open.groupId }
|
||||
const sent = openContentTabThroughLayoutOwner(state, {
|
||||
worktreeId: open.worktreeId,
|
||||
entityId: tabId,
|
||||
command: { type: 'openSimulatorTab', tabId, ...placement },
|
||||
facts: { label: open.label },
|
||||
shown: open.surface,
|
||||
drawHere: () => open.drawHere(tabId)
|
||||
})
|
||||
}
|
||||
|
||||
/** A simulator tab the window just closed. */
|
||||
export function recordClosedSimulatorTabInLayoutOwner(
|
||||
state: SimulatorTabState,
|
||||
tab: Pick<Tab, 'id' | 'worktreeId' | 'contentType'>
|
||||
): void {
|
||||
if (tab.contentType === 'simulator') {
|
||||
recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] })
|
||||
if (!sent) {
|
||||
return null
|
||||
}
|
||||
opening.set(open.worktreeId, tabId)
|
||||
afterPendingTab(tabId, () => {
|
||||
if (opening.get(open.worktreeId) === tabId) {
|
||||
opening.delete(open.worktreeId)
|
||||
}
|
||||
})
|
||||
return tabId
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau
|
||||
import type { ProvisionalLaunchArgs } from './structured-agent-session-provisional-tab'
|
||||
import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import {
|
||||
notifyHostDeclined,
|
||||
openDeclinedStructuredLaunchTerminal,
|
||||
@@ -54,7 +57,7 @@ export async function openTerminalForDeclinedChat(
|
||||
args: ProvisionalLaunchArgs,
|
||||
worktreeId: string,
|
||||
target: RuntimeClientTarget,
|
||||
tab: Tab,
|
||||
tab: OpenedAgentSessionTab,
|
||||
settlement: StructuredAgentLaunchSettlement
|
||||
): Promise<StructuredAgentLaunchSettlement> {
|
||||
if (
|
||||
@@ -125,15 +128,12 @@ export async function openTerminalForDeclinedChat(
|
||||
notifyHostDeclined(args.plan.agent)
|
||||
return settlement
|
||||
}
|
||||
state.moveUnifiedTabToGroup(replacement.id, current.groupId, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
if (Boolean(current.isPinned) !== Boolean(replacement.isPinned)) {
|
||||
const setPinned = current.isPinned ? state.pinTab : state.unpinTab
|
||||
setPinned(replacement.id, { recordInteraction: false })
|
||||
// Switch point: main places and pins the tab in one step; a pin sent alone would re-sort it.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await placeReplacementThroughMain(worktreeId, replacement, current)
|
||||
} else {
|
||||
placeReplacementAfterSource(worktreeId, replacement, current)
|
||||
}
|
||||
insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id)
|
||||
const placed = useAppStore.getState()
|
||||
const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId)
|
||||
if (
|
||||
@@ -157,13 +157,69 @@ export async function openTerminalForDeclinedChat(
|
||||
if (current.color !== null) {
|
||||
placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false })
|
||||
}
|
||||
placed.closeUnifiedTab(tab.id, { recordInteraction: false })
|
||||
if (foreground) {
|
||||
const showReplacement = (): void => {
|
||||
useAppStore.getState().setActiveTab(replacement.entityId)
|
||||
useAppStore.getState().activateTab(replacement.id)
|
||||
useAppStore.getState().setActiveTabType('terminal', worktreeId)
|
||||
focusTerminalTabSurface(replacement.id)
|
||||
}
|
||||
// Switch-on, main's close lands later and shows the group's active tab: make it the terminal.
|
||||
const showsFirst = foreground && ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
if (showsFirst) {
|
||||
showReplacement()
|
||||
}
|
||||
placed.closeUnifiedTab(tab.id, { recordInteraction: false })
|
||||
if (foreground && !showsFirst) {
|
||||
showReplacement()
|
||||
}
|
||||
notifyHostDeclined(args.plan.agent)
|
||||
return { kind: 'terminal' }
|
||||
}
|
||||
|
||||
/** The terminal goes right after the declined chat, pinned as the chat is. */
|
||||
function placeReplacementAfterSource(worktreeId: string, replacement: Tab, source: Tab): void {
|
||||
const state = useAppStore.getState()
|
||||
state.moveUnifiedTabToGroup(replacement.id, source.groupId, {
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
if (Boolean(source.isPinned) !== Boolean(replacement.isPinned)) {
|
||||
const setPinned = source.isPinned ? state.pinTab : state.unpinTab
|
||||
setPinned(replacement.id, { recordInteraction: false })
|
||||
}
|
||||
insertUnifiedTabAfterAnchor(worktreeId, replacement.id, source.id)
|
||||
}
|
||||
|
||||
/** One main move puts the terminal after the chat with the chat's pin; resolves once drawn. */
|
||||
function placeReplacementThroughMain(
|
||||
worktreeId: string,
|
||||
replacement: Tab,
|
||||
source: Tab
|
||||
): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
const rows = new Set((state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id))
|
||||
// Main's index counts only tabs it draws: a hidden launch slot has no row and is not in main.
|
||||
const order = (state.groupsByWorktree[worktreeId] ?? [])
|
||||
.find((group) => group.id === source.groupId)
|
||||
?.tabOrder.filter((id) => id !== replacement.id && rows.has(id))
|
||||
return new Promise((resolve) => {
|
||||
const placeHere = (): void => {
|
||||
placeReplacementAfterSource(worktreeId, replacement, source)
|
||||
resolve()
|
||||
}
|
||||
const sent = sendLayoutCommandToOwner(
|
||||
worktreeId,
|
||||
{
|
||||
type: 'moveTab',
|
||||
tabId: replacement.id,
|
||||
toGroupId: source.groupId,
|
||||
index: (order?.indexOf(source.id) ?? -1) + 1,
|
||||
isPinned: Boolean(source.isPinned)
|
||||
},
|
||||
{ apply: () => resolve(), asToday: placeHere, refused: () => resolve() }
|
||||
)
|
||||
if (!sent) {
|
||||
placeHere()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createSupport: vi.fn(),
|
||||
@@ -27,6 +28,17 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
vi.mock('@/runtime/structured-chat-host-permission', () => ({
|
||||
readStructuredChatHostPermissionVerdict: mocks.hostPermission
|
||||
}))
|
||||
// Switch-on, main opens this machine's chats and its frames draw them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
|
||||
@@ -37,6 +49,10 @@ import { getStructuredAgentSessionLaunchSelection } from './structured-agent-ses
|
||||
import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
|
||||
import { LOCAL_ADMISSION_WAIT_MS } from './structured-agent-session-host-admission'
|
||||
import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const WORKTREE = 'repo-1::/srv/app'
|
||||
const INITIAL_SETTINGS = useAppStore.getState().settings
|
||||
@@ -58,6 +74,32 @@ function pairedPlan(
|
||||
|
||||
const localPlan = () => pairedPlan({ executionHostId: 'local' })
|
||||
|
||||
/** The workspace's row names the paired server as its owner, whose layout draws its tabs. */
|
||||
function seedPairedServerWorkspace(): void {
|
||||
const worktree: Worktree = {
|
||||
id: WORKTREE,
|
||||
repoId: 'repo-1',
|
||||
path: '/srv/app',
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
displayName: 'app',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
hostId: 'runtime:server-1',
|
||||
runtimeOwnerEnvironmentId: 'server-1'
|
||||
}
|
||||
useAppStore.setState({ worktreesByRepo: { 'repo-1': [worktree] } })
|
||||
}
|
||||
|
||||
/** Nothing of a chat exists on this machine: no tab, launch, record, queued prompt or intent. */
|
||||
function expectNoChatCommitted(): void {
|
||||
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
|
||||
@@ -69,7 +111,9 @@ function expectNoChatCommitted(): void {
|
||||
).toBeNull()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.hostPermission.mockResolvedValue({ kind: 'allowed' })
|
||||
localStorage.clear()
|
||||
@@ -77,11 +121,17 @@ beforeEach(() => {
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
unifiedTabsByWorktree: {},
|
||||
worktreesByRepo: {},
|
||||
settings: INITIAL_SETTINGS
|
||||
})
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
})
|
||||
|
||||
afterEach(() => main.close())
|
||||
|
||||
describe('a structured chat launch on a paired server', () => {
|
||||
beforeEach(seedPairedServerWorkspace)
|
||||
|
||||
it('opens only the terminal when the server declines, keeping the workspace selected', async () => {
|
||||
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
|
||||
const onHostDeclined = vi.fn(() => ({
|
||||
@@ -421,6 +471,8 @@ describe('a structured chat launch on this machine', () => {
|
||||
})
|
||||
|
||||
describe('a paired server that cannot resolve a new workspace yet', () => {
|
||||
beforeEach(seedPairedServerWorkspace)
|
||||
|
||||
it('keeps its terminal fallback, as before', async () => {
|
||||
mocks.createSupport.mockRejectedValue(new Error('selector_not_found'))
|
||||
const onHostDeclined = vi.fn(() => ({ opened: true }))
|
||||
@@ -437,6 +489,7 @@ describe('a paired server that cannot resolve a new workspace yet', () => {
|
||||
})
|
||||
|
||||
it('keeps explicit refusal owned by admission and keeps an opened chat failure out of the menu', async () => {
|
||||
seedPairedServerWorkspace()
|
||||
for (const supported of [false, true]) {
|
||||
mocks.toastError.mockClear()
|
||||
mocks.createSupport.mockResolvedValue({ supported, reason: 'wsl' })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abandonIntent: vi.fn(),
|
||||
@@ -180,13 +181,31 @@ describe('a close that races a structured launch', () => {
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1' })
|
||||
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
|
||||
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)).toBe(
|
||||
true
|
||||
)
|
||||
const persistedTombstones =
|
||||
localStorage.getItem('orca:structuredAgentLaunchCancelledSessions:v1') ?? ''
|
||||
expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId))
|
||||
expect(persistedTombstones).not.toContain(worktreeId)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// This machine's main hears the cancel at once and fences the chat; the window keeps none.
|
||||
expect(
|
||||
hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)
|
||||
).toBe(false)
|
||||
expect(persistedTombstones).not.toContain(JSON.stringify(intent.sessionId))
|
||||
expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.close',
|
||||
{ sessionId: intent.sessionId }
|
||||
)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'session.tabs.close',
|
||||
expect.objectContaining({ tabId: `agent-session:${intent.sessionId}` })
|
||||
)
|
||||
} else {
|
||||
expect(
|
||||
hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)
|
||||
).toBe(true)
|
||||
expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId))
|
||||
expect(persistedTombstones).not.toContain(worktreeId)
|
||||
}
|
||||
resolveRefresh([])
|
||||
await flushLaunchSettlement()
|
||||
|
||||
@@ -221,8 +240,11 @@ describe('a close that races a structured launch', () => {
|
||||
resolveRefresh([])
|
||||
await flushLaunchSettlement()
|
||||
|
||||
// Neither sent nor given back to the closed chat's composer.
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
// Neither sent nor given back to the closed chat's composer; a layout-owner build's only call
|
||||
// retires the chat on this machine's main.
|
||||
expect(mocks.callRuntimeRpc.mock.calls.map(([, method]) => method)).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? ['session.tabs.close'] : []
|
||||
)
|
||||
expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(intent.sessionId))).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
retireStructuredAgentLaunchCancellation
|
||||
} from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { cancelStructuredAgentLaunchOnLocalHost } from './structured-agent-session-local-launch-cancel'
|
||||
import {
|
||||
cancelStructuredAgentLaunchOnLocalHost,
|
||||
resetStructuredAgentLocalLaunchCancelsForTests
|
||||
} from './structured-agent-session-local-launch-cancel'
|
||||
|
||||
export type StructuredLaunchState = StructuredLaunchRecoveryState & {
|
||||
identity: string
|
||||
@@ -56,6 +59,7 @@ export function resetStructuredAgentLaunchRegistryForTests(): void {
|
||||
structuredLaunchesBySessionId.clear()
|
||||
structuredLaunchListeners.clear()
|
||||
resetStructuredAgentLaunchCancellationForTests()
|
||||
resetStructuredAgentLocalLaunchCancelsForTests()
|
||||
}
|
||||
|
||||
export function notifyStructuredLaunchListeners(): void {
|
||||
@@ -237,12 +241,7 @@ function markStructuredAgentSessionLaunchCancelledInternal(
|
||||
if (matchesLaunchWorktree(state, worktreeId) && state) {
|
||||
// The launch's own owner outranks the caller's: it is the host the create was sent to.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && state.intent.executionHostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
cancelStructuredAgentLaunchOnLocalHost(
|
||||
worktreeId,
|
||||
sessionId,
|
||||
notifyStructuredLaunchListeners,
|
||||
state.promise
|
||||
)
|
||||
cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId)
|
||||
} else {
|
||||
markStructuredAgentLaunchCancellation(
|
||||
sessionId,
|
||||
@@ -261,7 +260,7 @@ function markStructuredAgentSessionLaunchCancelledInternal(
|
||||
!alreadyCancelled &&
|
||||
executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
) {
|
||||
cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId, notifyStructuredLaunchListeners)
|
||||
cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId)
|
||||
} else if (!alreadyCancelled) {
|
||||
markStructuredAgentLaunchCancellation(sessionId, executionHostId, alreadyCancelled)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
} from './structured-agent-session-launch-registry'
|
||||
import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner'
|
||||
import {
|
||||
openAgentSessionTabThroughLayoutOwner,
|
||||
type OpenedAgentSessionTab
|
||||
} from './structured-agent-session-tab-open-through-layout-owner'
|
||||
import {
|
||||
clearWebSessionFocusIntentIfMatches,
|
||||
peekWebSessionFocusIntent,
|
||||
@@ -147,7 +150,7 @@ export function openStructuredAgentSessionProvisionalTab(args: {
|
||||
/** The tab the chat opens right after, in that tab's group. */
|
||||
afterTabId?: string
|
||||
activate?: boolean
|
||||
}): Tab {
|
||||
}): OpenedAgentSessionTab {
|
||||
const state = useAppStore.getState()
|
||||
const existing = findStructuredLaunchTab(args)
|
||||
if (existing) {
|
||||
@@ -165,26 +168,55 @@ export function openStructuredAgentSessionProvisionalTab(args: {
|
||||
const group = state.groupsByWorktree?.[args.worktreeId]?.find((entry) =>
|
||||
entry.tabOrder.includes(tabId)
|
||||
)
|
||||
const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', {
|
||||
id: tabId,
|
||||
entityId: args.sessionId,
|
||||
executionHostId: args.executionHostId,
|
||||
agentSessionAgent: args.agent,
|
||||
label: defaultAgentChatLabel(args.agent),
|
||||
targetGroupId:
|
||||
group?.id ??
|
||||
(address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId),
|
||||
...(args.afterTabId ? { afterTabId: args.afterTabId } : {}),
|
||||
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)
|
||||
const targetGroupId =
|
||||
group?.id ??
|
||||
(address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId)
|
||||
const drawHere = (): Tab => {
|
||||
const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', {
|
||||
id: tabId,
|
||||
entityId: args.sessionId,
|
||||
executionHostId: args.executionHostId,
|
||||
agentSessionAgent: args.agent,
|
||||
label: defaultAgentChatLabel(args.agent),
|
||||
targetGroupId,
|
||||
...(args.afterTabId ? { afterTabId: args.afterTabId } : {}),
|
||||
activate: args.activate !== false
|
||||
})
|
||||
if (args.activate !== false) {
|
||||
state.setActiveTabType('agent-session', args.worktreeId)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
if (args.activate !== false) {
|
||||
state.setActiveTabType('agent-session', args.worktreeId)
|
||||
// Switch point: main opens the chat's tab and its frame draws it.
|
||||
const sent = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? openAgentSessionTabThroughLayoutOwner({
|
||||
worktreeId: args.worktreeId,
|
||||
executionHostId: args.executionHostId,
|
||||
sessionId: args.sessionId,
|
||||
agent: args.agent,
|
||||
...(targetGroupId ? { targetGroupId } : {}),
|
||||
// A reserved slot names where the chat goes: after the tab before it.
|
||||
...(args.afterTabId
|
||||
? { afterTabId: args.afterTabId }
|
||||
: slotAnchor(address, args.worktreeId)),
|
||||
activate: args.activate !== false,
|
||||
drawHere
|
||||
})
|
||||
: null
|
||||
return sent ?? drawHere()
|
||||
}
|
||||
|
||||
/** The last tab still before a reserved chat slot, which main places the chat after. */
|
||||
function slotAnchor(
|
||||
address: StructuredLaunchSurfaceAddress | undefined,
|
||||
worktreeId: string
|
||||
): { afterTabId?: string } {
|
||||
if (address?.worktreeId !== worktreeId) {
|
||||
return {}
|
||||
}
|
||||
return tab
|
||||
const tabs = useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []
|
||||
const afterTabId = address.precedingTabIds.findLast((id) => tabs.some((tab) => tab.id === id))
|
||||
return afterTabId ? { afterTabId } : {}
|
||||
}
|
||||
|
||||
export function materializeStructuredLaunchSurface(launch: LaunchSurface, activate?: false): void {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
|
||||
import { retireStructuredAgentSessionTab } from '@/runtime/structured-agent-session-tab-retirement'
|
||||
import {
|
||||
deleteStructuredAgentLaunchRecord,
|
||||
hasStructuredAgentLaunchCancellationTombstonePersisted
|
||||
} from './structured-agent-session-launch-persistence'
|
||||
import { markStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation'
|
||||
import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence'
|
||||
|
||||
// Chats a cancel closed on this machine's host; session ids are never reused.
|
||||
const closedByLocalCancel = new Set<string>()
|
||||
@@ -15,16 +10,18 @@ export function wasStructuredAgentLaunchClosedByLocalCancel(sessionId: string):
|
||||
return closedByLocalCancel.has(sessionId)
|
||||
}
|
||||
|
||||
export function resetStructuredAgentLocalLaunchCancelsForTests(): void {
|
||||
closedByLocalCancel.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* ORCA_LAYOUT_OWNER=main only: this machine's host refuses a create whose close came first, or
|
||||
* closes the chat as it lands, so a local cancel retires the chat on the host at once instead of
|
||||
* keeping a tombstone until the host lists it. A host that cannot be told gets the tombstone.
|
||||
* closes the chat as it lands, so a local cancel retires the chat on the host at once and keeps no
|
||||
* tombstone. A failed retire is logged; main still remembers the close.
|
||||
*/
|
||||
export function cancelStructuredAgentLaunchOnLocalHost(
|
||||
worktreeId: string,
|
||||
sessionId: string,
|
||||
onTombstoned: () => void,
|
||||
launchPromise?: Promise<unknown>
|
||||
sessionId: string
|
||||
): void {
|
||||
deleteStructuredAgentLaunchRecord(sessionId)
|
||||
// Why: main answers a create for a chat closed meanwhile as ok, and no tombstone is written.
|
||||
@@ -35,16 +32,9 @@ export function cancelStructuredAgentLaunchOnLocalHost(
|
||||
sessionId,
|
||||
onError: (error) => {
|
||||
// A create that has not landed has no host tab yet; main still heard the close.
|
||||
if (hasRuntimeRpcErrorCode(error, 'tab_not_found')) {
|
||||
return
|
||||
if (!hasRuntimeRpcErrorCode(error, 'tab_not_found')) {
|
||||
console.warn('[structured-agent-session] local cancel retirement failed', error)
|
||||
}
|
||||
markStructuredAgentLaunchCancellation(
|
||||
sessionId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId),
|
||||
launchPromise
|
||||
)
|
||||
onTombstoned()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission'
|
||||
import type * as RpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntent: vi.fn(),
|
||||
@@ -49,6 +51,12 @@ function emptyStoreState(): StoreState {
|
||||
}
|
||||
}
|
||||
const store = vi.hoisted((): { state: StoreState } => ({ state: emptyStoreState() }))
|
||||
type DrawnStore = {
|
||||
getState: () => AppState
|
||||
subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void
|
||||
}
|
||||
/** Switch on: a real store drawn from main's frames stands in for `store`. */
|
||||
const drawn = vi.hoisted((): { store: DrawnStore | null } => ({ store: null }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), message: vi.fn() } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
@@ -81,10 +89,21 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: (target: unknown, method: string, params?: unknown) =>
|
||||
mocks.callRuntimeRpc(target, method, params)
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: mocks.callRuntimeRpc,
|
||||
ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined)
|
||||
}))
|
||||
// Switch-on, main answers the window's chat opens and its frames draw them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply({
|
||||
...(await importOriginal<typeof RpcClientModule>()),
|
||||
callRuntimeRpc: mocks.callRuntimeRpc,
|
||||
ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined)
|
||||
})
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/structured-agent-session-status-feed', () => ({
|
||||
getStructuredAgentSessionStatusFeed: () => ({
|
||||
getSessionObservation: (sessionId: string) =>
|
||||
@@ -97,33 +116,45 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
...store.state,
|
||||
createUnifiedTab: (
|
||||
worktreeId: string,
|
||||
contentType: Tab['contentType'],
|
||||
init: { targetGroupId?: string }
|
||||
) => {
|
||||
const groupId =
|
||||
init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left'
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries.
|
||||
const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab
|
||||
store.state.unifiedTabsByWorktree[worktreeId] = [
|
||||
...(store.state.unifiedTabsByWorktree[worktreeId] ?? []),
|
||||
tab
|
||||
]
|
||||
return tab
|
||||
},
|
||||
activateTab: mocks.activateTab,
|
||||
focusGroup: mocks.focusGroup,
|
||||
setActiveTabType: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn()
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
getState: () =>
|
||||
drawn.store
|
||||
? {
|
||||
...drawn.store.getState(),
|
||||
activateTab: mocks.activateTab,
|
||||
focusGroup: mocks.focusGroup
|
||||
}
|
||||
: standInState(),
|
||||
subscribe: (listener: (state: AppState, previous: AppState) => void) =>
|
||||
drawn.store?.subscribe(listener) ?? (() => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
function standInState() {
|
||||
return {
|
||||
...store.state,
|
||||
createUnifiedTab: (
|
||||
worktreeId: string,
|
||||
contentType: Tab['contentType'],
|
||||
init: { targetGroupId?: string }
|
||||
) => {
|
||||
const groupId =
|
||||
init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left'
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries.
|
||||
const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab
|
||||
store.state.unifiedTabsByWorktree[worktreeId] = [
|
||||
...(store.state.unifiedTabsByWorktree[worktreeId] ?? []),
|
||||
tab
|
||||
]
|
||||
return tab
|
||||
},
|
||||
activateTab: mocks.activateTab,
|
||||
focusGroup: mocks.focusGroup,
|
||||
setActiveTabType: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender'
|
||||
import { clearNativeChatDraftCacheForTests } from '@/components/native-chat/native-chat-draft-cache'
|
||||
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
|
||||
@@ -138,6 +169,12 @@ import {
|
||||
} from './structured-agent-session-launch-prompt'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const WORKTREE_ID = 'wt-new-chat'
|
||||
|
||||
@@ -183,6 +220,64 @@ function published(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] {
|
||||
}))
|
||||
}
|
||||
|
||||
let main: TabsDrawnFromMain | null = null
|
||||
|
||||
/**
|
||||
* Switch on: the split workspace in a real store, drawn from main's frames. Main keeps no empty
|
||||
* group, so each side holds a terminal.
|
||||
*/
|
||||
async function drawSplitFromMain(): Promise<void> {
|
||||
const real = createTestStore()
|
||||
const side = (groupId: string): Tab => ({
|
||||
id: `terminal-${groupId}`,
|
||||
entityId: `terminal-${groupId}`,
|
||||
groupId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
})
|
||||
const seeded = emptyStoreState()
|
||||
seedStore(real, {
|
||||
worktreesByRepo: { repo: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo' })] },
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: ['group-left', 'group-right'].map((groupId, index) => ({
|
||||
id: `terminal-${groupId}`,
|
||||
ptyId: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: 1
|
||||
}))
|
||||
},
|
||||
unifiedTabsByWorktree: { [WORKTREE_ID]: [side('group-left'), side('group-right')] },
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: seeded.groupsByWorktree[WORKTREE_ID]!.map((group) => ({
|
||||
...group,
|
||||
activeTabId: `terminal-${group.id}`,
|
||||
tabOrder: [`terminal-${group.id}`]
|
||||
}))
|
||||
},
|
||||
layoutByWorktree: seeded.layoutByWorktree,
|
||||
activeGroupIdByWorktree: seeded.activeGroupIdByWorktree
|
||||
})
|
||||
main = await drawTabsFromMain(real)
|
||||
drawn.store = real
|
||||
}
|
||||
|
||||
/** The chat tabs the workspace shows: switch-on, as main's frames drew them. */
|
||||
async function openedChats(): Promise<Tab[]> {
|
||||
await main?.settle()
|
||||
const tabs = (drawn.store?.getState() ?? store.state).unifiedTabsByWorktree[WORKTREE_ID] ?? []
|
||||
return tabs.filter((tab) => tab.contentType === 'agent-session')
|
||||
}
|
||||
|
||||
const first = launchIntent('session-first')
|
||||
const second = launchIntent('session-second')
|
||||
|
||||
@@ -258,15 +353,27 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await drawSplitFromMain()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = null
|
||||
drawn.store = null
|
||||
})
|
||||
|
||||
describe('a second "new chat" with no text', () => {
|
||||
it('opens a second chat while the first empty one is still starting', () => {
|
||||
it('opens a second chat while the first empty one is still starting', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const firstPick = pick('plus-pick-1')
|
||||
const secondPick = pick('plus-pick-2')
|
||||
|
||||
expect(firstPick.sessionId).toBe(first.sessionId)
|
||||
expect(secondPick.sessionId).toBe(second.sessionId)
|
||||
expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2)
|
||||
expect(await openedChats()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens a second chat beside an empty one that published and sits idle', async () => {
|
||||
@@ -276,7 +383,7 @@ describe('a second "new chat" with no text', () => {
|
||||
const secondPick = pick('plus-pick-2')
|
||||
|
||||
expect(secondPick.sessionId).toBe(second.sessionId)
|
||||
expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2)
|
||||
expect(await openedChats()).toHaveLength(2)
|
||||
await expect(secondPick.settlement).resolves.toEqual({
|
||||
kind: 'structured',
|
||||
sessionId: second.sessionId
|
||||
@@ -306,30 +413,28 @@ describe('a "new chat" with text', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens its own chat beside an empty one still starting', () => {
|
||||
it('opens its own chat beside an empty one still starting', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const blank = pick('plus-pick-1')
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes' })
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2)
|
||||
expect(await openedChats()).toHaveLength(2)
|
||||
expect(hasStagedStructuredLaunchPrompt(blank.sessionId)).toBe(false)
|
||||
expect(hasStagedStructuredLaunchPrompt(second.sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
it('opens its own chat in the split it was sent from', () => {
|
||||
it('opens its own chat in the split it was sent from', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
pick('plus-pick-1', { group: 'group-left' })
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes', group: 'group-right' })
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
expect(
|
||||
store.state.unifiedTabsByWorktree[WORKTREE_ID]?.find(
|
||||
(tab) => tab.entityId === second.sessionId
|
||||
)?.groupId
|
||||
).toBe('group-right')
|
||||
expect((await openedChats()).find((tab) => tab.entityId === second.sessionId)?.groupId).toBe(
|
||||
'group-right'
|
||||
)
|
||||
expect(mocks.focusGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type * as launchModule from './launch-structured-agent-session'
|
||||
import type { AgentSessionLaunchPlan } from './agent-session-launch-plan'
|
||||
@@ -18,6 +18,26 @@ import {
|
||||
addNativeChatPendingAttachment,
|
||||
clearNativeChatPendingAttachmentsForTests
|
||||
} from '@/components/native-chat/native-chat-pending-attachment-cache'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
// Switch-on, main opens and closes the chat's tab and its frames draw it.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() }))
|
||||
vi.mock('./launch-structured-agent-session', async (original) => ({
|
||||
@@ -32,8 +52,27 @@ vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({
|
||||
structuredAgentSessionDraftScopeKey: (id: string) => `agent-session:${id}`
|
||||
}))
|
||||
|
||||
function terminalRecord(id: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
let refuse = (): void => {}
|
||||
|
||||
// The host's refusal lands once the test releases it, after the chat's tab is drawn.
|
||||
function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentSessionLaunchPlan {
|
||||
mocks.launch.mockRejectedValue(error)
|
||||
const refusal = new Promise<never>((_, reject) => {
|
||||
refuse = () => reject(error)
|
||||
})
|
||||
mocks.launch.mockReturnValue(refusal)
|
||||
return adoptAgentSessionLaunchVerdict({
|
||||
requestId: 'action',
|
||||
route: 'structured-native-chat',
|
||||
@@ -43,7 +82,9 @@ function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentS
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.text = ''
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
@@ -56,8 +97,56 @@ beforeEach(() => {
|
||||
tabsByWorktree: {},
|
||||
tabBarOrderByWorktree: {}
|
||||
})
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
})
|
||||
|
||||
afterEach(() => main.close())
|
||||
|
||||
// Main takes the terminal tabs the test drew straight into the store, as a real create sends them.
|
||||
async function mainLoadsStoreLayout(): Promise<void> {
|
||||
main.close()
|
||||
main = await drawTabsFromMain(useAppStore)
|
||||
}
|
||||
|
||||
/** The user's pin: the tab bar sends it to main switch-on, whose frame draws it. */
|
||||
function pinAsUser(tabId: string): void {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
useAppStore.getState().pinTab(tabId)
|
||||
return
|
||||
}
|
||||
const worktreeId = useAppStore.getState().getTab(tabId)?.worktreeId ?? 'wt-1'
|
||||
sendLayoutCommandToOwner(
|
||||
worktreeId,
|
||||
{ type: 'setTabProps', tabId, isPinned: true },
|
||||
{ apply: () => {}, asToday: () => useAppStore.getState().pinTab(tabId) }
|
||||
)
|
||||
}
|
||||
|
||||
/** The tab bar's rename and colour: main's commands with the switch on. */
|
||||
function labelAsUser(tabId: string, label: string, color: string): void {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
useAppStore.getState().setTabCustomLabel(tabId, label)
|
||||
useAppStore.getState().setUnifiedTabColor(tabId, color)
|
||||
return
|
||||
}
|
||||
const asToday = { apply: () => {}, asToday: () => {} }
|
||||
sendLayoutCommandToOwner(
|
||||
'wt-1',
|
||||
{ type: 'renameTab', tabId, kind: 'custom', title: label },
|
||||
asToday
|
||||
)
|
||||
sendLayoutCommandToOwner('wt-1', { type: 'setTabProps', tabId, color }, asToday)
|
||||
}
|
||||
|
||||
/** The group main's frame drew the tab in. */
|
||||
function groupOf(tabId: string): string {
|
||||
const groupId = useAppStore.getState().getTab(tabId)?.groupId
|
||||
if (!groupId) {
|
||||
throw new Error(`No drawn tab ${tabId}`)
|
||||
}
|
||||
return groupId
|
||||
}
|
||||
|
||||
function openChat(requestedSurface?: 'chat') {
|
||||
let complete!: (value: AgentTerminalLaunchCompletion) => void
|
||||
const completion = new Promise<AgentTerminalLaunchCompletion>((resolve) => {
|
||||
@@ -100,6 +189,7 @@ describe('a provisional structured chat tab', () => {
|
||||
const { launch, chat, fallback, complete } = openChat(
|
||||
scenario === 'explicit' ? 'chat' : undefined
|
||||
)
|
||||
await main.settle()
|
||||
expect(mocks.focus).toHaveBeenCalledWith(chat.id)
|
||||
const view = renderHook(() => {
|
||||
const current = useNativeChatProvisionalLaunch('wt-1', chat.entityId)
|
||||
@@ -120,6 +210,8 @@ describe('a provisional structured chat tab', () => {
|
||||
if (scenario === 'closed') {
|
||||
useAppStore.getState().closeUnifiedTab(chat.id)
|
||||
}
|
||||
await main.settle()
|
||||
refuse()
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
scenario === 'closed' || scenario === 'explicit' || fallback.mock.calls.length
|
||||
@@ -131,19 +223,26 @@ describe('a provisional structured chat tab', () => {
|
||||
view.unmount()
|
||||
return
|
||||
}
|
||||
expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(chat)
|
||||
// Switch-on, main's frame draws the tab the open named.
|
||||
expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(
|
||||
expect.objectContaining({ ...chat, contentType: 'agent-session' })
|
||||
)
|
||||
// Move the source while its host is preparing the terminal, then invest in the composer.
|
||||
const sibling = useAppStore.getState().createUnifiedTab('wt-1', 'terminal', {
|
||||
entityId: 'sibling',
|
||||
split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true }
|
||||
split: { sourceGroupId: groupOf(chat.id), splitDirection: 'right', activate: true }
|
||||
})
|
||||
const groupId = sibling.groupId
|
||||
// Main loads only terminal tabs with their terminal's record.
|
||||
const siblingRecords = () =>
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? [terminalRecord(sibling.entityId)] : []
|
||||
useAppStore.setState({ tabsByWorktree: { 'wt-1': siblingRecords() } })
|
||||
await mainLoadsStoreLayout()
|
||||
const pinnedSource = scenario === 'empty' || scenario === 'typed-during'
|
||||
if (pinnedSource) {
|
||||
useAppStore.getState().pinTab(chat.id)
|
||||
useAppStore.getState().pinTab(sibling.id)
|
||||
useAppStore.getState().setTabCustomLabel(chat.id, 'My work')
|
||||
useAppStore.getState().setUnifiedTabColor(chat.id, '#ff0000')
|
||||
pinAsUser(chat.id)
|
||||
pinAsUser(sibling.id)
|
||||
labelAsUser(chat.id, 'My work', '#ff0000')
|
||||
}
|
||||
useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId)
|
||||
useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id])
|
||||
@@ -163,12 +262,12 @@ describe('a provisional structured chat tab', () => {
|
||||
if (scenario === 'closed-during') {
|
||||
useAppStore.getState().closeUnifiedTab(chat.id)
|
||||
}
|
||||
await main.settle()
|
||||
if (pinnedSource) {
|
||||
useAppStore.getState().activateTab(chat.id)
|
||||
useAppStore.getState().setActiveTabType('agent-session', 'wt-1')
|
||||
}
|
||||
const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId)
|
||||
const selected = useAppStore.getState().getActiveTab('wt-2')
|
||||
const terminal =
|
||||
scenario === 'failed' || scenario === 'unreconciled'
|
||||
? null
|
||||
@@ -177,25 +276,14 @@ describe('a provisional structured chat tab', () => {
|
||||
.createUnifiedTab('wt-1', 'terminal', { id: 'replacement', activate: false })
|
||||
if (terminal) {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: terminal.entityId,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
tabsByWorktree: { 'wt-1': [terminalRecord(terminal.entityId), ...siblingRecords()] }
|
||||
})
|
||||
if (scenario === 'typed-before') {
|
||||
useAppStore.getState().pinTab(terminal.id)
|
||||
}
|
||||
}
|
||||
await mainLoadsStoreLayout()
|
||||
const selected = useAppStore.getState().getActiveTab('wt-2')
|
||||
const interaction = vi.fn()
|
||||
useAppStore.setState({ recordFeatureInteraction: interaction })
|
||||
await act(async () => {
|
||||
@@ -205,6 +293,7 @@ describe('a provisional structured chat tab', () => {
|
||||
tabId: terminal?.id ?? 'missing'
|
||||
})
|
||||
await launch?.settlement
|
||||
await main.settle()
|
||||
})
|
||||
expect(interaction).not.toHaveBeenCalled()
|
||||
const state = useAppStore.getState()
|
||||
@@ -267,4 +356,51 @@ describe('a provisional structured chat tab', () => {
|
||||
}
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
// A hidden launch slot is in the window's group order but not in main's: the terminal's place
|
||||
// is counted among the tabs both draw.
|
||||
it("places a declined chat's terminal right after the chat past a hidden launch slot", async () => {
|
||||
const { launch, chat, fallback, complete } = openChat()
|
||||
await main.settle()
|
||||
refuse()
|
||||
await vi.waitFor(() => expect(fallback).toHaveBeenCalled())
|
||||
const chatGroupId = groupOf(chat.id)
|
||||
const state = useAppStore.getState()
|
||||
const sibling = state.createUnifiedTab('wt-1', 'terminal', {
|
||||
entityId: 'sibling',
|
||||
targetGroupId: chatGroupId,
|
||||
activate: false
|
||||
})
|
||||
const terminal = state.createUnifiedTab('wt-1', 'terminal', {
|
||||
id: 'replacement',
|
||||
targetGroupId: chatGroupId,
|
||||
activate: false
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? [terminalRecord(sibling.entityId), terminalRecord(terminal.entityId)]
|
||||
: []
|
||||
}
|
||||
})
|
||||
await mainLoadsStoreLayout()
|
||||
const slot = structuredAgentSessionTabId('hidden-launch')
|
||||
useAppStore.getState().ensureWorktreeRootGroup('wt-1', {
|
||||
tabId: slot,
|
||||
targetGroupId: chatGroupId,
|
||||
precedingTabIds: []
|
||||
})
|
||||
const order = () =>
|
||||
useAppStore.getState().groupsByWorktree['wt-1']?.find((g) => g.id === chatGroupId)?.tabOrder
|
||||
expect(order()).toEqual([slot, chat.id, sibling.id, terminal.id])
|
||||
|
||||
await act(async () => {
|
||||
complete({ outcome: { status: 'created' }, tabId: terminal.id })
|
||||
await launch?.settlement
|
||||
await main.settle()
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().getTab(chat.id)).toBeNull()
|
||||
expect(order()?.filter((id) => id !== slot)).toEqual([terminal.id, sibling.id])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type {
|
||||
AgentSessionLaunchPlan,
|
||||
AgentSessionLaunchTarget
|
||||
@@ -27,13 +26,14 @@ import {
|
||||
opensAtAction
|
||||
} from './structured-agent-session-action-launch'
|
||||
import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims'
|
||||
import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner'
|
||||
import {
|
||||
captureStructuredLaunchSurfaceAddress,
|
||||
openStructuredAgentSessionProvisionalTab
|
||||
} from './structured-agent-session-launch-surface'
|
||||
export { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface'
|
||||
|
||||
type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab }
|
||||
type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: OpenedAgentSessionTab }
|
||||
|
||||
/** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */
|
||||
export type StructuredAgentSessionProvisionalLaunch =
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// A chat tab open in a layout-owner build: the window sends the open under the chat's own tab id;
|
||||
// main's frame draws the tab, and only then does the window select it. A caller acting on the
|
||||
// returned tab in the store waits with `afterPendingTab`.
|
||||
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label'
|
||||
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
|
||||
import { useAppStore } from '@/store'
|
||||
import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open'
|
||||
import { hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs'
|
||||
import { contentTabOpenPlacement } from '@/store/slices/tabs/content-tab-open-placement'
|
||||
|
||||
export type AgentSessionTabOpen = {
|
||||
worktreeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
sessionId: string
|
||||
agent: TuiAgent
|
||||
targetGroupId?: string
|
||||
afterTabId?: string
|
||||
activate: boolean
|
||||
/** Today's draw, for a twin main may route to a paired server. */
|
||||
drawHere: () => Tab
|
||||
}
|
||||
|
||||
/** What a caller may rely on before main's frame draws the tab: its id and the chat it shows. */
|
||||
export type OpenedAgentSessionTab = Pick<Tab, 'id' | 'entityId'>
|
||||
|
||||
/** Null when main is not asked (a paired server owns the workspace's layout): draw as today. */
|
||||
export function openAgentSessionTabThroughLayoutOwner(
|
||||
open: AgentSessionTabOpen
|
||||
): OpenedAgentSessionTab | null {
|
||||
const { worktreeId, sessionId } = open
|
||||
const state = useAppStore.getState()
|
||||
const tabId = structuredAgentSessionTabId(sessionId)
|
||||
const label = defaultAgentChatLabel(open.agent)
|
||||
const placement = contentTabOpenPlacement(state, worktreeId, tabId, {
|
||||
targetGroupId: open.targetGroupId,
|
||||
afterTabId: open.afterTabId,
|
||||
executionHostId: open.executionHostId
|
||||
})
|
||||
if (!placement) {
|
||||
return null
|
||||
}
|
||||
const tab = { id: tabId, entityId: sessionId }
|
||||
// A second launch of the same chat waits for the first open.
|
||||
if (hasPendingEntityTab(worktreeId, sessionId)) {
|
||||
return tab
|
||||
}
|
||||
const sent = openContentTabThroughLayoutOwner(state, {
|
||||
worktreeId,
|
||||
entityId: sessionId,
|
||||
command: { type: 'openAgentSessionTab', tabId, sessionId, agent: open.agent, ...placement },
|
||||
facts: { label },
|
||||
shown: (shownId) => {
|
||||
if (!open.activate) {
|
||||
return
|
||||
}
|
||||
// As the window's own open: the chat is selected in its group; the focused group stays.
|
||||
const current = useAppStore.getState()
|
||||
current.activateTab(shownId, { worktreeId })
|
||||
current.setActiveTabType('agent-session', worktreeId)
|
||||
},
|
||||
drawHere: () => open.drawHere().id
|
||||
})
|
||||
return sent ? tab : null
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// @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([])
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
// 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 { drawnTabPlacement } from './layout-owner-drawn-tab-placement'
|
||||
|
||||
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
|
||||
}
|
||||
recordLayoutApply(state, tab.worktreeId, {
|
||||
type: 'openAgentSessionTab',
|
||||
tabId: tab.id,
|
||||
sessionId: tab.entityId,
|
||||
agent: tab.agentSessionAgent,
|
||||
...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id),
|
||||
createdAt: tab.createdAt
|
||||
})
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export type WindowLayoutCommand =
|
||||
| { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide }
|
||||
| { type: 'setPaneRatios'; tabId: string; ratios: SplitRatioIntent[] }
|
||||
| { type: 'setChatPane'; tabId: string; leafId: string | null }
|
||||
| { type: 'moveTab'; tabId: string; toGroupId: string; index: number }
|
||||
| { type: 'moveTab'; tabId: string; toGroupId: string; index: number; isPinned?: boolean }
|
||||
| { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide }
|
||||
| { type: 'setGroupRatios'; ratios: SplitRatioIntent[] }
|
||||
| { type: 'closeTabs'; tabIds: [string]; recordClose?: 'user' | 'cleanup' }
|
||||
@@ -71,8 +71,9 @@ export type WindowLayoutCommand =
|
||||
groupId?: string
|
||||
afterTabId?: string
|
||||
index?: number
|
||||
createdAt: number
|
||||
browserCreatedAt: number
|
||||
/** Absent from a switch-on open: main stamps the tab's time. */
|
||||
createdAt?: number
|
||||
browserCreatedAt?: number
|
||||
split?: NewGroupSplit
|
||||
}
|
||||
| { type: 'openBrowserPage' | 'closeBrowserPage'; browserTabId: string; pageId: string }
|
||||
@@ -105,7 +106,7 @@ export type WindowLayoutCommand =
|
||||
groupId: string
|
||||
afterTabId?: string
|
||||
index?: number
|
||||
createdAt: number
|
||||
createdAt?: number
|
||||
split?: NewGroupSplit
|
||||
}
|
||||
| { type: 'promotePreviewTab'; tabId: string }
|
||||
@@ -115,7 +116,7 @@ export type WindowLayoutCommand =
|
||||
groupId: string
|
||||
afterTabId?: string
|
||||
index?: number
|
||||
createdAt: number
|
||||
createdAt?: number
|
||||
split?: NewGroupSplit
|
||||
}
|
||||
| {
|
||||
@@ -174,26 +175,24 @@ function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnT
|
||||
switch (command.type) {
|
||||
case 'closeTabs':
|
||||
return [{ workspace: worktreeId, ids: command.tabIds }]
|
||||
case 'openBrowserTab':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId, command.browserTabId] }]
|
||||
case 'splitGroup':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId], groupLayout: true }]
|
||||
case 'moveTab':
|
||||
case 'renameTab':
|
||||
case 'setTabProps':
|
||||
case 'openAgentSessionTab':
|
||||
case 'openSimulatorTab':
|
||||
case 'createTerminalTab':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId] }]
|
||||
case 'setGroupRatios':
|
||||
return [{ workspace: worktreeId, ids: [], groupLayout: true }]
|
||||
// Pane commands hold their tab's pane tree; records are not drawn from frames; editor, browser,
|
||||
// chat and simulator opens and page changes are sent before the window draws them.
|
||||
case 'openBrowserTab':
|
||||
case 'openBrowserPage':
|
||||
case 'closeBrowserPage':
|
||||
case 'replaceBrowserPage':
|
||||
case 'setBrowserTabProfile':
|
||||
return [{ workspace: worktreeId, ids: [command.browserTabId] }]
|
||||
case 'setGroupRatios':
|
||||
return [{ workspace: worktreeId, ids: [], groupLayout: true }]
|
||||
// Pane commands hold their tab's pane tree; records are not drawn from frames; editor opens,
|
||||
// promotions, moves and renames are sent before the window draws them.
|
||||
case 'openAgentSessionTab':
|
||||
case 'openSimulatorTab':
|
||||
case 'openEditorTab':
|
||||
case 'promotePreviewTab':
|
||||
case 'moveTabToWorkspace':
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// A browser, chat or simulator tab open in a layout-owner build: the window proposes the ids and
|
||||
// sends the open; main's frame draws the tab with the window's facts for it, and only then does
|
||||
// the window act on it (select, focus). A caller acting on the tab waits with `afterPendingTab`.
|
||||
|
||||
import { sendLayoutApply, type WindowLayoutCommand } from './layout-apply-send'
|
||||
import { beginPendingTab } from './layout-owner-pending-tabs'
|
||||
import { expectTabFacts, type PendingTabFacts } from './layout-owner-pending-tab-facts'
|
||||
|
||||
type ContentTabOpenCommand = Extract<
|
||||
WindowLayoutCommand,
|
||||
{ type: 'openBrowserTab' | 'openAgentSessionTab' | 'openSimulatorTab' }
|
||||
>
|
||||
|
||||
export type ContentTabOpen = {
|
||||
worktreeId: string
|
||||
/** What the tab shows: a browser record, chat session or the simulator tab itself. */
|
||||
entityId: string
|
||||
command: ContentTabOpenCommand
|
||||
facts: Omit<PendingTabFacts, 'worktreeId' | 'tabId'>
|
||||
/** Runs once main's frame drew the tab, with the id it shows. */
|
||||
shown: (tabId: string) => void
|
||||
/** Today's draw, for a twin main may route to a paired server; the tab id it drew, if any. */
|
||||
drawHere: () => string | null
|
||||
}
|
||||
|
||||
/** False when main is not asked (a paired server owns the workspace's layout); draw as today. */
|
||||
export function openContentTabThroughLayoutOwner(
|
||||
state: Parameters<typeof sendLayoutApply>[0],
|
||||
open: ContentTabOpen
|
||||
): boolean {
|
||||
const { worktreeId, command } = open
|
||||
const groupId = command.groupId
|
||||
const settle = beginPendingTab(command.tabId, worktreeId, open.entityId, {
|
||||
kind: 'open',
|
||||
groupId
|
||||
})
|
||||
const release = expectTabFacts({ ...open.facts, worktreeId, tabId: command.tabId })
|
||||
const sent = sendLayoutApply(state, worktreeId, command, {
|
||||
apply: (result) => {
|
||||
const shownId = result.tabId ?? command.tabId
|
||||
// The frame drew the tab with these facts; the store holds them from here on.
|
||||
release()
|
||||
try {
|
||||
open.shown(shownId)
|
||||
} finally {
|
||||
settle(shownId)
|
||||
}
|
||||
},
|
||||
asToday: () => {
|
||||
release()
|
||||
settle(open.drawHere())
|
||||
},
|
||||
refused: () => {
|
||||
release()
|
||||
settle(null)
|
||||
}
|
||||
})
|
||||
if (!sent) {
|
||||
release()
|
||||
settle(null)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
|
||||
import { drawnTabPlacement } from './layout-owner-drawn-tab-placement'
|
||||
|
||||
const WT = 'wt-1'
|
||||
|
||||
function stateOf(groups: TabGroup[], layout: TabGroupLayoutNode) {
|
||||
return { groupsByWorktree: { [WT]: groups }, layoutByWorktree: { [WT]: layout } }
|
||||
}
|
||||
|
||||
function group(id: string, tabOrder: string[]): TabGroup {
|
||||
return { id, worktreeId: WT, activeTabId: tabOrder[0] ?? null, tabOrder }
|
||||
}
|
||||
|
||||
describe('drawnTabPlacement', () => {
|
||||
const single = stateOf([group('G', ['a', 'b', 'c'])], { type: 'leaf', groupId: 'G' })
|
||||
|
||||
it('places a tab drawn first at index 0', () => {
|
||||
expect(drawnTabPlacement(single, WT, 'G', 'a')).toEqual({ groupId: 'G', index: 0 })
|
||||
})
|
||||
|
||||
it('places a middle tab after the tab before it', () => {
|
||||
expect(drawnTabPlacement(single, WT, 'G', 'b')).toEqual({ groupId: 'G', afterTabId: 'a' })
|
||||
})
|
||||
|
||||
it('places the last tab after the tab before it', () => {
|
||||
expect(drawnTabPlacement(single, WT, 'G', 'c')).toEqual({ groupId: 'G', afterTabId: 'b' })
|
||||
})
|
||||
|
||||
it('names only the group for a tab missing from the order', () => {
|
||||
expect(drawnTabPlacement(single, WT, 'G', 'z')).toEqual({ groupId: 'G' })
|
||||
expect(drawnTabPlacement(single, WT, 'missing-group', 'a')).toEqual({
|
||||
groupId: 'missing-group'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the split for a tab opened to the side in its own new group', () => {
|
||||
const state = stateOf([group('G', ['a']), group('N', ['n'])], {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'G' },
|
||||
second: { type: 'leaf', groupId: 'N' }
|
||||
})
|
||||
expect(drawnTabPlacement(state, WT, 'N', 'n')).toEqual({
|
||||
groupId: 'N',
|
||||
index: 0,
|
||||
split: { besideGroupId: 'G', direction: 'right' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { NewGroupSplit } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { newGroupSplitOf } from './layout-owner-new-group-split'
|
||||
|
||||
type DrawnTabPlacement = {
|
||||
groupId: string
|
||||
afterTabId?: string
|
||||
index?: 0
|
||||
split?: NewGroupSplit
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the window drew a just-opened tab, as an open command's placement fields: after the tab
|
||||
* before it, first in its group, or (missing from the order) only the group; plus the split that
|
||||
* made its group when it was opened to the side.
|
||||
*/
|
||||
export function drawnTabPlacement(
|
||||
state: Parameters<typeof newGroupSplitOf>[0],
|
||||
worktreeId: string,
|
||||
groupId: string,
|
||||
tabId: string
|
||||
): DrawnTabPlacement {
|
||||
const order = state.groupsByWorktree[worktreeId]?.find((group) => group.id === groupId)?.tabOrder
|
||||
const position = order?.indexOf(tabId) ?? -1
|
||||
const afterTabId = position > 0 ? order?.[position - 1] : undefined
|
||||
return {
|
||||
groupId,
|
||||
...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {}),
|
||||
...newGroupSplitOf(state, worktreeId, groupId, tabId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Content facts of a tab this window asked main to open, in a layout-owner build: its tab-bar
|
||||
// label and, for a browser tab, its page state and pages. Main holds only the tab's layout, so a
|
||||
// frame build reads these as the store's own until the frame that draws the tab is in.
|
||||
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types'
|
||||
import {
|
||||
BROWSER_TAB_LAYOUT_FIELDS,
|
||||
BROWSER_TAB_WORKSPACE_FIELDS,
|
||||
type LayoutContentFacts
|
||||
} from '../../../shared/workspace-layout/workspace-layout-beside'
|
||||
import { omitStoredFields } from '../../../shared/workspace-layout/stored-record-fields'
|
||||
|
||||
export type PendingTabFacts = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
label: string
|
||||
browser?: { record: BrowserWorkspace; pages: BrowserPage[] }
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingTabFacts>()
|
||||
|
||||
/** Kept until the release, which is a no-op once a later expectation for the tab replaced it. */
|
||||
export function expectTabFacts(facts: PendingTabFacts): () => void {
|
||||
pending.set(facts.tabId, facts)
|
||||
return () => {
|
||||
if (pending.get(facts.tabId) === facts) {
|
||||
pending.delete(facts.tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `facts` with the pending tabs of `keys` added where the store had none of their own. */
|
||||
export function withPendingTabFacts(
|
||||
facts: LayoutContentFacts,
|
||||
keys: readonly string[]
|
||||
): LayoutContentFacts {
|
||||
const entries = [...pending.values()].filter((entry) => keys.includes(entry.worktreeId))
|
||||
if (entries.length === 0) {
|
||||
return facts
|
||||
}
|
||||
const tabLabels = { ...facts.tabLabels }
|
||||
const browserTabs = { ...facts.browserTabs }
|
||||
const browserPagesByWorkspace = { ...facts.browserPagesByWorkspace }
|
||||
for (const { worktreeId, tabId, label, browser } of entries) {
|
||||
if (tabLabels[worktreeId]?.[tabId] === undefined) {
|
||||
tabLabels[worktreeId] = { ...tabLabels[worktreeId], [tabId]: label }
|
||||
}
|
||||
if (!browser) {
|
||||
continue
|
||||
}
|
||||
const id = browser.record.id
|
||||
if (!browserTabs[worktreeId]?.[id]) {
|
||||
const live = omitStoredFields(browser.record, [
|
||||
...BROWSER_TAB_LAYOUT_FIELDS,
|
||||
...BROWSER_TAB_WORKSPACE_FIELDS
|
||||
])
|
||||
browserTabs[worktreeId] = { ...browserTabs[worktreeId], [id]: { ...live, loading: false } }
|
||||
}
|
||||
if (!browserPagesByWorkspace[id]?.length) {
|
||||
browserPagesByWorkspace[id] = browser.pages.map((page) => ({ ...page, loading: false }))
|
||||
}
|
||||
}
|
||||
return { ...facts, tabLabels, browserTabs, browserPagesByWorkspace }
|
||||
}
|
||||
|
||||
export function _resetPendingTabFactsForTests(): void {
|
||||
pending.clear()
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export function afterPendingTab(tabId: string, run: (outcome: Outcome) => void):
|
||||
}
|
||||
}
|
||||
|
||||
export function isPendingTab(tabId: string): boolean {
|
||||
return pending.has(tabId)
|
||||
}
|
||||
|
||||
/** The group a pending open asked for. */
|
||||
export function pendingTabGroupId(tabId: string): string | undefined {
|
||||
const ask = pending.get(tabId)?.ask
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// Switch-on, this machine's chat listing leaves a frame-drawn workspace's tabs as main drew them
|
||||
// and only updates a drawn chat tab's label from the host's title.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
|
||||
import { resetLocalStructuredSessionVersionForTests } from './inventory-generation-fence'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '../web-session-tabs-sync/tracking-lifecycle'
|
||||
|
||||
const WT = 'repo-1::wt-drawn'
|
||||
const GROUP = 'group-1'
|
||||
const TERMINAL = 'terminal-1'
|
||||
const CHAT = 'structured-agent-session-chat-1'
|
||||
|
||||
function tab(id: string, entityId: string, contentType: Tab['contentType'], label: string): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId,
|
||||
groupId: GROUP,
|
||||
worktreeId: WT,
|
||||
contentType,
|
||||
label,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
const STRUCTURE_FIELDS = [
|
||||
'unifiedTabsByWorktree',
|
||||
'groupsByWorktree',
|
||||
'layoutByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'tabBarOrderByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'activeTabId',
|
||||
'activeTabType'
|
||||
] as const
|
||||
|
||||
function structure(state: AppState): Partial<AppState> {
|
||||
return Object.fromEntries(STRUCTURE_FIELDS.map((field) => [field, state[field]]))
|
||||
}
|
||||
|
||||
/** The host lists the drawn chat under a new title and a second chat main has not placed yet. */
|
||||
function listing(): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WT,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'host-group',
|
||||
activeTabId: 'agent-session:chat-2',
|
||||
activeTabType: 'agent-session',
|
||||
navigationIntent: 'follow',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'host-group',
|
||||
activeTabId: 'agent-session:chat-2',
|
||||
tabOrder: ['agent-session:chat-1', 'agent-session:chat-2']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:chat-1',
|
||||
title: 'Renamed by host',
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:chat-2',
|
||||
title: 'Second chat',
|
||||
sessionId: 'chat-2',
|
||||
agent: 'codex',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: WT,
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
tab(TERMINAL, TERMINAL, 'terminal', 'Terminal'),
|
||||
{ ...tab(CHAT, 'chat-1', 'agent-session', 'Old title'), agentSessionAgent: 'codex' }
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [{ id: GROUP, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL, CHAT] }]
|
||||
},
|
||||
layoutByWorktree: { [WT]: { type: 'leaf', groupId: GROUP } },
|
||||
activeGroupIdByWorktree: { [WT]: GROUP },
|
||||
tabBarOrderByWorktree: { [WT]: [TERMINAL, CHAT] },
|
||||
activeTabIdByWorktree: { [WT]: TERMINAL },
|
||||
activeTabTypeByWorktree: { [WT]: 'terminal' },
|
||||
activeTabId: TERMINAL,
|
||||
activeTabType: 'terminal'
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetLocalStructuredSessionVersionForTests()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
})
|
||||
|
||||
describe('chat listing for a frame-drawn workspace', () => {
|
||||
it('keeps the tabs main drew and takes the host title as the drawn chat label', () => {
|
||||
const drawn = structure(useAppStore.getState())
|
||||
|
||||
applyStructuredSessionTabSnapshots([listing()])
|
||||
|
||||
const after = useAppStore.getState()
|
||||
expect(after.unifiedTabsByWorktree[WT]?.find((t) => t.id === CHAT)?.label).toBe(
|
||||
'Renamed by host'
|
||||
)
|
||||
const relabelled = {
|
||||
...drawn,
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: (drawn.unifiedTabsByWorktree?.[WT] ?? []).map((t) =>
|
||||
t.id === CHAT ? { ...t, label: 'Renamed by host' } : t
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(structure(after)).toEqual(relabelled)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// This machine's chat listing in a layout-owner build: main places and removes a listed chat's tab
|
||||
// in its own layout, and its frames draw it, so the listing changes no tab-bar structure of a
|
||||
// workspace frames draw. It still updates what a drawn chat tab shows (its label from the host).
|
||||
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label'
|
||||
|
||||
export type DrawnChatLabel = { tabId: string; label: string }
|
||||
|
||||
/** The host's chat titles that differ from the labels on the window's drawn chat tabs. */
|
||||
export function drawnChatLabelChanges(
|
||||
drawnTabs: readonly Tab[],
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
): DrawnChatLabel[] {
|
||||
const changes: DrawnChatLabel[] = []
|
||||
for (const listed of snapshot.tabs) {
|
||||
if (listed.type !== 'agent-session') {
|
||||
continue
|
||||
}
|
||||
const drawn = drawnTabs.find(
|
||||
(tab) => tab.contentType === 'agent-session' && tab.entityId === listed.sessionId
|
||||
)
|
||||
// Same rule as the mirror's label, so a drawn tab reads as an undrawn one would.
|
||||
const label = listed.title?.trim() || defaultAgentChatLabel(listed.agent)
|
||||
if (drawn && drawn.label !== label) {
|
||||
changes.push({ tabId: drawn.id, label })
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function refreshLocalStructuredSessionTabs(
|
||||
export async function refreshLocalStructuredSessionWorktreeTabs(
|
||||
worktreeId: string,
|
||||
options: { reacceptCurrentVersion?: boolean } = {}
|
||||
): Promise<void> {
|
||||
): Promise<RuntimeMobileSessionTabsResult | null> {
|
||||
// Structured chat can be switched off mid-call, which wipes the mirror; a late answer must not re-seed it.
|
||||
const expectedGeneration = localStructuredSessionGeneration()
|
||||
const snapshot = await callRuntimeRpc<RuntimeMobileSessionTabsResult>(
|
||||
@@ -100,7 +100,9 @@ export async function refreshLocalStructuredSessionWorktreeTabs(
|
||||
'session.tabs.list',
|
||||
{ worktree: toRuntimeWorktreeSelector(worktreeId) }
|
||||
)
|
||||
if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) {
|
||||
applyStructuredSessionTabSnapshots([snapshot], undefined, options)
|
||||
if (!isCurrentLocalStructuredSessionGeneration(expectedGeneration)) {
|
||||
return null
|
||||
}
|
||||
applyStructuredSessionTabSnapshots([snapshot], undefined, options)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@ 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'
|
||||
import { useAppStore } from '../../store'
|
||||
import { framesDrawWorkspace } from '../workspace-layout-frame-drawn-workspaces'
|
||||
import { drawnChatLabelChanges, type DrawnChatLabel } from './frame-drawn-chat-tabs'
|
||||
|
||||
/** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */
|
||||
function isWorktreeRetraction(
|
||||
@@ -60,6 +61,10 @@ export type StructuredSessionSnapshotApplyOptions = {
|
||||
onRetiredEpochDrop?: (worktreeId: string, publicationEpoch: string) => void
|
||||
/** Collects accepted publications so lifecycle listeners run after the store patch settles. */
|
||||
onAcceptedAgentSession?: (worktreeId: string, sessionId: string) => void
|
||||
/** Workspaces main's frames draw: the listing writes none of their tabs, only collects labels. */
|
||||
framesDraw?: (worktreeId: string) => boolean
|
||||
/** Collects host titles for drawn chat tabs, applied as content after the store patch. */
|
||||
onDrawnChatLabel?: (change: DrawnChatLabel) => void
|
||||
}
|
||||
|
||||
export function applyStructuredSessionTabSnapshots(
|
||||
@@ -68,7 +73,7 @@ export function applyStructuredSessionTabSnapshots(
|
||||
options: StructuredSessionSnapshotApplyOptions = {}
|
||||
): void {
|
||||
const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = []
|
||||
const before = ORCA_LAYOUT_OWNER_IS_MAIN ? useAppStore.getState() : null
|
||||
const drawnChatLabels: DrawnChatLabel[] = []
|
||||
const settleStructuredSessionMirror = applyWebSessionTabsStorePatch(
|
||||
(state) =>
|
||||
applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, {
|
||||
@@ -76,18 +81,17 @@ export function applyStructuredSessionTabSnapshots(
|
||||
onAcceptedAgentSession: (worktreeId, sessionId) => {
|
||||
acceptedAgentSessions.push({ worktreeId, sessionId })
|
||||
options.onAcceptedAgentSession?.(worktreeId, sessionId)
|
||||
}
|
||||
},
|
||||
// Switch point: main places the listed chats' tabs; its frames draw them.
|
||||
framesDraw: ORCA_LAYOUT_OWNER_IS_MAIN ? framesDrawWorkspace(state) : undefined,
|
||||
onDrawnChatLabel: (change) => drawnChatLabels.push(change)
|
||||
}),
|
||||
{ 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)
|
||||
)
|
||||
const { setTabLabel } = useAppStore.getState()
|
||||
for (const { tabId, label } of drawnChatLabels) {
|
||||
setTabLabel(tabId, label)
|
||||
}
|
||||
markStructuredAgentSessionLaunchesPublished(LOCAL_EXECUTION_HOST_ID, acceptedAgentSessions)
|
||||
if (options.authoritative) {
|
||||
@@ -167,6 +171,7 @@ export function applyLocalStructuredSessionTabSnapshots<
|
||||
options.onAcceptedAgentSession?.(snapshot.worktree, tab.sessionId)
|
||||
}
|
||||
}
|
||||
// Runs for a drawn workspace too: its tab-id, focus and close-intent ledgers still follow the host.
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
next,
|
||||
projectLocalStructuredSessionTabs(effectiveSnapshot),
|
||||
@@ -178,7 +183,17 @@ export function applyLocalStructuredSessionTabSnapshots<
|
||||
terminalPtyMode: 'local'
|
||||
}
|
||||
)
|
||||
next = patch === next ? next : ({ ...next, ...patch } as State)
|
||||
if (options.framesDraw?.(snapshot.worktree)) {
|
||||
// Main's frames own this workspace's tabs, so the patch (tab structure only here) is dropped.
|
||||
for (const change of drawnChatLabelChanges(
|
||||
next.unifiedTabsByWorktree[snapshot.worktree] ?? [],
|
||||
effectiveSnapshot
|
||||
)) {
|
||||
options.onDrawnChatLabel?.(change)
|
||||
}
|
||||
} else {
|
||||
next = patch === next ? next : ({ ...next, ...patch } as State)
|
||||
}
|
||||
if (isWorktreeRetraction(effectiveSnapshot)) {
|
||||
// A retraction was applied above — the mirrored rows must go — but it is not a publication
|
||||
// to fence later frames against. Recording it would retire the renderer's own epoch, which
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Diff and markdown-preview rows: main's projection of its layout model (the window's session plus
|
||||
// the editor views the window publishes) must equal the rows the window builds for the same tabs.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
|
||||
import type { LayoutEditorView } from '../../../shared/workspace-layout/layout-editor-view'
|
||||
import { projectSessionTabs } from '../../../shared/workspace-layout/old-client/old-client-session-tab-rows'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership'
|
||||
import type { OpenFile } from '../store/slices/editor'
|
||||
import { layoutEditorViewOfOpenFile } from '../store/slices/editor/tabs/open-file-layout-view'
|
||||
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
|
||||
import { makeState } from './sync-runtime-graph-test-harness'
|
||||
|
||||
// Main's projection runs on the layout-owner path only.
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const README = '/Users/dev/orca/README.md'
|
||||
const SERVER_DOC = '/srv/app/GUIDE.md'
|
||||
const SERVER_DOC_ID = ownedEditorFileId(SERVER_DOC, GIT_KEY, 'env-1')
|
||||
|
||||
const files: OpenFile[] = [
|
||||
{
|
||||
id: README,
|
||||
filePath: README,
|
||||
relativePath: 'README.md',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: true
|
||||
},
|
||||
{
|
||||
id: SERVER_DOC_ID,
|
||||
filePath: SERVER_DOC,
|
||||
relativePath: 'GUIDE.md',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: `markdown-preview::${README}`,
|
||||
filePath: README,
|
||||
relativePath: 'README.md',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'markdown',
|
||||
mode: 'markdown-preview',
|
||||
markdownPreviewSourceFileId: README,
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: `markdown-preview::${SERVER_DOC_ID}`,
|
||||
filePath: SERVER_DOC,
|
||||
relativePath: 'GUIDE.md',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
mode: 'markdown-preview',
|
||||
markdownPreviewSourceFileId: SERVER_DOC_ID,
|
||||
isDirty: false
|
||||
},
|
||||
// Its source tab is closed: the preview is its own source.
|
||||
{
|
||||
id: 'markdown-preview::/Users/dev/orca/CHANGELOG.md',
|
||||
filePath: '/Users/dev/orca/CHANGELOG.md',
|
||||
relativePath: 'CHANGELOG.md',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'markdown',
|
||||
mode: 'markdown-preview',
|
||||
markdownPreviewSourceFileId: '/Users/dev/orca/CHANGELOG.md',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: `${GIT_KEY}::diff::unstaged::src/app.ts`,
|
||||
filePath: '/Users/dev/orca/src/app.ts',
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: `${GIT_KEY}::diff::branch::src/lib.ts`,
|
||||
filePath: '/Users/dev/orca/src/lib.ts',
|
||||
relativePath: 'src/lib.ts',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'branch',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: `${GIT_KEY}::all-diffs::uncommitted`,
|
||||
filePath: '/Users/dev/orca',
|
||||
relativePath: 'All Changes',
|
||||
worktreeId: GIT_KEY,
|
||||
language: 'plaintext',
|
||||
mode: 'diff',
|
||||
diffSource: 'combined-all',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
|
||||
const tabIdOf = (file: OpenFile): string => `tab:${file.id}`
|
||||
|
||||
/** The window's session for `files`, one tab each in one group, and the views it publishes. */
|
||||
function scenario(activeFileId: string) {
|
||||
const session = addWorkspace(emptySession(), GIT_KEY, [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: tabIdOf(files.find((file) => file.id === activeFileId)!),
|
||||
tabs: files.map((file) => ({
|
||||
id: tabIdOf(file),
|
||||
entityId: file.id,
|
||||
kind: file.mode === 'diff' ? ('diff' as const) : ('editor' as const),
|
||||
entry: { color: file.mode === 'diff' ? '#00ff00' : null }
|
||||
}))
|
||||
}
|
||||
])
|
||||
const editFiles = files.filter((file) => file.mode === 'edit')
|
||||
session.openFilesByWorktree = {
|
||||
[GIT_KEY]: editFiles.map((file) => ({
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: GIT_KEY,
|
||||
language: file.language,
|
||||
...(file.runtimeEnvironmentId ? { runtimeEnvironmentId: file.runtimeEnvironmentId } : {}),
|
||||
...(file.isDirty ? { dirtyDraftContent: '# draft' } : {})
|
||||
}))
|
||||
}
|
||||
const views: Record<string, LayoutEditorView> = {}
|
||||
for (const file of files) {
|
||||
const view = layoutEditorViewOfOpenFile(file)
|
||||
if (view) {
|
||||
views[tabIdOf(file)] = view
|
||||
}
|
||||
}
|
||||
const windowState = makeState({
|
||||
openFiles: files,
|
||||
unifiedTabsByWorktree: session.unifiedTabs!,
|
||||
groupsByWorktree: session.tabGroups!,
|
||||
layoutByWorktree: session.tabGroupLayouts!,
|
||||
activeGroupIdByWorktree: { [GIT_KEY]: 'group-1' },
|
||||
activeFileId,
|
||||
activeFileIdByWorktree: { [GIT_KEY]: activeFileId },
|
||||
activeTabType: 'editor',
|
||||
activeTabTypeByWorktree: { [GIT_KEY]: 'editor' }
|
||||
})
|
||||
const loaded = loadWorkspaceLayoutWithoutReport(LOCAL_EXECUTION_HOST_ID, session, {
|
||||
...nameBasedLoadContext(),
|
||||
editorViewsOf: (key) => (key === GIT_KEY ? views : undefined)
|
||||
})
|
||||
const main = projectSessionTabs(
|
||||
GIT_KEY,
|
||||
loaded.layout.workspaces[GIT_KEY]!,
|
||||
loaded.desktopView,
|
||||
loaded.facts,
|
||||
false
|
||||
)
|
||||
const window = buildMobileSessionTabSnapshots(windowState).find(
|
||||
(snapshot) => snapshot.worktree === GIT_KEY
|
||||
)!
|
||||
return { main, window }
|
||||
}
|
||||
|
||||
/** What both sides publish, minus what only the runtime stamps. */
|
||||
function published(snapshot: Pick<RuntimeMobileSessionTabsSnapshot, 'tabs' | 'tabGroups'>) {
|
||||
return { tabs: snapshot.tabs, tabGroups: snapshot.tabGroups }
|
||||
}
|
||||
|
||||
describe("main's diff and markdown-preview rows", () => {
|
||||
it.each([
|
||||
['a diff', `${GIT_KEY}::diff::unstaged::src/app.ts`],
|
||||
['a markdown preview', `markdown-preview::${README}`]
|
||||
])("equal the window's rows with %s active", (_label, activeFileId) => {
|
||||
const { main, window } = scenario(activeFileId)
|
||||
// Every file but the combined diff, which neither side lists.
|
||||
expect(window.tabs).toHaveLength(files.length - 1)
|
||||
expect(published(main)).toEqual(published(window))
|
||||
expect(main.activeTabId).toBe(window.activeTabId)
|
||||
expect(main.activeTabType).toBe(window.activeTabType)
|
||||
})
|
||||
|
||||
it('links a preview to its source by the open-file id, owned ids included', () => {
|
||||
const { main } = scenario(`markdown-preview::${README}`)
|
||||
const previews = main.tabs.filter((tab) => tab.type === 'markdown' && tab.mode !== 'edit')
|
||||
expect(
|
||||
previews.map((tab) => tab.type === 'markdown' && [tab.sourceFileId, tab.isDirty])
|
||||
).toEqual([
|
||||
[README, true],
|
||||
[SERVER_DOC_ID, false],
|
||||
['markdown-preview::/Users/dev/orca/CHANGELOG.md', false]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -83,7 +83,9 @@ export function retireStructuredAgentSessionTab(args: {
|
||||
function reacceptHostSessionTabs(
|
||||
target: RuntimeClientTarget,
|
||||
worktreeId: string,
|
||||
intentOwner: WebSessionIntentOwner
|
||||
intentOwner: WebSessionIntentOwner,
|
||||
/** This machine's host still lists the chat. */
|
||||
stillListed?: { sessionId: string; reopen: () => void }
|
||||
): void {
|
||||
// Lazy imports: both refresh paths apply frames through this module.
|
||||
const reread =
|
||||
@@ -97,8 +99,19 @@ function reacceptHostSessionTabs(
|
||||
})
|
||||
)
|
||||
: import('./local-structured-session-tabs-sync/inventory-refresh').then(
|
||||
({ refreshLocalStructuredSessionWorktreeTabs }) =>
|
||||
refreshLocalStructuredSessionWorktreeTabs(worktreeId, { reacceptCurrentVersion: true })
|
||||
async ({ refreshLocalStructuredSessionWorktreeTabs }) => {
|
||||
const listed = await refreshLocalStructuredSessionWorktreeTabs(worktreeId, {
|
||||
reacceptCurrentVersion: true
|
||||
})
|
||||
if (
|
||||
stillListed &&
|
||||
listed?.tabs.some(
|
||||
(tab) => tab.type === 'agent-session' && tab.sessionId === stillListed.sessionId
|
||||
)
|
||||
) {
|
||||
stillListed.reopen()
|
||||
}
|
||||
}
|
||||
)
|
||||
reread.catch((error: unknown) =>
|
||||
console.warn('[structured-agent-session] tab re-read after a close failed', error)
|
||||
@@ -113,6 +126,8 @@ export function beginStructuredAgentSessionTabClose(args: {
|
||||
provisional: boolean
|
||||
layoutOwner: StructuredLaunchSlotOwner
|
||||
onError?: (error: unknown) => void
|
||||
/** Main dropped the tab before the host answered: a host still listing the chat shows it again. */
|
||||
reopen?: () => void
|
||||
}): void {
|
||||
if (args.provisional) {
|
||||
markStructuredAgentSessionLaunchCancelled(
|
||||
@@ -144,7 +159,14 @@ export function beginStructuredAgentSessionTabClose(args: {
|
||||
// needs a read; a paired server's frames travel apart from its answer, so, as for its terminal
|
||||
// closes, every close is followed by a read.
|
||||
if (!hostTabClosed || args.target.kind === 'environment') {
|
||||
reacceptHostSessionTabs(args.target, args.worktreeId, intentOwner)
|
||||
reacceptHostSessionTabs(
|
||||
args.target,
|
||||
args.worktreeId,
|
||||
intentOwner,
|
||||
!hostTabClosed && args.reopen
|
||||
? { sessionId: args.sessionId, reopen: args.reopen }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
markStructuredAgentSessionLaunchCancelled,
|
||||
resetStructuredAgentLaunchRegistryForTests,
|
||||
setStructuredLaunchState,
|
||||
subscribeStructuredAgentLaunchStatus,
|
||||
type StructuredLaunchState
|
||||
} from '@/lib/structured-agent-session-launch-registry'
|
||||
import {
|
||||
@@ -50,6 +49,7 @@ import {
|
||||
resetStructuredAgentLaunchPersistenceForTests,
|
||||
writeStructuredAgentLaunchRecord
|
||||
} from '@/lib/structured-agent-session-launch-persistence'
|
||||
import { wasStructuredAgentLaunchClosedByLocalCancel } from '@/lib/structured-agent-session-local-launch-cancel'
|
||||
import {
|
||||
acceptPairedHostStructuredSessions,
|
||||
beginStructuredAgentSessionTabClose,
|
||||
@@ -184,19 +184,24 @@ describe('this machine, with main owning the layout', () => {
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
it('a main that cannot be told gets the tombstone, so a late listing is still hidden', async () => {
|
||||
// Main notes the close before anything in it can fail, so its fence refuses the late create and
|
||||
// no chat is published to draw (structured-agent-session-close-before-create.test.ts).
|
||||
it('a retire main fails is logged; no tombstone, and a late create reply revives nothing', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.closeSession.mockRejectedValueOnce(new Error('host unavailable'))
|
||||
pendingLaunch('local', LOCAL)
|
||||
const listener = vi.fn()
|
||||
subscribeStructuredAgentLaunchStatus(listener)
|
||||
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local', layoutOwner)
|
||||
await vi.waitFor(() =>
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[structured-agent-session] local cancel retirement failed',
|
||||
expect.any(Error)
|
||||
)
|
||||
)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toEqual([])
|
||||
expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false)
|
||||
expect(wasStructuredAgentLaunchClosedByLocalCancel(SESSION)).toBe(true)
|
||||
expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('switch off: keeps the tombstone and leaves the close to the tab close, as today', () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '../lib/agent-status'
|
||||
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
|
||||
import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers'
|
||||
import {
|
||||
drawTabsFromMain,
|
||||
type TabsDrawnFromMain
|
||||
} from '../store/slices/tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
releaseDocPreviewGrant: vi.fn(),
|
||||
@@ -14,6 +18,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// Main answers the window's layout commands; its frames draw the browser tabs and conversions.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
const DOC_LOCATION = {
|
||||
kind: 'workspace-doc' as const,
|
||||
@@ -21,7 +37,18 @@ const DOC_LOCATION = {
|
||||
filePath: '/home/alice/wt1/report/index.html'
|
||||
}
|
||||
|
||||
function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
/** A layout-owner build draws each open and conversion once main's frame is in. */
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
async function createStoreWithWorktree(): Promise<ReturnType<typeof createTestStore>> {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
@@ -30,6 +57,7 @@ function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
},
|
||||
activeWorktreeId: WORKTREE_ID
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -86,8 +114,8 @@ function publishedUnifiedTabIds(store: ReturnType<typeof createTestStore>): Set<
|
||||
// web page. Driven through the real store actions, not a hand-built state, so the mirror path the
|
||||
// conversion writes is the one the publisher reads.
|
||||
describe('mobile publish across an address-bar conversion', () => {
|
||||
it('starts publishing a doc tab the moment it converts to web, and stops on the way back', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('starts publishing a doc tab the moment it converts to web, and stops on the way back', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
// Presence precondition: an ordinary URL tab publishes throughout, so an empty answer would
|
||||
// fail rather than pass by the publisher being broken for browser tabs entirely.
|
||||
const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
@@ -96,6 +124,7 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
title: 'index.html',
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? ''
|
||||
|
||||
expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id])
|
||||
@@ -105,6 +134,7 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
url: 'https://converted.example/'
|
||||
})
|
||||
expect(webPage).not.toBeNull()
|
||||
await settled()
|
||||
expect(publishedBrowserWorkspaceIds(store).sort()).toEqual([urlTab.id, docTab.id].sort())
|
||||
|
||||
const docPage = store.getState().convertBrowserPage(webPage?.id ?? '', {
|
||||
@@ -112,15 +142,17 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
expect(docPage).not.toBeNull()
|
||||
await settled()
|
||||
expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id])
|
||||
})
|
||||
|
||||
// Why groups and the layout are driven separately: the group projection was the historically
|
||||
// untested half of the publish boundary, and a doc tab surviving in a group's tabOrder names a
|
||||
// tab the phone is never sent.
|
||||
it('holds the doc tab out of published groups and the layout, and admits it on conversion', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('holds the doc tab out of published groups and the layout, and admits it on conversion', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
// The doc tab gets a split group of its own, so holding it back must also take its group out
|
||||
// of the projection AND its leaf out of the layout — the observable no earlier test drove.
|
||||
const sourceGroupId = store.getState().activeGroupIdByWorktree[WORKTREE_ID] ?? ''
|
||||
@@ -133,6 +165,7 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
browserRuntimeEnvironmentId: null,
|
||||
targetGroupId: splitGroupId ?? undefined
|
||||
})
|
||||
await settled()
|
||||
const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? ''
|
||||
const docUnifiedTabId =
|
||||
(store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).find(
|
||||
@@ -163,6 +196,7 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
url: 'https://converted.example/'
|
||||
})
|
||||
expect(webPage).not.toBeNull()
|
||||
await settled()
|
||||
expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(true)
|
||||
expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(true)
|
||||
expect(layoutGroupIds().sort()).toEqual([sourceGroupId, splitGroupId ?? ''].sort())
|
||||
@@ -173,6 +207,7 @@ describe('mobile publish across an address-bar conversion', () => {
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
expect(docPage).not.toBeNull()
|
||||
await settled()
|
||||
expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(false)
|
||||
expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(false)
|
||||
expect(layoutGroupIds()).toEqual([sourceGroupId])
|
||||
|
||||
@@ -6,7 +6,6 @@ import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal
|
||||
import type { AppState } from '@/store/types'
|
||||
import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
|
||||
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
|
||||
|
||||
@@ -71,21 +70,3 @@ export function terminalLayoutsWithFrameBindings(
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Every rebound workspace's bindings shown, and the workspaces only a build shows so. */
|
||||
export function terminalLayoutsWithReboundFrames(
|
||||
layouts: TerminalLayouts,
|
||||
cache: WorkspaceLayoutCache,
|
||||
rebound: ReadonlyMap<string, ReadonlySet<string>>
|
||||
): { layouts: TerminalLayouts; unshown: string[] } {
|
||||
const unshown: string[] = []
|
||||
for (const [key, tabIds] of rebound) {
|
||||
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
|
||||
if (next) {
|
||||
layouts = next
|
||||
} else {
|
||||
unshown.push(key)
|
||||
}
|
||||
}
|
||||
return { layouts, unshown }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '../../../shared/workspace-layout/workspace-layout-beside'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { withPendingTabFacts } from './layout-owner-pending-tab-facts'
|
||||
|
||||
/** What the window's own commands hold as drawn; see buildLayoutFrameStorePatch. */
|
||||
export type LayoutFrameBuildHolds = {
|
||||
@@ -45,7 +46,8 @@ function patchFromSlices(
|
||||
): LayoutFrameBuildPatch {
|
||||
const built = buildLayoutFrameSlices({
|
||||
layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])),
|
||||
facts,
|
||||
// A tab this window asked main to open draws with the window's facts for it.
|
||||
facts: withPendingTabFacts(facts, keys),
|
||||
view
|
||||
})
|
||||
return buildLayoutFrameStorePatch(
|
||||
|
||||
@@ -17,8 +17,6 @@ import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-
|
||||
import { buildWorkspaceSessionPayload } from '../lib/workspace-session'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { recordOpenedAgentSessionTabInLayoutOwner } from './agent-session-tab-layout-owner'
|
||||
import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner'
|
||||
import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
@@ -237,61 +235,7 @@ describe('a tab the window drew, closed or moved, with a frame from before main
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'chat',
|
||||
(store: ReturnType<typeof createTestStore>) => {
|
||||
const tab = drawTab(store, {
|
||||
id: 'agent-session-new',
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab)
|
||||
return tab
|
||||
}
|
||||
],
|
||||
[
|
||||
'simulator',
|
||||
(store: ReturnType<typeof createTestStore>) => {
|
||||
const tab = drawTab(store, { id: 'simulator-new' })
|
||||
recordOpenedSimulatorTabInLayoutOwner(store.getState(), tab)
|
||||
return tab
|
||||
}
|
||||
]
|
||||
])('keeps a just-opened %s tab', async (_kind, open) => {
|
||||
const { store, sync, before } = await boot()
|
||||
const tab = open(store)
|
||||
expect(rpc.calls.length).toBeGreaterThan(0)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(tab.id)
|
||||
expect(groupOrder(store, 'group-b')).toContain(tab.id)
|
||||
if (tab.contentType === 'editor') {
|
||||
expect(store.getState().openFiles.map((file) => file.id)).toContain(tab.entityId)
|
||||
}
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a just-opened browser tab and its page', async () => {
|
||||
const { store, sync, before } = await boot()
|
||||
const created = store.getState().createBrowserTab(GIT_KEY, 'https://example.com/', {
|
||||
title: 'Example'
|
||||
})
|
||||
const entry = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[GIT_KEY]!.find((tab) => tab.entityId === created.id)!
|
||||
expect(rpc.calls.some((call) => call.command.type === 'openBrowserTab')).toBe(true)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(entry.id)
|
||||
expect(store.getState().browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain(
|
||||
created.id
|
||||
)
|
||||
expect(store.getState().browserPagesByWorkspace[created.id]?.length).toBeGreaterThan(0)
|
||||
sync.close()
|
||||
})
|
||||
// Browser, chat and simulator opens draw from main's frame alone: content-tabs-from-main-frames.test.ts.
|
||||
|
||||
it('keeps a reorder the window made, and main’s order for the rest', async () => {
|
||||
const { store, sync, before, answer } = await boot()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness'
|
||||
import { createBrowserMockApi } from './browser-slice-test-harness'
|
||||
import { createEditorTabsStore } from './editor-slice-test-harness'
|
||||
import { createTestStore as createAppStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
@@ -127,12 +127,17 @@ describe('selection in a workspace other than the active one', () => {
|
||||
expect(store.getState().activeFileId).toBe(fileId)
|
||||
})
|
||||
|
||||
it('records a browser selection in its own workspace without moving the global surface', () => {
|
||||
const store = createTestStore()
|
||||
it('records a browser selection in its own workspace without moving the global surface', async () => {
|
||||
const store = createAppStore()
|
||||
seedStore(store, { activeWorktreeId: 'wt-1' })
|
||||
// A layout-owner build draws the tabs from main's frame before they can be selected.
|
||||
const main = await drawTabsFromMain(store)
|
||||
const mainTab = store.getState().createBrowserTab('wt-1', 'https://main.example.com')
|
||||
const floatingTab = store
|
||||
.getState()
|
||||
.createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'https://floating.example.com')
|
||||
await main.settle()
|
||||
main.close()
|
||||
store.getState().setActiveBrowserTab(mainTab.id)
|
||||
store.setState({ activeTabType: 'editor' })
|
||||
|
||||
@@ -145,9 +150,13 @@ describe('selection in a workspace other than the active one', () => {
|
||||
expect(state.activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('browser')
|
||||
})
|
||||
|
||||
it('still moves the global browser surface for the active workspace', () => {
|
||||
const store = createTestStore()
|
||||
it('still moves the global browser surface for the active workspace', async () => {
|
||||
const store = createAppStore()
|
||||
seedStore(store, { activeWorktreeId: 'wt-1' })
|
||||
const main = await drawTabsFromMain(store)
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://main.example.com')
|
||||
await main.settle()
|
||||
main.close()
|
||||
store.setState({ activeTabType: 'editor' })
|
||||
|
||||
store.getState().setActiveBrowserTab(tab.id)
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { flushMicrotasks } from './agent-status-test-harness'
|
||||
import { createTestStore, makeTab, makeUnifiedTab } from './store-test-helpers'
|
||||
import { createTestStore, makeTab, makeTabGroup, makeUnifiedTab } from './store-test-helpers'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { RetainedAgentEntry } from './agent-status'
|
||||
import {
|
||||
@@ -13,10 +13,34 @@ import {
|
||||
} from '../../../../shared/structured-agent-session-projection'
|
||||
import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer'
|
||||
import { createStoreSessionMockApi } from './store-session-test-harness'
|
||||
import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({
|
||||
beginStructuredAgentSessionTabClose: vi.fn()
|
||||
}))
|
||||
// Main answers the window's chat-tab commands and its frames draw them (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
/** Resolves `pending` under fake timers by running the timer ticks main's drain waits on (a 0 ms timer fires at 1 ms). */
|
||||
async function underFakeTimers<T>(pending: Promise<T>): Promise<T> {
|
||||
let done = false
|
||||
void pending.finally(() => {
|
||||
done = true
|
||||
})
|
||||
for (let turn = 0; turn < 50 && !done; turn += 1) {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Track the original global before the shared fixture installs the renderer API.
|
||||
@@ -417,7 +441,7 @@ afterEach(() => {
|
||||
|
||||
it.each(['wt-1', 'folder-workspace-1'])(
|
||||
'preserves an open chat and its read state when an unrelated terminal closes in %s',
|
||||
(worktreeId) => {
|
||||
async (worktreeId) => {
|
||||
vi.useFakeTimers()
|
||||
const store = createTestStore()
|
||||
const sessionId = 'chat-1'
|
||||
@@ -441,7 +465,19 @@ afterEach(() => {
|
||||
makeTab({ id: 'tab-live', worktreeId })
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: { [worktreeId]: [chat] }
|
||||
unifiedTabsByWorktree: { [worktreeId]: [chat] },
|
||||
groupsByWorktree: {
|
||||
[worktreeId]: [
|
||||
makeTabGroup({
|
||||
id: 'group-1',
|
||||
worktreeId,
|
||||
activeTabId: chat.id,
|
||||
tabOrder: [chat.id]
|
||||
})
|
||||
]
|
||||
},
|
||||
layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId: 'group-1' } },
|
||||
activeGroupIdByWorktree: { [worktreeId]: 'group-1' }
|
||||
})
|
||||
for (const key of [paneKey, missingOwnerPaneKey]) {
|
||||
store
|
||||
@@ -459,6 +495,7 @@ afterEach(() => {
|
||||
activityClearedAtByPaneKey: { [paneKey]: 100, [missingOwnerPaneKey]: 100 },
|
||||
manuallyUnreadTurnsByPaneKey: { [paneKey]: 200, [missingOwnerPaneKey]: 200 }
|
||||
})
|
||||
const main = await underFakeTimers(drawTabsFromMain(store))
|
||||
const before = store.getState()
|
||||
|
||||
store.getState().closeTab('tab-closed')
|
||||
@@ -476,7 +513,9 @@ afterEach(() => {
|
||||
expect(after.manuallyUnreadTurnsByPaneKey[missingOwnerPaneKey]).toBeUndefined()
|
||||
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await underFakeTimers(main.settle())
|
||||
store.getState().closeTab('tab-live')
|
||||
main.close()
|
||||
|
||||
const closed = store.getState()
|
||||
expect(closed.unifiedTabsByWorktree[worktreeId]).toEqual([])
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GRAB_BUDGET } from '../../../../shared/browser-grab-types'
|
||||
import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createBrowserMockApi } from './browser-slice-test-harness'
|
||||
import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture'
|
||||
import { makeAnnotation } from './browser-annotation-test-fixture'
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: vi.fn()
|
||||
}))
|
||||
|
||||
function createAnnotatedPage() {
|
||||
async function createAnnotatedPage() {
|
||||
vi.stubGlobal('window', { api: createBrowserMockApi(vi.fn()) })
|
||||
const store = createTestStore()
|
||||
store.setState({ repos: [], folderWorkspaces: [] })
|
||||
const { store, main } = await createTestStoreDrawnFromMain({ repos: [], folderWorkspaces: [] })
|
||||
const workspace = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = workspace.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected browser page')
|
||||
}
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId))
|
||||
return { store, workspace, pageId }
|
||||
return { store, main, workspace, pageId }
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('saved browser note lifetime and marker eligibility', () => {
|
||||
it('retires same-URL geometry without changing the saved array or object references', () => {
|
||||
const { store, pageId } = createAnnotatedPage()
|
||||
it('retires same-URL geometry without changing the saved array or object references', async () => {
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
const saved = store.getState().browserAnnotationsByPageId[pageId]
|
||||
const note = saved[0]
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id])
|
||||
@@ -47,8 +60,8 @@ describe('saved browser note lifetime and marker eligibility', () => {
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', () => {
|
||||
const { store, pageId } = createAnnotatedPage()
|
||||
it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', async () => {
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
for (let index = 0; index < GRAB_BUDGET.annotationsMaxPerPage + 3; index++) {
|
||||
const url = `https://example.com/page-${index % 2}`
|
||||
store.getState().setBrowserPageUrl(pageId, url)
|
||||
@@ -70,8 +83,8 @@ describe('saved browser note lifetime and marker eligibility', () => {
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prunes eligible IDs when the oldest current-document notes are evicted', () => {
|
||||
const { store, pageId } = createAnnotatedPage()
|
||||
it('prunes eligible IDs when the oldest current-document notes are evicted', async () => {
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
for (let index = 0; index < 22; index++) {
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, `note-${index}`))
|
||||
}
|
||||
@@ -82,7 +95,7 @@ describe('saved browser note lifetime and marker eligibility', () => {
|
||||
})
|
||||
|
||||
it('removes an accepted delivery snapshot after navigation while preserving later edits and additions', async () => {
|
||||
const { store, pageId } = createAnnotatedPage()
|
||||
const { store, pageId } = await createAnnotatedPage()
|
||||
store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'edited'))
|
||||
const delivered = store.getState().browserAnnotationsByPageId[pageId]
|
||||
let accept!: () => void
|
||||
@@ -110,37 +123,44 @@ describe('saved browser note lifetime and marker eligibility', () => {
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['page', 'workspace', 'conversion', 'hydration'] as const)(
|
||||
'cleans up note and marker authority on %s teardown',
|
||||
(mode) => {
|
||||
const { store, workspace, pageId } = createAnnotatedPage()
|
||||
if (mode === 'page') {
|
||||
store.getState().closeBrowserPage(pageId)
|
||||
}
|
||||
if (mode === 'workspace') {
|
||||
store.getState().closeBrowserTab(workspace.id)
|
||||
}
|
||||
if (mode === 'conversion') {
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: '/workspace/index.html'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mode === 'hydration') {
|
||||
store.getState().hydrateBrowserSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
}
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined()
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined()
|
||||
it.each([
|
||||
['page', 'closeBrowserPage'],
|
||||
['workspace', 'closeTabs'],
|
||||
['conversion', 'replaceBrowserPage'],
|
||||
['hydration', null]
|
||||
] as const)('cleans up note and marker authority on %s teardown', async (mode, mainCommand) => {
|
||||
const { store, main, workspace, pageId } = await createAnnotatedPage()
|
||||
if (mode === 'page') {
|
||||
store.getState().closeBrowserPage(pageId)
|
||||
}
|
||||
)
|
||||
if (mode === 'workspace') {
|
||||
store.getState().closeBrowserTab(workspace.id)
|
||||
}
|
||||
if (mode === 'conversion') {
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: '/workspace/index.html'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mode === 'hydration') {
|
||||
store.getState().hydrateBrowserSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
}
|
||||
await main.settle()
|
||||
// A layout-owner build's teardown is main's, drawn from its frame.
|
||||
expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? ['openBrowserTab', ...(mainCommand ? [mainCommand] : [])] : []
|
||||
)
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined()
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import {
|
||||
layoutFrameApplyObserver,
|
||||
observeLayoutFrameApply
|
||||
} from '@/runtime/workspace-layout-frame-apply-observer'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mockCallRuntimeRpc = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -20,32 +25,53 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// Main answers the window's layout commands; the paired runtime's calls reach the mock.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof RuntimeRpcClientModule>()
|
||||
return { ...actual, callRuntimeRpc: mockCallRuntimeRpc }
|
||||
const { runtimeRpcClientWithLayoutApply } =
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
return runtimeRpcClientWithLayoutApply({ ...actual, callRuntimeRpc: mockCallRuntimeRpc })
|
||||
})
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
/** Seeds a worktree whose only tab is a remote-backed browser workspace. */
|
||||
function storeWithOnlyBrowserTab(): {
|
||||
async function storeWithOnlyBrowserTab(): Promise<{
|
||||
store: ReturnType<typeof createTestStore>
|
||||
workspaceId: string
|
||||
pageId: string
|
||||
} {
|
||||
}> {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT,
|
||||
activeTabType: 'terminal'
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
const workspace = store.getState().createBrowserTab(WT, 'about:blank', {
|
||||
activate: true,
|
||||
browserPageId: 'remote-page-1',
|
||||
browserRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? ''
|
||||
store.getState().setRemoteBrowserPageHandle(pageId, {
|
||||
environmentId: 'env-1',
|
||||
@@ -56,20 +82,22 @@ function storeWithOnlyBrowserTab(): {
|
||||
}
|
||||
|
||||
/** Three browser tabs in a known tab-bar order, so neighbor selection is deterministic. */
|
||||
function storeWithThreeBrowserTabs(): {
|
||||
async function storeWithThreeBrowserTabs(): Promise<{
|
||||
store: ReturnType<typeof createTestStore>
|
||||
ids: string[]
|
||||
} {
|
||||
}> {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT,
|
||||
activeTabType: 'terminal'
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
const ids = [0, 1, 2].map(
|
||||
(index) =>
|
||||
store.getState().createBrowserTab(WT, `https://example.test/${index}`, { activate: true }).id
|
||||
)
|
||||
await settled()
|
||||
return { store, ids }
|
||||
}
|
||||
|
||||
@@ -81,12 +109,14 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
|
||||
// Why: a failed create must land the user where the click did — the pre-click worktree — not on
|
||||
// the landing screen the real "you closed your last tab" path deliberately falls back to.
|
||||
it('keeps the worktree active when it unwinds the only tab in that worktree', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('keeps the worktree active when it unwinds the only tab in that worktree', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
expect(store.getState().activeWorktreeId).toBe(WT)
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
|
||||
await settled()
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.activeWorktreeId).toBe(WT)
|
||||
expect(s.browserTabsByWorktree[WT] ?? []).toEqual([])
|
||||
@@ -95,29 +125,36 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
|
||||
// Why: the landing-screen fallback is the correct answer for a real close, so pin that the
|
||||
// cleanup carve-out did not disable it for everyone.
|
||||
it('still returns to the landing state when the user closes the only tab', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('still returns to the landing state when the user closes the only tab', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId)
|
||||
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
})
|
||||
|
||||
// Why: the staged page's host tab was never minted, so a tabClose would either 404 or, worse,
|
||||
// race the in-flight create and kill the page the host is about to hand back.
|
||||
it('does not ask the host to close the page it never published', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('does not ask the host to close the page it never published', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
|
||||
await settled()
|
||||
|
||||
// A layout-owner build's close goes to main as `layout.apply`, answered before this mock.
|
||||
expect(mockCallRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks the host to close the page on a real close', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('asks the host to close the page on a real close', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId)
|
||||
|
||||
await settled()
|
||||
|
||||
expect(mockCallRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'env-1' },
|
||||
'browser.tabClose',
|
||||
@@ -128,21 +165,25 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
|
||||
// Why: reopen-stack entries are the user's undo history; a tab that never existed must not
|
||||
// occupy a slot, and reopening it would resurrect a workspace with no host page behind it.
|
||||
it('does not enter the reopen stack', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('does not enter the reopen stack', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
|
||||
await settled()
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([])
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('enters the reopen stack on a real close', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('enters the reopen stack on a real close', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId)
|
||||
|
||||
await settled()
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.recentlyClosedBrowserTabsByWorktree[WT]).toHaveLength(1)
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT]).toEqual(['browser'])
|
||||
@@ -150,36 +191,56 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
|
||||
// Why: feature-interaction counters drive onboarding nudges; an unwound create is not the user
|
||||
// exercising tabs.
|
||||
it('does not record a tab interaction', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('does not record a tab interaction', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
const recordFeatureInteraction = vi.fn()
|
||||
store.setState({ recordFeatureInteraction })
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
|
||||
await settled()
|
||||
|
||||
expect(recordFeatureInteraction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a tab interaction on a real close', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('records a tab interaction on a real close', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
const recordFeatureInteraction = vi.fn()
|
||||
store.setState({ recordFeatureInteraction })
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId)
|
||||
|
||||
await settled()
|
||||
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('terminal-tabs')
|
||||
})
|
||||
it('clears focus for closed pages without rescanning pages for unrelated focus entries', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('clears focus for closed pages without rescanning pages for unrelated focus entries', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
const original = store.getState().browserPagesByWorkspace[workspaceId][0]
|
||||
let idReads = 0
|
||||
let counting = true
|
||||
const pages = Array.from({ length: 1000 }, (_, index) => ({
|
||||
...original,
|
||||
get id() {
|
||||
idReads++
|
||||
if (counting) {
|
||||
idReads++
|
||||
}
|
||||
return `closed-${index}`
|
||||
}
|
||||
}))
|
||||
// The test transport's frame-apply check rebuilds every flush; its reads are not the close's.
|
||||
const check = layoutFrameApplyObserver()
|
||||
observeLayoutFrameApply(
|
||||
check &&
|
||||
((observation) => {
|
||||
counting = false
|
||||
try {
|
||||
check(observation)
|
||||
} finally {
|
||||
counting = true
|
||||
}
|
||||
})
|
||||
)
|
||||
const unrelated = Object.fromEntries(
|
||||
Array.from({ length: 1000 }, (_, i) => [`other-${i}`, true as const])
|
||||
)
|
||||
@@ -189,7 +250,12 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
pendingAddressBarFocusByTabId: { ...unrelated, 'closed-999': true, [workspaceId]: true }
|
||||
})
|
||||
idReads = 0
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
try {
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
await settled()
|
||||
} finally {
|
||||
observeLayoutFrameApply(check)
|
||||
}
|
||||
expect(store.getState().pendingAddressBarFocusByPageId).toEqual({
|
||||
...unrelated,
|
||||
[workspaceId]: true
|
||||
@@ -199,8 +265,8 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
})
|
||||
|
||||
// Why: the omit path must not hand every pending-focus selector a fresh record on each close.
|
||||
it('keeps the pending focus records identical when the closed tab had no pending entry', () => {
|
||||
const { store, workspaceId } = storeWithOnlyBrowserTab()
|
||||
it('keeps the pending focus records identical when the closed tab had no pending entry', async () => {
|
||||
const { store, workspaceId } = await storeWithOnlyBrowserTab()
|
||||
const pending = { 'other-tab': true as const }
|
||||
store.setState({
|
||||
pendingAddressBarFocusByPageId: pending,
|
||||
@@ -209,18 +275,22 @@ describe('closeBrowserTab with reason cleanup', () => {
|
||||
|
||||
store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' })
|
||||
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByPageId).toBe(pending)
|
||||
expect(store.getState().pendingAddressBarFocusByTabId).toBe(pending)
|
||||
})
|
||||
|
||||
// Why: closing a tab the user is not looking at must never move focus, whatever the
|
||||
// pending-address-bar bookkeeping does around it.
|
||||
it('leaves the active browser tab alone when a background tab closes', () => {
|
||||
const { store, ids } = storeWithThreeBrowserTabs()
|
||||
it('leaves the active browser tab alone when a background tab closes', async () => {
|
||||
const { store, ids } = await storeWithThreeBrowserTabs()
|
||||
store.getState().setActiveBrowserTab(ids[0]!)
|
||||
|
||||
store.getState().closeBrowserTab(ids[2]!)
|
||||
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(ids[0]!)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// A layout-owner build's browser tab and page commands: the window sends main each change under
|
||||
// the ids it proposes, and draws it from main's frame (content-tabs-from-main-frames.test.ts
|
||||
// covers what shows before and after main answers).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ send: vi.fn<typeof recordLayoutApply>() }))
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
@@ -20,122 +31,120 @@ createStoreCascadesMockApi()
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
|
||||
function seeded(): ReturnType<typeof createTestStore> {
|
||||
const store = createTestStore()
|
||||
let store: ReturnType<typeof createTestStore>
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
beforeEach(async () => {
|
||||
store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT
|
||||
})
|
||||
return store
|
||||
}
|
||||
main = await drawTabsFromMain(store)
|
||||
})
|
||||
|
||||
const sent = () => mocks.send.mock.calls.map(([, worktreeId, command]) => ({ worktreeId, command }))
|
||||
afterEach(() => {
|
||||
main.close()
|
||||
})
|
||||
|
||||
function entryOf(store: ReturnType<typeof createTestStore>, browserTabId: string) {
|
||||
const sent = () => [...layoutApplyTestTransport.sent]
|
||||
|
||||
function entryOf(browserTabId: string) {
|
||||
return store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT]!.find((entry) => entry.entityId === browserTabId)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.send.mockReset()
|
||||
})
|
||||
|
||||
describe('browser tabs in a layout-owner build', () => {
|
||||
it('records an open under the ids and place the window drew it with', () => {
|
||||
const store = seeded()
|
||||
it('sends an open under the ids it proposes, which main’s frame then draws', async () => {
|
||||
const first = store.getState().createBrowserTab(WT, 'about:blank', { sessionProfileId: 'p1' })
|
||||
const second = store.getState().createBrowserTab(WT, 'https://example.com/')
|
||||
const firstEntry = entryOf(store, first.id)
|
||||
const [firstOpen, secondOpen] = layoutApplyTestTransport.sentOfType('openBrowserTab')
|
||||
await main.settle()
|
||||
|
||||
expect(sent()).toEqual([
|
||||
{
|
||||
worktreeId: WT,
|
||||
command: {
|
||||
type: 'openBrowserTab',
|
||||
tabId: firstEntry.id,
|
||||
browserTabId: first.id,
|
||||
pageId: first.pageIds![0],
|
||||
profileId: 'p1',
|
||||
sessionPartition: null,
|
||||
groupId: firstEntry.groupId,
|
||||
index: 0,
|
||||
createdAt: firstEntry.createdAt,
|
||||
browserCreatedAt: first.createdAt
|
||||
}
|
||||
},
|
||||
{
|
||||
worktreeId: WT,
|
||||
command: expect.objectContaining({
|
||||
type: 'openBrowserTab',
|
||||
tabId: entryOf(store, second.id).id,
|
||||
browserTabId: second.id,
|
||||
afterTabId: firstEntry.id
|
||||
})
|
||||
}
|
||||
const firstEntry = entryOf(first.id)
|
||||
expect(firstOpen).toEqual({
|
||||
type: 'openBrowserTab',
|
||||
workspace: WT,
|
||||
tabId: firstEntry.id,
|
||||
browserTabId: first.id,
|
||||
pageId: store.getState().browserPagesByWorkspace[first.id]![0]!.id,
|
||||
profileId: 'p1',
|
||||
sessionPartition: null,
|
||||
groupId: firstEntry.groupId
|
||||
})
|
||||
expect(secondOpen).toMatchObject({ tabId: entryOf(second.id).id, browserTabId: second.id })
|
||||
expect(store.getState().groupsByWorktree[WT]![0]!.tabOrder).toEqual([
|
||||
firstEntry.id,
|
||||
entryOf(second.id).id
|
||||
])
|
||||
})
|
||||
|
||||
it('records a close with the window’s reason while the tab-bar entry still names it', () => {
|
||||
const store = seeded()
|
||||
it('sends a close with the window’s reason while the tab still shows', async () => {
|
||||
const user = store.getState().createBrowserTab(WT, 'about:blank')
|
||||
const cleanup = store.getState().createBrowserTab(WT, 'about:blank')
|
||||
const userEntry = entryOf(store, user.id).id
|
||||
const cleanupEntry = entryOf(store, cleanup.id).id
|
||||
mocks.send.mockClear()
|
||||
await main.settle()
|
||||
const userEntry = entryOf(user.id).id
|
||||
const cleanupEntry = entryOf(cleanup.id).id
|
||||
const before = sent().length
|
||||
|
||||
store.getState().closeBrowserTab(user.id)
|
||||
store.getState().closeBrowserTab(cleanup.id, { reason: 'cleanup' })
|
||||
await main.settle()
|
||||
|
||||
expect(sent().map(({ command }) => command)).toEqual([
|
||||
{ type: 'closeTabs', tabIds: [userEntry], recordClose: 'user' },
|
||||
{ type: 'closeTabs', tabIds: [cleanupEntry], recordClose: 'cleanup' }
|
||||
expect(sent().slice(before)).toEqual([
|
||||
{ type: 'closeTabs', workspace: WT, tabIds: [userEntry], recordClose: 'user' },
|
||||
{ type: 'closeTabs', workspace: WT, tabIds: [cleanupEntry], recordClose: 'cleanup' }
|
||||
])
|
||||
})
|
||||
|
||||
it('records page create, convert and close, and a profile switch', () => {
|
||||
const store = seeded()
|
||||
it('sends page create, convert and close, and a profile switch', async () => {
|
||||
const tab = store.getState().createBrowserTab(WT, 'about:blank')
|
||||
mocks.send.mockClear()
|
||||
await main.settle()
|
||||
const before = sent().length
|
||||
|
||||
// A conversion changes a page's kind: this document page becomes a web page.
|
||||
const page = store.getState().createBrowserPage(tab.id, 'about:blank', {
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: WT, filePath: '/path/wt1/index.html' }
|
||||
})!
|
||||
await main.settle()
|
||||
const converted = store.getState().convertBrowserPage(page.id, {
|
||||
kind: 'web',
|
||||
url: 'https://other.example/'
|
||||
})!
|
||||
await main.settle()
|
||||
store.getState().closeBrowserPage(converted.id)
|
||||
await main.settle()
|
||||
store.getState().switchBrowserTabProfile(tab.id, 'p2', 'persist:p2')
|
||||
await main.settle()
|
||||
|
||||
expect(sent().map(({ command }) => command)).toEqual([
|
||||
{ type: 'openBrowserPage', browserTabId: tab.id, pageId: page.id },
|
||||
expect(sent().slice(before)).toEqual([
|
||||
{ type: 'openBrowserPage', workspace: WT, browserTabId: tab.id, pageId: page.id },
|
||||
{
|
||||
type: 'replaceBrowserPage',
|
||||
workspace: WT,
|
||||
browserTabId: tab.id,
|
||||
pageId: page.id,
|
||||
newPageId: converted.id
|
||||
},
|
||||
{ type: 'closeBrowserPage', browserTabId: tab.id, pageId: converted.id },
|
||||
{ type: 'closeBrowserPage', workspace: WT, browserTabId: tab.id, pageId: converted.id },
|
||||
{
|
||||
type: 'setBrowserTabProfile',
|
||||
workspace: WT,
|
||||
browserTabId: tab.id,
|
||||
profileId: 'p2',
|
||||
sessionPartition: 'persist:p2'
|
||||
}
|
||||
])
|
||||
expect(sent().every(({ worktreeId }) => worktreeId === WT)).toBe(true)
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends nothing for a page or tab the window does not hold', () => {
|
||||
const store = seeded()
|
||||
|
||||
expect(store.getState().createBrowserPage('missing', 'about:blank')).toBeNull()
|
||||
store.getState().closeBrowserPage('missing')
|
||||
store.getState().closeBrowserTab('missing')
|
||||
store.getState().switchBrowserTabProfile('missing', null)
|
||||
|
||||
expect(mocks.send).not.toHaveBeenCalled()
|
||||
expect(sent()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* leaves the store. A bulk worktree purge used to drop the page records without releasing it.
|
||||
* The sweep is the same function useTerminalWindowLifecycle runs on every store change.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
@@ -27,6 +28,18 @@ vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOr
|
||||
destroyPersistentWebview: vi.fn(() => Promise.resolve())
|
||||
}))
|
||||
|
||||
// Main answers the window's layout commands; its frames draw the browser tabs.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
const { isBrowserPageMountAdmitted } =
|
||||
@@ -36,13 +49,21 @@ const { destroyWebviewsRemovedFromStore, destroyWorktreeBrowserGuests } =
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
|
||||
function storeWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
async function storeWithWorktree(): Promise<ReturnType<typeof createTestStore>> {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT,
|
||||
activeTabType: 'terminal'
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -58,15 +79,18 @@ function sweepRemovedPages(
|
||||
)
|
||||
}
|
||||
|
||||
function openBackgroundTabs(store: ReturnType<typeof createTestStore>, count: number): string[] {
|
||||
const pageIds: string[] = []
|
||||
async function openBackgroundTabs(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
count: number
|
||||
): Promise<string[]> {
|
||||
const workspaceIds: string[] = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, `https://example.com/${index}`, { activate: false })
|
||||
pageIds.push(store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? '')
|
||||
workspaceIds.push(
|
||||
store.getState().createBrowserTab(WT, `https://example.com/${index}`, { activate: false }).id
|
||||
)
|
||||
}
|
||||
return pageIds
|
||||
await settled()
|
||||
return workspaceIds.map((id) => store.getState().browserPagesByWorkspace[id]?.[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const admittedCount = (pageIds: readonly string[]): number =>
|
||||
@@ -76,22 +100,27 @@ describe('browser page mount admission ends when the page leaves the store', ()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
it('ends on a bulk worktree purge', () => {
|
||||
const store = storeWithWorktree()
|
||||
const pageIds = openBackgroundTabs(store, 5)
|
||||
it('ends on a bulk worktree purge', async () => {
|
||||
const store = await storeWithWorktree()
|
||||
const pageIds = await openBackgroundTabs(store, 5)
|
||||
const before = sweepRemovedPages(store, new Set())
|
||||
expect(admittedCount(pageIds)).toBe(5)
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT])
|
||||
await settled()
|
||||
sweepRemovedPages(store, before)
|
||||
|
||||
expect(admittedCount(pageIds)).toBe(0)
|
||||
})
|
||||
|
||||
it('ends when the guest is evicted, though the page stays in the store', () => {
|
||||
const store = storeWithWorktree()
|
||||
const pageIds = openBackgroundTabs(store, 3)
|
||||
it('ends when the guest is evicted, though the page stays in the store', async () => {
|
||||
const store = await storeWithWorktree()
|
||||
const pageIds = await openBackgroundTabs(store, 3)
|
||||
const state = store.getState()
|
||||
expect(admittedCount(pageIds)).toBe(3)
|
||||
|
||||
@@ -101,19 +130,22 @@ describe('browser page mount admission ends when the page leaves the store', ()
|
||||
expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('ends when the tab or the page is closed', () => {
|
||||
const store = storeWithWorktree()
|
||||
const [closedTabPage, keptTabPage] = openBackgroundTabs(store, 2)
|
||||
it('ends when the tab or the page is closed', async () => {
|
||||
const store = await storeWithWorktree()
|
||||
const [closedTabPage, keptTabPage] = await openBackgroundTabs(store, 2)
|
||||
const keptWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[1]?.id ?? ''
|
||||
const extraPage = store.getState().createBrowserPage(keptWorkspaceId, 'https://example.com/x', {
|
||||
activate: false
|
||||
})
|
||||
await settled()
|
||||
let seen = sweepRemovedPages(store, new Set())
|
||||
const closedWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().closeBrowserTab(closedWorkspaceId)
|
||||
await settled()
|
||||
seen = sweepRemovedPages(store, seen)
|
||||
store.getState().closeBrowserPage(extraPage?.id ?? '')
|
||||
await settled()
|
||||
sweepRemovedPages(store, seen)
|
||||
|
||||
expect(isBrowserPageMountAdmitted(closedTabPage)).toBe(false)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createTestStore, makeWorktree } from './store-test-helpers'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
@@ -13,16 +14,34 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<object>()
|
||||
return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc }
|
||||
})
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) =>
|
||||
method === 'layout.apply' ? layoutApplyTestTransport.call(params) : undefined
|
||||
)
|
||||
/** The paired runtime's answer to the page close; main answers the window's layout commands. */
|
||||
function runtimeAnswers(answer: () => Promise<unknown> | undefined): void {
|
||||
mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) =>
|
||||
method === 'layout.apply' ? layoutApplyTestTransport.call(params) : answer()
|
||||
)
|
||||
}
|
||||
runtimeAnswers(() => undefined)
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
/** Calls that reached the paired runtime, not main's layout. */
|
||||
const runtimeCalls = () =>
|
||||
mocks.callRuntimeRpc.mock.calls.filter(([, method]) => method !== 'layout.apply')
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
const DOC_LOCATION = {
|
||||
@@ -31,10 +50,10 @@ const DOC_LOCATION = {
|
||||
filePath: '/home/alice/wt1/report/index.html'
|
||||
}
|
||||
|
||||
function createStoreWithClientHostedPage(handleOverrides: Record<string, unknown> = {}): {
|
||||
store: ReturnType<typeof createTestStore>
|
||||
pageId: string
|
||||
} {
|
||||
async function createStoreWithClientHostedPage(
|
||||
handleOverrides: Record<string, unknown> = {}
|
||||
): Promise<{ store: ReturnType<typeof createTestStore>; pageId: string }> {
|
||||
runtimeAnswers(() => undefined)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
@@ -43,7 +62,11 @@ function createStoreWithClientHostedPage(handleOverrides: Record<string, unknown
|
||||
},
|
||||
activeWorktreeId: WORKTREE_ID
|
||||
})
|
||||
// A layout-owner build draws the tab and its pages from main's frames.
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(store)
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/')
|
||||
await main.settle()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
store.setState((s) => ({
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
@@ -72,8 +95,8 @@ function recordedIntents(store: ReturnType<typeof createTestStore>): string[] {
|
||||
// so the reconnect replay can deliver the close late instead of the page coming back.
|
||||
describe('durable close intents for unheard client-hosted page closes', () => {
|
||||
it('records an intent when the conversion-away close never reaches the runtime', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage()
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable'))
|
||||
const { store, pageId } = await createStoreWithClientHostedPage()
|
||||
runtimeAnswers(() => Promise.reject(new Error('runtime unreachable')))
|
||||
|
||||
const converted = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
@@ -85,8 +108,8 @@ describe('durable close intents for unheard client-hosted page closes', () => {
|
||||
})
|
||||
|
||||
it('records an intent when the plain page close never reaches the runtime', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage()
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable'))
|
||||
const { store, pageId } = await createStoreWithClientHostedPage()
|
||||
runtimeAnswers(() => Promise.reject(new Error('runtime unreachable')))
|
||||
|
||||
store.getState().closeBrowserPage(pageId)
|
||||
|
||||
@@ -94,57 +117,61 @@ describe('durable close intents for unheard client-hosted page closes', () => {
|
||||
})
|
||||
|
||||
it('records nothing when the close succeeds', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage()
|
||||
mocks.callRuntimeRpc.mockResolvedValue({})
|
||||
const { store, pageId } = await createStoreWithClientHostedPage()
|
||||
runtimeAnswers(() => Promise.resolve({}))
|
||||
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
|
||||
await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0))
|
||||
await Promise.resolve()
|
||||
expect(recordedIntents(store)).toEqual([])
|
||||
})
|
||||
|
||||
it('records nothing when the runtime has definitively forgotten the page', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage()
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('browser_tab_not_found'))
|
||||
const { store, pageId } = await createStoreWithClientHostedPage()
|
||||
runtimeAnswers(() => Promise.reject(new Error('browser_tab_not_found')))
|
||||
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
|
||||
await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0))
|
||||
await Promise.resolve()
|
||||
expect(recordedIntents(store)).toEqual([])
|
||||
})
|
||||
|
||||
it('records nothing for a server-placed page, which dies with its runtime', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage({ placement: { kind: 'server' } })
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable'))
|
||||
const { store, pageId } = await createStoreWithClientHostedPage({
|
||||
placement: { kind: 'server' }
|
||||
})
|
||||
runtimeAnswers(() => Promise.reject(new Error('runtime unreachable')))
|
||||
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
|
||||
await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0))
|
||||
await Promise.resolve()
|
||||
expect(recordedIntents(store)).toEqual([])
|
||||
})
|
||||
|
||||
it('records nothing for a staged page the runtime never minted', async () => {
|
||||
const { store, pageId } = createStoreWithClientHostedPage({ staged: true })
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable'))
|
||||
const { store, pageId } = await createStoreWithClientHostedPage({ staged: true })
|
||||
// A staged page is left out of the window's save, so frames would redraw it from main's copy.
|
||||
main?.close()
|
||||
runtimeAnswers(() => Promise.reject(new Error('runtime unreachable')))
|
||||
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
|
||||
await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0))
|
||||
await Promise.resolve()
|
||||
expect(recordedIntents(store)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { browserPageSchema } from '../../../../shared/workspace-session-browser-schema'
|
||||
import { createTestStore, makeWorktree } from './store-test-helpers'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn(), callRuntimeRpc: vi.fn() }))
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
@@ -10,10 +12,21 @@ vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
ensureDocPreviewGrant: vi.fn(),
|
||||
buildDocPreviewGrantRequest: vi.fn()
|
||||
}))
|
||||
// Main answers the window's layout commands (switch-on); every other call reaches the mock.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<object>()
|
||||
return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) }
|
||||
const actual = await importOriginal<typeof RuntimeRpcClientModule>()
|
||||
const { runtimeRpcClientWithLayoutApply } =
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
return runtimeRpcClientWithLayoutApply({
|
||||
...actual,
|
||||
callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({})
|
||||
})
|
||||
})
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
@@ -33,7 +46,18 @@ const OTHER_DOC_LOCATION = {
|
||||
}
|
||||
const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html`
|
||||
|
||||
function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
/** A store showing one worktree, its tabs drawn from main once the switch is on. */
|
||||
async function createStoreWithWorktree(): Promise<ReturnType<typeof createTestStore>> {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
@@ -42,31 +66,34 @@ function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
},
|
||||
activeWorktreeId: WORKTREE_ID
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
return store
|
||||
}
|
||||
|
||||
function createDocTab(store: ReturnType<typeof createTestStore>): {
|
||||
async function createDocTab(store: ReturnType<typeof createTestStore>): Promise<{
|
||||
tabId: string
|
||||
pageId: string
|
||||
} {
|
||||
}> {
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
title: 'index.html',
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
return { tabId: tab.id, pageId }
|
||||
}
|
||||
|
||||
describe('convertBrowserPage doc→web', () => {
|
||||
it('replaces the page under a fresh id in the same workspace row', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId } = createDocTab(store)
|
||||
it('replaces the page under a fresh id in the same workspace row', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId } = await createDocTab(store)
|
||||
|
||||
const converted = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'web',
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(converted).not.toBeNull()
|
||||
// Fresh id: the old id must never resurface in the other registry half.
|
||||
@@ -85,22 +112,24 @@ describe('convertBrowserPage doc→web', () => {
|
||||
expect(workspace?.activePageId).toBe(converted?.id)
|
||||
})
|
||||
|
||||
it('releases the old grant exactly once, after the store stops naming the document', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { pageId } = createDocTab(store)
|
||||
it('releases the old grant exactly once, after the store stops naming the document', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { pageId } = await createDocTab(store)
|
||||
mocks.releaseDocPreviewGrant.mockClear()
|
||||
|
||||
store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' })
|
||||
await settled()
|
||||
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId)
|
||||
})
|
||||
|
||||
it('records one level of provenance so Back can return to the document', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId } = createDocTab(store)
|
||||
it('records one level of provenance so Back can return to the document', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId } = await createDocTab(store)
|
||||
|
||||
store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' })
|
||||
await settled()
|
||||
|
||||
const page = store.getState().browserPagesByWorkspace[tabId]?.[0]
|
||||
expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION })
|
||||
@@ -109,14 +138,16 @@ describe('convertBrowserPage doc→web', () => {
|
||||
expect(parsed.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION })
|
||||
})
|
||||
|
||||
it('refuses to convert a web page to web — that is navigation, not conversion', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('refuses to convert a web page to web — that is navigation, not conversion', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
const converted = store
|
||||
.getState()
|
||||
.convertBrowserPage(pageId, { kind: 'web', url: 'https://other.example/' })
|
||||
await settled()
|
||||
|
||||
expect(converted).toBeNull()
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id).toBe(pageId)
|
||||
@@ -125,9 +156,10 @@ describe('convertBrowserPage doc→web', () => {
|
||||
})
|
||||
|
||||
describe('convertBrowserPage web→doc', () => {
|
||||
it('replaces the page with a client-local document page and mirrors it', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('replaces the page with a client-local document page and mirrors it', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
mocks.releaseDocPreviewGrant.mockClear()
|
||||
|
||||
@@ -135,6 +167,7 @@ describe('convertBrowserPage web→doc', () => {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(converted).not.toBeNull()
|
||||
expect(converted?.id).not.toBe(pageId)
|
||||
@@ -151,15 +184,17 @@ describe('convertBrowserPage web→doc', () => {
|
||||
expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never lets a grant url into the converted page, even handed one directly', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('never lets a grant url into the converted page, even handed one directly', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
|
||||
const page = store.getState().browserPagesByWorkspace[tab.id]?.[0]
|
||||
const written = JSON.stringify([page, store.getState().browserTabsByWorktree[WORKTREE_ID]])
|
||||
@@ -168,15 +203,16 @@ describe('convertBrowserPage web→doc', () => {
|
||||
})
|
||||
|
||||
describe('convertBrowserPage doc→doc retarget', () => {
|
||||
it('replaces the page and its grant for a different document', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId } = createDocTab(store)
|
||||
it('replaces the page and its grant for a different document', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId } = await createDocTab(store)
|
||||
mocks.releaseDocPreviewGrant.mockClear()
|
||||
|
||||
const converted = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: OTHER_DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(converted?.id).not.toBe(pageId)
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId)
|
||||
@@ -185,15 +221,16 @@ describe('convertBrowserPage doc→doc retarget', () => {
|
||||
expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION })
|
||||
})
|
||||
|
||||
it('is a no-op for the same document', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId } = createDocTab(store)
|
||||
it('is a no-op for the same document', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId } = await createDocTab(store)
|
||||
mocks.releaseDocPreviewGrant.mockClear()
|
||||
|
||||
const converted = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(converted).toBeNull()
|
||||
expect(store.getState().browserPagesByWorkspace[tabId]?.[0]?.id).toBe(pageId)
|
||||
@@ -202,14 +239,15 @@ describe('convertBrowserPage doc→doc retarget', () => {
|
||||
})
|
||||
|
||||
describe('convertBrowserPage history legs', () => {
|
||||
it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId } = createDocTab(store)
|
||||
it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId } = await createDocTab(store)
|
||||
|
||||
const webPage = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'web',
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
await settled()
|
||||
expect(webPage?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION })
|
||||
expect(webPage?.convertedTo ?? null).toBeNull()
|
||||
|
||||
@@ -220,6 +258,7 @@ describe('convertBrowserPage history legs', () => {
|
||||
{ kind: 'workspace-doc', docLocation: DOC_LOCATION },
|
||||
{ leg: 'history-return' }
|
||||
)
|
||||
await settled()
|
||||
|
||||
expect(returned?.docLocation).toEqual(DOC_LOCATION)
|
||||
expect(returned?.convertedFrom ?? null).toBeNull()
|
||||
@@ -235,12 +274,13 @@ describe('convertBrowserPage history legs', () => {
|
||||
expect(parsed.convertedTo).toEqual(expectedForward)
|
||||
})
|
||||
|
||||
it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { pageId } = createDocTab(store)
|
||||
it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { pageId } = await createDocTab(store)
|
||||
const webPage = store
|
||||
.getState()
|
||||
.convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' })
|
||||
await settled()
|
||||
const docPage = store
|
||||
.getState()
|
||||
.convertBrowserPage(
|
||||
@@ -248,6 +288,7 @@ describe('convertBrowserPage history legs', () => {
|
||||
{ kind: 'workspace-doc', docLocation: DOC_LOCATION },
|
||||
{ leg: 'history-return' }
|
||||
)
|
||||
await settled()
|
||||
|
||||
const advanced = store
|
||||
.getState()
|
||||
@@ -256,6 +297,7 @@ describe('convertBrowserPage history legs', () => {
|
||||
{ kind: 'web', url: 'https://example.com/' },
|
||||
{ leg: 'history-advance' }
|
||||
)
|
||||
await settled()
|
||||
|
||||
expect(advanced?.url).toBe('https://example.com/')
|
||||
expect(advanced?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION })
|
||||
@@ -264,17 +306,19 @@ describe('convertBrowserPage history legs', () => {
|
||||
})
|
||||
|
||||
describe('convertBrowserPage placement and activation', () => {
|
||||
it('converts a background page without stealing the active page', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { tabId, pageId: docPageId } = createDocTab(store)
|
||||
it('converts a background page without stealing the active page', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { tabId, pageId: docPageId } = await createDocTab(store)
|
||||
const webPage = store.getState().createBrowserPage(tabId, 'https://active.example/', {
|
||||
activate: true
|
||||
})
|
||||
await settled()
|
||||
|
||||
const converted = store.getState().convertBrowserPage(docPageId, {
|
||||
kind: 'web',
|
||||
url: 'https://converted.example/'
|
||||
})
|
||||
await settled()
|
||||
|
||||
const pages = store.getState().browserPagesByWorkspace[tabId] ?? []
|
||||
// Replacement holds the old page's position.
|
||||
@@ -289,9 +333,9 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
expect(converted?.id).not.toBe(docPageId)
|
||||
})
|
||||
|
||||
it('clears the old page id from every per-page side table', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { pageId } = createDocTab(store)
|
||||
it('clears the old page id from every per-page side table', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { pageId } = await createDocTab(store)
|
||||
// Every table SEEDED, so a deletion that stops running fails instead of passing vacuously.
|
||||
store.setState((s) => ({
|
||||
pendingAddressBarFocusByPageId: { ...s.pendingAddressBarFocusByPageId, [pageId]: true },
|
||||
@@ -307,6 +351,7 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
}))
|
||||
|
||||
store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' })
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByPageId[pageId]).toBeUndefined()
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[pageId]).toBeUndefined()
|
||||
@@ -316,9 +361,10 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
|
||||
// The leg the remote pane's address bar reaches: converting a runtime-owned page must close the
|
||||
// host's page and drop the handle, or a ghost page stays open on the remote host.
|
||||
it('closes the remote page and drops its handle when a runtime-owned page converts', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('closes the remote page and drops its handle when a runtime-owned page converts', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/')
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
store.setState((s) => ({
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
@@ -332,6 +378,7 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(converted).not.toBeNull()
|
||||
expect(store.getState().remoteBrowserPageHandlesByPageId[pageId]).toBeUndefined()
|
||||
@@ -345,17 +392,19 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
|
||||
// Ownership rides provenance both ways, or Back silently moves a remote tab's browsing onto
|
||||
// this desktop (the ssh-execution-boundary concern).
|
||||
it('carries runtime ownership through provenance and honors it on the return leg', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('carries runtime ownership through provenance and honors it on the return leg', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/', {
|
||||
browserRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
const docPage = store.getState().convertBrowserPage(pageId, {
|
||||
kind: 'workspace-doc',
|
||||
docLocation: DOC_LOCATION
|
||||
})
|
||||
await settled()
|
||||
expect(docPage?.convertedFrom).toEqual({
|
||||
kind: 'url',
|
||||
url: 'https://remote.example/',
|
||||
@@ -371,12 +420,13 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
},
|
||||
{ leg: 'history-return' }
|
||||
)
|
||||
await settled()
|
||||
expect(returned?.browserRuntimeEnvironmentId).toBe('env-1')
|
||||
})
|
||||
|
||||
it('returns a worktree-inferred remote page as inferred, never as client-local', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
const { pageId } = createDocTab(store)
|
||||
it('returns a worktree-inferred remote page as inferred, never as client-local', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const { pageId } = await createDocTab(store)
|
||||
// The return leg says "inferred" by passing the property explicitly undefined.
|
||||
const returned = store
|
||||
.getState()
|
||||
@@ -385,18 +435,20 @@ describe('convertBrowserPage placement and activation', () => {
|
||||
{ kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: undefined },
|
||||
{ leg: 'history-return' }
|
||||
)
|
||||
await settled()
|
||||
expect(returned).not.toBeNull()
|
||||
expect('browserRuntimeEnvironmentId' in (returned ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null for an unknown page and changes nothing', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
createDocTab(store)
|
||||
it('returns null for an unknown page and changes nothing', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
await createDocTab(store)
|
||||
const before = store.getState().browserPagesByWorkspace
|
||||
|
||||
const converted = store
|
||||
.getState()
|
||||
.convertBrowserPage('missing-page', { kind: 'web', url: 'https://example.com/' })
|
||||
await settled()
|
||||
|
||||
expect(converted).toBeNull()
|
||||
expect(store.getState().browserPagesByWorkspace).toBe(before)
|
||||
|
||||
@@ -52,7 +52,11 @@ export function planBrowserPageConversion(
|
||||
},
|
||||
pageId: string,
|
||||
target: BrowserPageConversionTarget,
|
||||
options?: { leg?: BrowserPageConversionLeg }
|
||||
options?: {
|
||||
leg?: BrowserPageConversionLeg
|
||||
/** The id main already holds the new page under. */
|
||||
newPageId?: string
|
||||
}
|
||||
): BrowserPageConversionPlan | null {
|
||||
const oldPage = findPage(state.browserPagesByWorkspace, pageId)
|
||||
if (!oldPage) {
|
||||
@@ -101,7 +105,7 @@ export function planBrowserPageConversion(
|
||||
// Why explicitly client-local: the document is read through a grant this desktop mints,
|
||||
// so the page never belongs to a remote runtime even when the worktree does.
|
||||
null,
|
||||
undefined,
|
||||
options?.newPageId,
|
||||
target.docLocation
|
||||
)
|
||||
: buildBrowserPage(
|
||||
@@ -116,7 +120,8 @@ export function planBrowserPageConversion(
|
||||
// when it restores a worktree-owned remote page.
|
||||
'browserRuntimeEnvironmentId' in target
|
||||
? target.browserRuntimeEnvironmentId
|
||||
: (oldPage.browserRuntimeEnvironmentId ?? null)
|
||||
: (oldPage.browserRuntimeEnvironmentId ?? null),
|
||||
options?.newPageId
|
||||
)),
|
||||
...(convertedFrom ? { convertedFrom } : {}),
|
||||
...(convertedTo ? { convertedTo } : {})
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import type { AppState } from '../types'
|
||||
import {
|
||||
createBrowserMockApi,
|
||||
createTestStore,
|
||||
resetBrowserRuntimeMocks,
|
||||
settingsWithRuntime
|
||||
} from './browser-slice-test-harness'
|
||||
import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture'
|
||||
import { makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
|
||||
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock
|
||||
}))
|
||||
@@ -20,6 +34,84 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall)
|
||||
// @ts-expect-error test window mock
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
/** wt-1's one browser tab, whose page is a runtime's remote page, seeded in its pane group. */
|
||||
function remoteBackedBrowserTab(): Partial<AppState> {
|
||||
return {
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-browser-tab',
|
||||
entityId: 'workspace-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'browser',
|
||||
label: 'Example',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'unified-browser-tab',
|
||||
tabOrder: ['unified-browser-tab']
|
||||
}
|
||||
]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A layout-owner build's close is main's: the window sent it, and main's frame drew it. */
|
||||
function expectMainClosed(command: 'closeBrowserPage' | 'closeTabs'): void {
|
||||
expect(layoutApplyTestTransport.sent.map((sent) => sent.type)).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? [command] : []
|
||||
)
|
||||
}
|
||||
|
||||
describe('createBrowserSlice runtime guard', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserRuntimeMocks({
|
||||
@@ -69,51 +161,14 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('closes the mapped remote tab when closing a browser page in the active runtime', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
...remoteBackedBrowserTab()
|
||||
})
|
||||
|
||||
store.getState().closeBrowserPage('page-1')
|
||||
await main.settle()
|
||||
expectMainClosed('closeBrowserPage')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
@@ -127,53 +182,16 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('closes mapped remote tabs when closing a browser workspace in the active runtime', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
...remoteBackedBrowserTab(),
|
||||
activeBrowserTabId: 'workspace-1',
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' }
|
||||
})
|
||||
|
||||
store.getState().closeBrowserTab('workspace-1')
|
||||
await main.settle()
|
||||
expectMainClosed('closeTabs')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
@@ -187,50 +205,12 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('closes mapped remote pages in their owning environment after switching local', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
})
|
||||
const { store, main } = await createTestStoreDrawnFromMain(remoteBackedBrowserTab())
|
||||
|
||||
store.getState().closeBrowserPage('page-1')
|
||||
await main.settle()
|
||||
expectMainClosed('closeBrowserPage')
|
||||
expect(store.getState().browserPagesByWorkspace['workspace-1'] ?? []).toEqual([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
@@ -243,51 +223,15 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('closes mapped remote tabs in their owning environment after switching environments', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
settings: settingsWithRuntime('env-2'),
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
...remoteBackedBrowserTab()
|
||||
})
|
||||
|
||||
store.getState().closeBrowserTab('workspace-1')
|
||||
await main.settle()
|
||||
expectMainClosed('closeTabs')
|
||||
expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
createBrowserMockApi,
|
||||
createTestStore,
|
||||
resetBrowserRuntimeMocks,
|
||||
runtimeStatuses,
|
||||
settingsWithRuntime
|
||||
} from './browser-slice-test-harness'
|
||||
import {
|
||||
createTestStoreDrawnFromMain,
|
||||
onePaneGroup
|
||||
} from './browser-slice-drawn-from-main.test-fixture'
|
||||
|
||||
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/store/slices/browser/paired-browser-tab-creator', () => ({
|
||||
getRegisteredPairedBrowserTabCreator: () => null,
|
||||
loadPairedBrowserTabCreator: async () => createWebRuntimeSessionBrowserTabMock
|
||||
@@ -22,6 +36,12 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall)
|
||||
// @ts-expect-error test window mock
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
/** A server-owned workspace's desktop tab: the window draws it, as main never holds that layout. */
|
||||
function expectDrawnHere(state: AppState): void {
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
expect(state.createUnifiedTab).toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('createBrowserSlice runtime guard', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserRuntimeMocks({
|
||||
@@ -35,9 +55,9 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rejects direct client-local materialization in the web client', () => {
|
||||
it('rejects direct client-local materialization in the web client', async () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
const store = createTestStore()
|
||||
const { store } = await createTestStoreDrawnFromMain()
|
||||
|
||||
expect(() => store.getState().createBrowserTab('wt-1', 'about:blank')).toThrow(
|
||||
'must be created by a capable paired runtime'
|
||||
@@ -46,10 +66,11 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
expect(store.getState().createUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects direct remote materialization without the provider capability', () => {
|
||||
it('rejects direct remote materialization without the provider capability', async () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
const store = createTestStore()
|
||||
store.setState({ runtimeStatusByEnvironmentId: runtimeStatuses([]) })
|
||||
const { store } = await createTestStoreDrawnFromMain({
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses([])
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
store.getState().createBrowserTab('wt-1', 'about:blank', {
|
||||
@@ -61,8 +82,9 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
// group-1 is wt-remote's, where the new-tab click lands.
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: { activeRuntimeEnvironmentId: null } as AppState['settings'],
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']),
|
||||
@@ -117,11 +139,13 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
expect(store.getState().createUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined()
|
||||
expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created')
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the desktop client browser when a remote npm host cannot stream', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses([]),
|
||||
@@ -138,21 +162,22 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace('group-1')
|
||||
await main.settle()
|
||||
|
||||
expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled()
|
||||
const tab = store.getState().browserTabsByWorktree['wt-remote']?.[0]
|
||||
const page = tab ? store.getState().browserPagesByWorkspace[tab.id]?.[0] : undefined
|
||||
expect(page?.browserRuntimeEnvironmentId).toBeNull()
|
||||
expect(store.getState().createUnifiedTab).toHaveBeenCalled()
|
||||
expectDrawnHere(store.getState())
|
||||
})
|
||||
|
||||
it('does not create a local fallback tab when remote browser creation fails', async () => {
|
||||
const store = createTestStore()
|
||||
// Why: a remote-owned workspace must stay remote-owned. If the remote host
|
||||
// cannot create the page, we must NOT silently open a local desktop tab —
|
||||
// that produces confusing split ownership (issue #5321 UX requirement).
|
||||
createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false)
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']),
|
||||
@@ -180,6 +205,8 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
clientTargetGroupId: 'group-1'
|
||||
})
|
||||
// No local tab created, no unified tab, no feature interaction recorded.
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined()
|
||||
expect(store.getState().createUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith(
|
||||
@@ -188,8 +215,8 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
})
|
||||
|
||||
it('opens a remote sign-in tab with the imported browser profile', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']),
|
||||
@@ -218,11 +245,13 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
profileId: 'profile-1'
|
||||
})
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined()
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('opens a local desktop sign-in tab when the remote host cannot stream browsers', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses([]),
|
||||
@@ -243,18 +272,20 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
.getState()
|
||||
.openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1')
|
||||
).resolves.toBe(true)
|
||||
await main.settle()
|
||||
|
||||
expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']?.[0]).toMatchObject({
|
||||
url: 'https://accounts.google.com/',
|
||||
sessionProfileId: 'profile-1'
|
||||
})
|
||||
expectDrawnHere(store.getState())
|
||||
})
|
||||
|
||||
it('rejects a paired-web sign-in tab when the remote host cannot stream browsers', async () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses([]),
|
||||
@@ -278,12 +309,14 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
|
||||
expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined()
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('does not create a local fallback tab when remote browser creation throws', async () => {
|
||||
const store = createTestStore()
|
||||
createWebRuntimeSessionBrowserTabMock.mockRejectedValueOnce(new Error('remote down'))
|
||||
store.setState({
|
||||
const { store, main } = await createTestStoreDrawnFromMain({
|
||||
...onePaneGroup('wt-remote'),
|
||||
activeWorktreeId: 'wt-remote',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
|
||||
runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']),
|
||||
@@ -302,6 +335,8 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
await expect(store.getState().openNewBrowserTabInActiveWorkspace('group-1')).rejects.toThrow(
|
||||
'remote down'
|
||||
)
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
|
||||
expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined()
|
||||
expect(store.getState().createUnifiedTab).not.toHaveBeenCalled()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// The browser slice's partial test store with main drawing its tabs in a layout-owner build.
|
||||
|
||||
import { onTestFinished } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { createTabsGroupActions } from './tabs/tabs-group-actions'
|
||||
import { createTestStore } from './browser-slice-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
export type BrowserTestStore = ReturnType<typeof createTestStore>
|
||||
|
||||
/** A worktree's one empty pane group, which main places an open in; it replaces wt-1's. */
|
||||
export function onePaneGroup(worktreeId: string, groupId = 'group-1'): Partial<AppState> {
|
||||
return {
|
||||
groupsByWorktree: {
|
||||
[worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }]
|
||||
},
|
||||
layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId } },
|
||||
activeGroupIdByWorktree: { [worktreeId]: groupId }
|
||||
}
|
||||
}
|
||||
|
||||
/** wt-1's pane group, plus the slices a session payload reads that the partial store lacks. */
|
||||
const DRAWN_STORE_SEED: Partial<AppState> = {
|
||||
repos: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
workspaceDocHistory: [],
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
...onePaneGroup('wt-1')
|
||||
}
|
||||
|
||||
/**
|
||||
* The partial store with main drawing its tabs (switch-on): seeded first, since main loads the
|
||||
* layout the store holds now. The test file installs the layout-apply transport (see
|
||||
* tabs-drawn-from-main.test-fixture.ts); main closes when the test finishes.
|
||||
*/
|
||||
export async function createTestStoreDrawnFromMain(
|
||||
seed?: Partial<AppState>
|
||||
): Promise<{ store: BrowserTestStore; main: TabsDrawnFromMain }> {
|
||||
const store = createTestStore()
|
||||
// Main drops a workspace with its last tab; the next open places through the real root group.
|
||||
const { ensureWorktreeRootGroup, focusGroup } = createTabsGroupActions(
|
||||
store.setState,
|
||||
store.getState
|
||||
)
|
||||
store.setState({ ...DRAWN_STORE_SEED, ensureWorktreeRootGroup, focusGroup, ...seed })
|
||||
const main = await drawTabsFromMain(store)
|
||||
onTestFinished(() => main.close())
|
||||
return { store, main }
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export function createTestStore() {
|
||||
browserDefaultUrl: 'about:blank',
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
tabsByWorktree: {},
|
||||
openFiles: [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { Tab, TabGroup } from '../../../../shared/tab-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
seedStore
|
||||
} from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
@@ -25,6 +27,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// Main answers the window's layout commands; its frames draw the browser tabs.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
const WT = 'repo1::/path/wt-placement'
|
||||
@@ -104,19 +118,36 @@ function createdUnifiedId(store: Store, workspaceId: string, worktreeId = WT): s
|
||||
|
||||
describe('browser tab creation placement', () => {
|
||||
let store: Store
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
store = createTestStore()
|
||||
})
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
/** Main takes the seeded layout; a layout-owner build draws the created tab from its frame. */
|
||||
async function drawFromMain(): Promise<void> {
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(store)
|
||||
}
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
it('appends Group New Browser Tab regardless of the active tab', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace(G1)
|
||||
await settled()
|
||||
const n = store.getState().unifiedTabsByWorktree[WT]!.at(-1)!
|
||||
expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id])
|
||||
|
||||
store.getState().activateTab('A')
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace(G1)
|
||||
await settled()
|
||||
const m = store.getState().unifiedTabsByWorktree[WT]!.at(-1)!
|
||||
expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id, m.id])
|
||||
})
|
||||
@@ -124,7 +155,9 @@ describe('browser tab creation placement', () => {
|
||||
it('uses the invoking group workspace instead of the globally selected one', async () => {
|
||||
seedSplit(store)
|
||||
store.setState({ activeWorktreeId: 'repo1::/elsewhere' })
|
||||
await drawFromMain()
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace(G1)
|
||||
await settled()
|
||||
expect(group(store, G1)?.tabOrder).toHaveLength(4)
|
||||
expect(store.getState().unifiedTabsByWorktree['repo1::/elsewhere']).toBeUndefined()
|
||||
})
|
||||
@@ -136,8 +169,10 @@ describe('browser tab creation placement', () => {
|
||||
repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:target' })]
|
||||
}
|
||||
})
|
||||
await drawFromMain()
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace(G1)
|
||||
await store.getState().openBrowserProfileTabInActiveWorkspace('https://example.com', null)
|
||||
await settled()
|
||||
const workspaces = store.getState().browserTabsByWorktree[WT] ?? []
|
||||
expect(workspaces).toHaveLength(2)
|
||||
for (const workspace of workspaces) {
|
||||
@@ -147,32 +182,33 @@ describe('browser tab creation placement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('places a duplicate directly after its source', () => {
|
||||
it('places a duplicate directly after its source', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' })
|
||||
await settled()
|
||||
const duplicate = createdUnifiedId(store, workspace.id)
|
||||
expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', duplicate, 'C'])
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1)
|
||||
.map((tab) => [tab.id, tab.sortOrder])
|
||||
).toEqual([
|
||||
['A', 0],
|
||||
['B', 1],
|
||||
['C', 3],
|
||||
[duplicate, 2]
|
||||
])
|
||||
Object.fromEntries(
|
||||
store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1)
|
||||
.map((tab) => [tab.id, tab.sortOrder])
|
||||
)
|
||||
).toEqual({ A: 0, B: 1, [duplicate]: 2, C: 3 })
|
||||
})
|
||||
|
||||
it('places a background link after its source without touching selection or MRU', () => {
|
||||
it('places a background link after its source without touching selection or MRU', async () => {
|
||||
seedSplit(store)
|
||||
const before = store.getState()
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com/link', { afterTabId: 'B', activate: false })
|
||||
await settled()
|
||||
const link = createdUnifiedId(store, workspace.id)
|
||||
const after = store.getState()
|
||||
|
||||
@@ -191,11 +227,13 @@ describe('browser tab creation placement', () => {
|
||||
expect(after.unifiedTabsByWorktree[WT]!.find((tab) => tab.id === 'B')?.lastFocusedAt).toBe(100)
|
||||
})
|
||||
|
||||
it('focuses the resolved group for a foreground source action in an unfocused split', () => {
|
||||
it('focuses the resolved group for a foreground source action in an unfocused split', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' })
|
||||
await settled()
|
||||
const created = createdUnifiedId(store, workspace.id)
|
||||
const state = store.getState()
|
||||
expect(state.activeGroupIdByWorktree[WT]).toBe(G1)
|
||||
@@ -205,64 +243,88 @@ describe('browser tab creation placement', () => {
|
||||
expect(state.activeTabType).toBe('browser')
|
||||
})
|
||||
|
||||
it('lets an explicit destination win over the anchor group', () => {
|
||||
it('lets an explicit destination win over the anchor group', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B', targetGroupId: G2 })
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('treats a closed anchor as absent and appends in the active group', () => {
|
||||
it('treats a closed anchor as absent and appends in the active group', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'closed-tab' })
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
})
|
||||
|
||||
it('falls back to the active group, not the first group, for an expired requested group', () => {
|
||||
it('falls back to the active group, not the first group, for an expired requested group', async () => {
|
||||
seedSplit(store)
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { targetGroupId: 'deleted-group' })
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
})
|
||||
|
||||
it('ignores an ambiguous anchor id', () => {
|
||||
seedSplit(store, {
|
||||
tabs: [browserTab('A', G1), browserTab('B', G1), browserTab('B', G2), browserTab('C', G1)]
|
||||
})
|
||||
it('ignores an ambiguous anchor id', async () => {
|
||||
const seed = {
|
||||
tabs: [
|
||||
browserTab('A', G1),
|
||||
browserTab('B', G1),
|
||||
browserTab('B', G2),
|
||||
browserTab('C', G1),
|
||||
browserTab('X', G2)
|
||||
]
|
||||
}
|
||||
seedSplit(store, seed)
|
||||
await drawFromMain()
|
||||
// Main's load repairs the malformed seed; the window's copy keeps it for the guard to read.
|
||||
seedSplit(store, seed)
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' })
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
})
|
||||
|
||||
it('ignores an anchor whose group order no longer contains it', () => {
|
||||
seedSplit(store, {
|
||||
it('ignores an anchor whose group order no longer contains it', async () => {
|
||||
const seed = {
|
||||
groups: [
|
||||
makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'A', tabOrder: ['A', 'C'] }),
|
||||
makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] })
|
||||
]
|
||||
})
|
||||
}
|
||||
seedSplit(store, seed)
|
||||
await drawFromMain()
|
||||
// Main's load repairs the malformed seed; the window's copy keeps it for the guard to read.
|
||||
seedSplit(store, seed)
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' })
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
})
|
||||
|
||||
it('collapses duplicate order entries while inserting once', () => {
|
||||
it('collapses duplicate order entries while inserting once', async () => {
|
||||
seedSplit(store, {
|
||||
groups: [
|
||||
makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'B', tabOrder: ['A', 'B', 'A', 'C'] }),
|
||||
makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] })
|
||||
]
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' })
|
||||
await settled()
|
||||
expect(group(store, G1)?.tabOrder).toEqual([
|
||||
'A',
|
||||
'B',
|
||||
@@ -271,7 +333,7 @@ describe('browser tab creation placement', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an unpinned tab from a pinned source after the pinned prefix', () => {
|
||||
it('keeps an unpinned tab from a pinned source after the pinned prefix', async () => {
|
||||
seedSplit(store, {
|
||||
tabs: [
|
||||
browserTab('P1', G1, { isPinned: true }),
|
||||
@@ -284,9 +346,11 @@ describe('browser tab creation placement', () => {
|
||||
makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] })
|
||||
]
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { afterTabId: 'P1' })
|
||||
await settled()
|
||||
expect(group(store, G1)?.tabOrder).toEqual([
|
||||
'P1',
|
||||
'P2',
|
||||
@@ -350,25 +414,28 @@ describe('browser tab creation placement', () => {
|
||||
expect(sortOrderById).toMatchObject({ A: 0, C: 1, [created.id]: 2 })
|
||||
})
|
||||
|
||||
it('leaves the wrapper host unset instead of stamping a substituted routing host', () => {
|
||||
it('leaves the wrapper host unset instead of stamping a substituted routing host', async () => {
|
||||
seedSplit(store)
|
||||
// Why: no repo row and a focused runtime make the routing resolver substitute runtime:env-1.
|
||||
store.setState({
|
||||
activeWorktreeId: 'repo1::/elsewhere',
|
||||
settings: { ...store.getState().settings!, activeRuntimeEnvironmentId: 'env-1' }
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store.getState().createBrowserTab(WT, 'https://example.com', {
|
||||
activate: false,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const wrapper = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === workspace.id)
|
||||
expect(wrapper).toBeDefined()
|
||||
expect(wrapper?.executionHostId).toBeUndefined()
|
||||
// A layout-owner build draws every tab with main's own host, never the routing substitute.
|
||||
expect(wrapper?.executionHostId).toBe(ORCA_LAYOUT_OWNER_IS_MAIN ? 'local' : undefined)
|
||||
})
|
||||
|
||||
it('rejects an anchor owned by a foreign execution host', () => {
|
||||
it('rejects an anchor owned by a foreign execution host', async () => {
|
||||
seedSplit(store, {
|
||||
tabs: [browserTab('A', G1, { executionHostId: 'ssh:other' }), browserTab('X', G2)],
|
||||
groups: [
|
||||
@@ -376,14 +443,16 @@ describe('browser tab creation placement', () => {
|
||||
makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] })
|
||||
]
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store.getState().createBrowserTab(WT, 'https://example.com', {
|
||||
afterTabId: 'A',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
await settled()
|
||||
expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)])
|
||||
})
|
||||
|
||||
it('accepts an SSH-backed anchor through the worktree runtime-owner alias', () => {
|
||||
it('accepts an SSH-backed anchor through the worktree runtime-owner alias', async () => {
|
||||
const sshHost: ExecutionHostId = 'ssh:target-1'
|
||||
seedSplit(store, {
|
||||
tabs: [browserTab('A', G1, { executionHostId: sshHost }), browserTab('C', G1)],
|
||||
@@ -401,14 +470,16 @@ describe('browser tab creation placement', () => {
|
||||
]
|
||||
}
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store.getState().createBrowserTab(WT, 'https://example.com', {
|
||||
afterTabId: 'A',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
await settled()
|
||||
expect(group(store, G1)?.tabOrder).toEqual(['A', createdUnifiedId(store, workspace.id), 'C'])
|
||||
})
|
||||
|
||||
it('places source actions in folder and floating workspaces', () => {
|
||||
it('places source actions in folder and floating workspaces', async () => {
|
||||
const folderWt = 'folder:folder-1'
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'folder-1',
|
||||
@@ -432,7 +503,12 @@ describe('browser tab creation placement', () => {
|
||||
unifiedTabsByWorktree: {
|
||||
...store.getState().unifiedTabsByWorktree,
|
||||
[worktreeId]: ['A', 'B'].map((id) =>
|
||||
makeUnifiedTab({ id: `${worktreeId}-${id}`, worktreeId, groupId: `${worktreeId}-g` })
|
||||
makeUnifiedTab({
|
||||
id: `${worktreeId}-${id}`,
|
||||
worktreeId,
|
||||
groupId: `${worktreeId}-g`,
|
||||
contentType: 'browser'
|
||||
})
|
||||
)
|
||||
},
|
||||
groupsByWorktree: {
|
||||
@@ -447,10 +523,12 @@ describe('browser tab creation placement', () => {
|
||||
]
|
||||
}
|
||||
})
|
||||
await drawFromMain()
|
||||
const workspace = store.getState().createBrowserTab(worktreeId, 'https://example.com', {
|
||||
afterTabId: `${worktreeId}-A`,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
expect(group(store, `${worktreeId}-g`, worktreeId)?.tabOrder).toEqual([
|
||||
`${worktreeId}-A`,
|
||||
createdUnifiedId(store, workspace.id, worktreeId),
|
||||
@@ -459,17 +537,22 @@ describe('browser tab creation placement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('selects the first tab of an empty group structurally without recording a visit', () => {
|
||||
seedSplit(store, {
|
||||
it('selects the first tab of an empty group structurally without recording a visit', async () => {
|
||||
const seed = {
|
||||
groups: [
|
||||
makeTabGroup({ id: G1, worktreeId: WT, activeTabId: null, tabOrder: [], recentTabIds: [] }),
|
||||
makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] })
|
||||
],
|
||||
tabs: [browserTab('X', G2)]
|
||||
})
|
||||
}
|
||||
seedSplit(store, seed)
|
||||
await drawFromMain()
|
||||
// Main stores no empty group; only the window's copy holds it.
|
||||
seedSplit(store, seed)
|
||||
const workspace = store
|
||||
.getState()
|
||||
.createBrowserTab(WT, 'https://example.com', { targetGroupId: G1, activate: false })
|
||||
await settled()
|
||||
const created = createdUnifiedId(store, workspace.id)
|
||||
expect(group(store, G1)).toMatchObject({ activeTabId: created, recentTabIds: [] })
|
||||
expect(store.getState().activeGroupIdByWorktree[WT]).toBe(G2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { buildBrowserSessionData } from '@/lib/workspace-session-browser-tabs'
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
browserWorkspaceSchema
|
||||
} from '../../../../shared/workspace-session-browser-schema'
|
||||
import { createTestStore, makeWorktree } from './store-test-helpers'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn() }))
|
||||
vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
@@ -19,6 +20,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
// Main answers the window's layout commands and its frames draw the tabs (switch-on only).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
const DOC_LOCATION = {
|
||||
@@ -30,7 +42,18 @@ const DOC_LOCATION = {
|
||||
// process. Nothing in the store, on disk or on the wire may ever carry it.
|
||||
const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html`
|
||||
|
||||
function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
/** A store showing one worktree, its tabs drawn from main once the switch is on. */
|
||||
async function createStoreWithWorktree(): Promise<ReturnType<typeof createTestStore>> {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
@@ -39,6 +62,8 @@ function createStoreWithWorktree(): ReturnType<typeof createTestStore> {
|
||||
},
|
||||
activeWorktreeId: WORKTREE_ID
|
||||
})
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(store)
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -55,13 +80,14 @@ function persistedSession(
|
||||
}
|
||||
|
||||
describe('a browser page that shows a workspace document', () => {
|
||||
it('is created blank, with the document as its identity', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('is created blank, with the document as its identity', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
|
||||
const page = store.getState().browserPagesByWorkspace[tab.id]?.[0]
|
||||
expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL)
|
||||
@@ -78,10 +104,11 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// The presence precondition for every blank-url assertion here: an ordinary tab created the same
|
||||
// way keeps the URL it was given, so a store that had stopped recording urls at all would fail
|
||||
// rather than pass by being uniformly blank.
|
||||
it('leaves an ordinary browser tab url alone', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('leaves an ordinary browser tab url alone', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.url).toBe('https://example.com/')
|
||||
expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull()
|
||||
@@ -89,13 +116,14 @@ describe('a browser page that shows a workspace document', () => {
|
||||
|
||||
// Browser parity: a browser tab is named by the document it shows, and so is this one. Without
|
||||
// this the blank url that is correct for a document page named every preview "New Tab".
|
||||
it('takes its name from the document, and falls back to the file', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('takes its name from the document, and falls back to the file', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
title: 'index.html',
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.title).toBe('index.html')
|
||||
|
||||
@@ -108,13 +136,14 @@ describe('a browser page that shows a workspace document', () => {
|
||||
|
||||
// Why a title needs the same refusal the url has: Chromium reports the URL as the title when a
|
||||
// document declares none, and titles are stored, mirrored onto the tab and written to disk.
|
||||
it('refuses a grant url arriving as the document title', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('refuses a grant url arriving as the document title', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
title: 'index.html',
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().updateBrowserPageState(pageId, { title: LIVE_GRANT_URL })
|
||||
@@ -126,9 +155,10 @@ describe('a browser page that shows a workspace document', () => {
|
||||
|
||||
// The presence half for both: an ordinary page still gets the blank-url name, so a fallback that
|
||||
// had swallowed every title would fail here rather than pass by naming everything after a file.
|
||||
it('still names an ordinary blank browser tab New Tab', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('still names an ordinary blank browser tab New Tab', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL)
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().updateBrowserPageState(pageId, { title: '' })
|
||||
@@ -139,13 +169,14 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// Why the third url door is fenced even though nothing reaches it today: creation and the title
|
||||
// update are fenced, and a door left open is one navigation report away from committing a grant
|
||||
// to the page every persisted, mirrored and published reader takes its url from.
|
||||
it('keeps its blank url when a navigation commits one onto it', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('keeps its blank url when a navigation commits one onto it', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
title: 'index.html',
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().setBrowserPageUrl(pageId, LIVE_GRANT_URL)
|
||||
@@ -163,9 +194,10 @@ describe('a browser page that shows a workspace document', () => {
|
||||
|
||||
// The presence half for the door above: an ordinary page still takes the url it is given, so a
|
||||
// door that had stopped writing urls entirely would fail here rather than pass by writing none.
|
||||
it('still commits a navigation url onto an ordinary browser tab', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('still commits a navigation url onto an ordinary browser tab', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
store.getState().setBrowserPageUrl(pageId, 'https://example.com/next')
|
||||
@@ -176,14 +208,16 @@ describe('a browser page that shows a workspace document', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('never asks for the address bar it does not have', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('never asks for the address bar it does not have', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
|
||||
const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const newTab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[docTab.id]).toBeUndefined()
|
||||
// The blank url is exactly what marks an ordinary New Tab as wanting focus there.
|
||||
@@ -194,15 +228,17 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// its one page, while every later change re-mirrors from whichever page is active. A mirror that
|
||||
// does not carry this field leaves the strip entry naming a document the reader has switched away
|
||||
// from — and the publish boundary reads the tab, not the page.
|
||||
it('follows the active page in and out of the document', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('follows the active page in and out of the document', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
|
||||
const urlPage = store.getState().createBrowserPage(tab.id, 'https://example.com/')
|
||||
await settled()
|
||||
|
||||
expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull()
|
||||
expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.url).toBe(
|
||||
@@ -221,12 +257,13 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// not — two documents named index.html in different folders share a title, and a document page's
|
||||
// url is blank by construction. Without this field in the comparison the strip entry keeps naming
|
||||
// the document the reader moved away from, and the publish boundary reads the entry.
|
||||
it('repairs a workspace still naming the document its page has left', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('repairs a workspace still naming the document its page has left', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const page = store.getState().browserPagesByWorkspace[tab.id]?.[0]
|
||||
const otherDocument = { ...DOC_LOCATION, filePath: '/home/alice/wt1/appendix/index.html' }
|
||||
store.setState({
|
||||
@@ -246,49 +283,59 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// Why closing has to say so: a grant is the only authority the preview scheme honors, and it
|
||||
// outlives the guest. A closed document that stays readable is a read authority nothing revokes
|
||||
// until the process ends.
|
||||
it('revokes the grant of the document tab that was closed, and only that one', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('revokes the grant of the document tab that was closed, and only that one', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id
|
||||
const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
|
||||
store.getState().closeBrowserTab(urlTab.id)
|
||||
await settled()
|
||||
expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled()
|
||||
|
||||
store.getState().closeBrowserTab(docTab.id)
|
||||
await settled()
|
||||
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(docPageId)
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('revokes a document grant when only that page is closed', () => {
|
||||
it('revokes a document grant when only that page is closed', async () => {
|
||||
mocks.releaseDocPreviewGrant.mockClear()
|
||||
const store = createStoreWithWorktree()
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? ''
|
||||
store.getState().createBrowserPage(tab.id, 'https://example.com/')
|
||||
await settled()
|
||||
|
||||
store.getState().closeBrowserPage(docPageId)
|
||||
await settled()
|
||||
|
||||
expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledExactlyOnceWith(docPageId)
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reopens a closed document tab with its document identity', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('reopens a closed document tab with its document identity', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
|
||||
store.getState().closeBrowserTab(tab.id)
|
||||
await settled()
|
||||
const reopened = store.getState().reopenClosedBrowserTab(WORKTREE_ID)
|
||||
await settled()
|
||||
const page = reopened ? store.getState().browserPagesByWorkspace[reopened.id]?.[0] : undefined
|
||||
|
||||
expect(page?.docLocation).toEqual(DOC_LOCATION)
|
||||
@@ -296,30 +343,35 @@ describe('a browser page that shows a workspace document', () => {
|
||||
expect(reopened?.docLocation).toEqual(DOC_LOCATION)
|
||||
})
|
||||
|
||||
it('reopens a closed document page with its document identity', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('reopens a closed document page with its document identity', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/')
|
||||
await settled()
|
||||
const documentPage = store.getState().createBrowserPage(tab.id, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
if (!documentPage) {
|
||||
throw new Error('Expected a document page')
|
||||
}
|
||||
|
||||
store.getState().closeBrowserPage(documentPage.id)
|
||||
await settled()
|
||||
const reopened = store.getState().reopenClosedBrowserPage(tab.id)
|
||||
await settled()
|
||||
|
||||
expect(reopened?.docLocation).toEqual(DOC_LOCATION)
|
||||
expect(reopened?.url).toBe(ORCA_BROWSER_BLANK_URL)
|
||||
})
|
||||
|
||||
it('writes the document and not the grant to the session', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('writes the document and not the grant to the session', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
|
||||
const session = persistedSession(store)
|
||||
const written = JSON.stringify(session)
|
||||
@@ -332,12 +384,13 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// Why the schema is asserted separately: both browser schemas are plain z.object, which strips
|
||||
// what it does not name. An unlisted docLocation survives every step above and disappears on
|
||||
// load, restoring the document as a blank New Tab under a strip entry that still names it.
|
||||
it('survives the session schema in both halves', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('survives the session schema in both halves', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const session = persistedSession(store)
|
||||
|
||||
const parsedPage = browserPageSchema.parse(session.browserPagesByWorkspace?.[tab.id]?.[0])
|
||||
@@ -349,15 +402,16 @@ describe('a browser page that shows a workspace document', () => {
|
||||
expect(parsedTab.docLocation).toEqual(DOC_LOCATION)
|
||||
})
|
||||
|
||||
it('restores as the document it was, still blank', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('restores as the document it was, still blank', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const session = persistedSession(store)
|
||||
|
||||
const restored = createStoreWithWorktree()
|
||||
const restored = await createStoreWithWorktree()
|
||||
restored.getState().hydrateBrowserSession({
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
@@ -377,15 +431,16 @@ describe('a browser page that shows a workspace document', () => {
|
||||
|
||||
// Why this shape and not just a missing field: salvage drops a malformed page row and leaves the
|
||||
// array empty, and hydration then rebuilds one page from the tab's own mirrored chrome.
|
||||
it('restores from the tab alone when its page row was salvaged away', () => {
|
||||
const store = createStoreWithWorktree()
|
||||
it('restores from the tab alone when its page row was salvaged away', async () => {
|
||||
const store = await createStoreWithWorktree()
|
||||
const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, {
|
||||
docLocation: DOC_LOCATION,
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await settled()
|
||||
const session = persistedSession(store)
|
||||
|
||||
const restored = createStoreWithWorktree()
|
||||
const restored = await createStoreWithWorktree()
|
||||
restored.getState().hydrateBrowserSession({
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
@@ -404,8 +459,8 @@ describe('a browser page that shows a workspace document', () => {
|
||||
// Why a hand-written row and not one this build can produce: the store refuses to mint one, so
|
||||
// the only way a grant url reaches this field is a session written by another build. It names a
|
||||
// grant that died with the process that minted it, and the address bar would show it.
|
||||
it('blanks a grant url a foreign session carried in', () => {
|
||||
const restored = createStoreWithWorktree()
|
||||
it('blanks a grant url a foreign session carried in', async () => {
|
||||
const restored = await createStoreWithWorktree()
|
||||
|
||||
restored.getState().hydrateBrowserSession({
|
||||
activeRepoId: 'repo1',
|
||||
@@ -451,6 +506,7 @@ describe('a browser page that shows a workspace document', () => {
|
||||
]
|
||||
}
|
||||
})
|
||||
await settled()
|
||||
|
||||
expect(restored.getState().browserPagesByWorkspace['browser-1']?.[0]?.url).toBe(
|
||||
ORCA_BROWSER_BLANK_URL
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isLocalBrowserPageOwner } from './browser'
|
||||
import type { AppState } from '../types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
createBrowserMockApi,
|
||||
createTestStore,
|
||||
resetBrowserRuntimeMocks
|
||||
} from './browser-slice-test-harness'
|
||||
import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture'
|
||||
|
||||
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock
|
||||
}))
|
||||
@@ -119,14 +132,26 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('stores a runtime-resolved browser partition without a renderer profile mirror', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ browserSessionProfiles: [] })
|
||||
it('stores a runtime-resolved browser partition without a renderer profile mirror', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain({ browserSessionProfiles: [] })
|
||||
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
sessionProfileId: 'profile-isolated',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-isolated'
|
||||
})
|
||||
await main.settle()
|
||||
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? [
|
||||
expect.objectContaining({
|
||||
browserTabId: tab.id,
|
||||
profileId: 'profile-isolated',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-isolated'
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
expect(tab.sessionProfileId).toBe('profile-isolated')
|
||||
expect(tab.sessionPartition).toBe('persist:orca-browser-session-profile-isolated')
|
||||
@@ -135,12 +160,13 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('stores a runtime-resolved partition when switching browser tab profiles', () => {
|
||||
const store = createTestStore()
|
||||
it('stores a runtime-resolved partition when switching browser tab profiles', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
sessionProfileId: null,
|
||||
sessionPartition: 'persist:orca-browser'
|
||||
})
|
||||
await main.settle()
|
||||
|
||||
store
|
||||
.getState()
|
||||
@@ -149,6 +175,20 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
'profile-isolated',
|
||||
'persist:orca-browser-session-profile-isolated'
|
||||
)
|
||||
await main.settle()
|
||||
|
||||
// A layout-owner build's main records the switch; its frame draws the record.
|
||||
expect(layoutApplyTestTransport.sentOfType('setBrowserTabProfile')).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? [
|
||||
expect.objectContaining({
|
||||
browserTabId: tab.id,
|
||||
profileId: 'profile-isolated',
|
||||
sessionPartition: 'persist:orca-browser-session-profile-isolated'
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -3,15 +3,31 @@ import type { AppState } from '../types'
|
||||
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
|
||||
import { makeAnnotation } from './browser-annotation-test-fixture'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import {
|
||||
createBrowserMockApi,
|
||||
createTestStore,
|
||||
settingsWithRuntime
|
||||
} from './browser-slice-test-harness'
|
||||
import {
|
||||
createTestStoreDrawnFromMain,
|
||||
type BrowserTestStore
|
||||
} from './browser-slice-drawn-from-main.test-fixture'
|
||||
|
||||
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/web-runtime-browser-creation', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock
|
||||
}))
|
||||
@@ -21,11 +37,7 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall)
|
||||
// @ts-expect-error test window mock
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
function seedUnifiedBrowserTab(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
entityId: string,
|
||||
label: string
|
||||
): void {
|
||||
function seedUnifiedBrowserTab(store: BrowserTestStore, entityId: string, label: string): void {
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
@@ -47,26 +59,30 @@ function seedUnifiedBrowserTab(
|
||||
}
|
||||
|
||||
describe('createBrowserSlice annotations', () => {
|
||||
it('announces the store-selected browser page before its guest is destroyed', () => {
|
||||
const store = createTestStore()
|
||||
it('announces the store-selected browser page before its guest is destroyed', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const previous = store.getState().createBrowserTab('wt-1', 'https://previous.example.com')
|
||||
await main.settle()
|
||||
const closing = store.getState().createBrowserTab('wt-1', 'https://closing.example.com')
|
||||
await main.settle()
|
||||
store.getState().setActiveBrowserTab(previous.id)
|
||||
store.getState().setActiveBrowserTab(closing.id)
|
||||
mockApi.browser.notifyActiveTabChanged.mockClear()
|
||||
|
||||
store.getState().closeBrowserTab(closing.id)
|
||||
await main.settle()
|
||||
|
||||
expect(mockApi.browser.notifyActiveTabChanged).toHaveBeenCalledWith({
|
||||
browserPageId: previous.activePageId
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a requested canonical page identity distinct from its workspace', () => {
|
||||
const store = createTestStore()
|
||||
it('keeps a requested canonical page identity distinct from its workspace', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'about:blank', {
|
||||
browserPageId: 'page-canonical'
|
||||
})
|
||||
await main.settle()
|
||||
|
||||
expect(tab.id).not.toBe('page-canonical')
|
||||
expect(tab.activePageId).toBe('page-canonical')
|
||||
@@ -79,9 +95,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
|
||||
it('records browser-tab-created only for the explicit new-tab action', async () => {
|
||||
const store = createTestStore()
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
|
||||
store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith(
|
||||
'browser-tab-created'
|
||||
)
|
||||
@@ -92,13 +109,14 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
|
||||
it('opens a local sign-in tab with the imported browser profile', async () => {
|
||||
const store = createTestStore()
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
|
||||
await expect(
|
||||
store
|
||||
.getState()
|
||||
.openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1')
|
||||
).resolves.toBe(true)
|
||||
await main.settle()
|
||||
|
||||
expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toMatchObject({
|
||||
url: 'https://accounts.google.com/',
|
||||
@@ -106,9 +124,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retains saved page annotations while retiring geometry when the URL changes', () => {
|
||||
const store = createTestStore()
|
||||
it('retains saved page annotations while retiring geometry when the URL changes', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -126,9 +145,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can commit a navigation URL without hiding an active recovery error', () => {
|
||||
const store = createTestStore()
|
||||
it('can commit a navigation URL without hiding an active recovery error', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -151,9 +171,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps certificate challenges transient across navigation, success, and close', () => {
|
||||
const store = createTestStore()
|
||||
it('keeps certificate challenges transient across navigation, success, and close', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://localhost:3443/')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -181,31 +202,56 @@ describe('createBrowserSlice annotations', () => {
|
||||
|
||||
store.getState().setBrowserPageCertificateFailure(pageId, failure)
|
||||
store.getState().closeBrowserTab(tab.id)
|
||||
await main.settle()
|
||||
expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates inactive browser unified tabs without stealing the visible tab', () => {
|
||||
const store = createTestStore()
|
||||
|
||||
store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false })
|
||||
|
||||
expect(store.getState().createUnifiedTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'browser',
|
||||
expect.objectContaining({ activate: false })
|
||||
)
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull()
|
||||
})
|
||||
|
||||
it('creates pages cold so a deferred guest is never owed a navigation', () => {
|
||||
const store = createTestStore()
|
||||
it('creates inactive browser unified tabs without stealing the visible tab', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
activate: false
|
||||
})
|
||||
store.getState().createBrowserPage(tab.id, 'https://example.com/second', { activate: false })
|
||||
await main.settle()
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main opens the tab and its frame draws it; the window leaves it unselected.
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual([
|
||||
expect.objectContaining({ browserTabId: tab.id, groupId: 'group-1' })
|
||||
])
|
||||
expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([
|
||||
expect.objectContaining({ contentType: 'browser', entityId: tab.id })
|
||||
])
|
||||
expect(store.getState().activateTab).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(store.getState().createUnifiedTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'browser',
|
||||
expect.objectContaining({ activate: false })
|
||||
)
|
||||
}
|
||||
// A background open leaves the worktree's remembered browser tab unset.
|
||||
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull()
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
expect(store.getState().activeBrowserTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('creates pages cold so a deferred guest is never owed a navigation', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
activate: false
|
||||
})
|
||||
await main.settle()
|
||||
const second = store
|
||||
.getState()
|
||||
.createBrowserPage(tab.id, 'https://example.com/second', { activate: false })
|
||||
await main.settle()
|
||||
|
||||
// A layout-owner build's main adds the page and its frame draws it.
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId: second?.id })] : []
|
||||
)
|
||||
// Why: only a live guest reports loading; a background page has none until first shown.
|
||||
expect(store.getState().browserPagesByWorkspace[tab.id]?.map((page) => page.loading)).toEqual([
|
||||
false,
|
||||
@@ -214,8 +260,8 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(tab.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('uses local browser profile defaults for client-local fallback pages', () => {
|
||||
const store = createTestStore()
|
||||
it('uses local browser profile defaults for client-local fallback pages', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
store.setState({
|
||||
settings: settingsWithRuntime('env-1'),
|
||||
defaultBrowserSessionProfileIdByHostId: {
|
||||
@@ -227,19 +273,22 @@ describe('createBrowserSlice annotations', () => {
|
||||
const localFallback = store.getState().createBrowserTab('wt-1', 'about:blank', {
|
||||
browserRuntimeEnvironmentId: null
|
||||
})
|
||||
await main.settle()
|
||||
const remoteTab = store.getState().createBrowserTab('wt-1', 'about:blank', {
|
||||
browserRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
await main.settle()
|
||||
|
||||
expect(localFallback.sessionProfileId).toBe('local-profile')
|
||||
expect(remoteTab.sessionProfileId).toBe('runtime-profile')
|
||||
})
|
||||
|
||||
it('preserves browser map references when a page-state update is unchanged', () => {
|
||||
const store = createTestStore()
|
||||
it('preserves browser map references when a page-state update is unchanged', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -264,11 +313,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
|
||||
})
|
||||
|
||||
it('persists a captured favicon with history and refreshes it with the page state', () => {
|
||||
const store = createTestStore()
|
||||
it('persists a captured favicon with history and refreshes it with the page state', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -283,11 +333,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon)
|
||||
})
|
||||
|
||||
it('clears a stale history favicon when a page reports none, and keeps it when none is reported', () => {
|
||||
const store = createTestStore()
|
||||
it('clears a stale history favicon when a page reports none, and keeps it when none is reported', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -330,11 +381,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserUrlHistory).toBe(revisited)
|
||||
})
|
||||
|
||||
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
|
||||
const store = createTestStore()
|
||||
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -350,11 +402,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
|
||||
})
|
||||
|
||||
it('repairs stale active browser workspace metadata on an otherwise unchanged page update', () => {
|
||||
const store = createTestStore()
|
||||
it('repairs stale active browser workspace metadata on an otherwise unchanged page update', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -393,11 +446,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace)
|
||||
})
|
||||
|
||||
it('updates the active browser unified-tab label without a second tab-label write', () => {
|
||||
const store = createTestStore()
|
||||
it('updates the active browser unified-tab label without a second tab-label write', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -410,11 +464,12 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().setTabLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates inactive browser pages without relabeling or rebuilding the workspace map', () => {
|
||||
const store = createTestStore()
|
||||
it('updates inactive browser pages without relabeling or rebuilding the workspace map', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
title: 'Example'
|
||||
})
|
||||
await main.settle()
|
||||
const activePageId = tab.activePageId
|
||||
if (!activePageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -425,9 +480,15 @@ describe('createBrowserSlice annotations', () => {
|
||||
title: 'Inactive',
|
||||
activate: false
|
||||
})
|
||||
await main.settle()
|
||||
if (!inactivePage) {
|
||||
throw new Error('Expected inactive browser page')
|
||||
}
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? [expect.objectContaining({ browserTabId: tab.id, pageId: inactivePage.id })]
|
||||
: []
|
||||
)
|
||||
seedUnifiedBrowserTab(store, tab.id, 'Example')
|
||||
const browserPagesByWorkspace = store.getState().browserPagesByWorkspace
|
||||
const browserTabsByWorktree = store.getState().browserTabsByWorktree
|
||||
@@ -450,9 +511,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().setTabLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps stored browser annotations per page', () => {
|
||||
const store = createTestStore()
|
||||
it('caps stored browser annotations per page', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -467,9 +529,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(annotations[0]?.id).toBe('annotation-3')
|
||||
})
|
||||
|
||||
it('sanitizes persistent annotation payloads at the store boundary', () => {
|
||||
const store = createTestStore()
|
||||
it('sanitizes persistent annotation payloads at the store boundary', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -496,9 +559,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(stored?.payload.screenshot).toBeNull()
|
||||
})
|
||||
|
||||
it('updates an existing annotation comment and intent', () => {
|
||||
const store = createTestStore()
|
||||
it('updates an existing annotation comment and intent', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -514,9 +578,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(stored?.intent).toBe('change')
|
||||
})
|
||||
|
||||
it('sanitizes an oversized comment when updating an annotation', () => {
|
||||
const store = createTestStore()
|
||||
it('sanitizes an oversized comment when updating an annotation', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -533,9 +598,10 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(stored?.comment).toHaveLength(GRAB_BUDGET.annotationCommentMaxLength)
|
||||
})
|
||||
|
||||
it('is a no-op when updating an annotation id that does not exist', () => {
|
||||
const store = createTestStore()
|
||||
it('is a no-op when updating an annotation id that does not exist', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const pageId = tab.activePageId
|
||||
if (!pageId) {
|
||||
throw new Error('Expected a new browser page')
|
||||
@@ -553,15 +619,17 @@ describe('createBrowserSlice annotations', () => {
|
||||
})
|
||||
|
||||
describe('createBrowserSlice floating tabs', () => {
|
||||
it('tracks new floating browser tabs without changing the main browser surface', () => {
|
||||
const store = createTestStore()
|
||||
it('tracks new floating browser tabs without changing the main browser surface', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
store.setState({ activeWorktreeId: 'wt-1', activeTabType: 'terminal' } as Partial<AppState>)
|
||||
const mainTab = store.getState().createBrowserTab('wt-1', 'https://example.com')
|
||||
await main.settle()
|
||||
const activeTabTypeBeforeFloating = store.getState().activeTabType
|
||||
|
||||
const tab = store.getState().createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', {
|
||||
focusAddressBar: true
|
||||
})
|
||||
await main.settle()
|
||||
|
||||
expect(store.getState().activeBrowserTabId).toBe(mainTab.id)
|
||||
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBe(mainTab.id)
|
||||
@@ -574,30 +642,46 @@ describe('createBrowserSlice floating tabs', () => {
|
||||
})
|
||||
|
||||
describe('createBrowserSlice closed browser workspaces', () => {
|
||||
it('reopens duplicate-URL browser pages on the originally active page', () => {
|
||||
const store = createTestStore()
|
||||
it('reopens duplicate-URL browser pages on the originally active page', async () => {
|
||||
const { store, main } = await createTestStoreDrawnFromMain()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com/dashboard', {
|
||||
title: 'First copy'
|
||||
})
|
||||
await main.settle()
|
||||
const secondPage = store.getState().createBrowserPage(tab.id, 'https://example.com/dashboard', {
|
||||
title: 'Second copy'
|
||||
})
|
||||
await main.settle()
|
||||
if (!secondPage) {
|
||||
throw new Error('Expected a second browser page')
|
||||
}
|
||||
|
||||
store.getState().closeBrowserTab(tab.id)
|
||||
await main.settle()
|
||||
const restored = store.getState().reopenClosedBrowserTab('wt-1')
|
||||
// Main draws the tab, then the window sends its second page.
|
||||
await main.settle()
|
||||
await main.settle()
|
||||
if (!restored) {
|
||||
throw new Error('Expected a reopened browser workspace')
|
||||
}
|
||||
const restoredPages = store.getState().browserPagesByWorkspace[restored.id] ?? []
|
||||
const activePage = restoredPages.find((page) => page.id === restored.activePageId)
|
||||
// The returned record is as proposed; the drawn one carries the reselected page.
|
||||
const activePageId = store
|
||||
.getState()
|
||||
.browserTabsByWorktree['wt-1']?.find((entry) => entry.id === restored.id)?.activePageId
|
||||
const activePage = restoredPages.find((page) => page.id === activePageId)
|
||||
|
||||
expect(restoredPages.map((page) => page.url)).toEqual([
|
||||
'https://example.com/dashboard',
|
||||
'https://example.com/dashboard'
|
||||
])
|
||||
expect(activePage?.title).toBe('Second copy')
|
||||
// A layout-owner build's main closed the tab, then reopened it with both pages.
|
||||
expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? ['openBrowserTab', 'openBrowserPage', 'closeTabs', 'openBrowserTab', 'openBrowserPage']
|
||||
: []
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,8 +13,13 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos
|
||||
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
|
||||
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
|
||||
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
|
||||
import { landOnEmptiedWorktree } from '../tabs/tabs-close-actions'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordBrowserTabClose } from './browser-layout-owner-commands'
|
||||
import {
|
||||
closeBrowserTabThroughLayoutOwner,
|
||||
withDrawnBrowserPages,
|
||||
type DrawnBrowserTab
|
||||
} from './browser-tab-close-through-layout-owner'
|
||||
|
||||
export function createBrowserCloseActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -25,220 +30,246 @@ export function createBrowserCloseActions(
|
||||
// Why: a cleanup close unwinds a tab that never finished being created — it owns no host
|
||||
// page to close and must not enter the reopen stack as if the user had closed something.
|
||||
const isCleanup = options?.reason === 'cleanup'
|
||||
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
|
||||
// Why collected rather than released inside the reducer: revoking is main-process work, and a
|
||||
// grant is the only authority the preview scheme honors — a closed document must stop being
|
||||
// readable, and it must stop being readable even if the reducer bails out below.
|
||||
let docPageIdsToRelease: string[] = []
|
||||
let activeBrowserWorktreeIdToNotify: string | null = null
|
||||
set((s) => {
|
||||
let owningWorktreeId: string | null = null
|
||||
let closedWorkspace: BrowserWorkspace | null = null
|
||||
const nextBrowserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
|
||||
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
|
||||
const removedTab = tabs.find((tab) => tab.id === tabId) ?? null
|
||||
const filtered = tabs.filter((tab) => tab.id !== tabId)
|
||||
if (filtered.length !== tabs.length) {
|
||||
owningWorktreeId = worktreeId
|
||||
closedWorkspace = removedTab
|
||||
const closeHere = (drawn?: DrawnBrowserTab): void => {
|
||||
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
|
||||
// Why collected rather than released inside the reducer: revoking is main-process work, and a
|
||||
// grant is the only authority the preview scheme honors — a closed document must stop being
|
||||
// readable, and it must stop being readable even if the reducer bails out below.
|
||||
let docPageIdsToRelease: string[] = []
|
||||
let activeBrowserWorktreeIdToNotify: string | null = null
|
||||
set((current) => {
|
||||
const s = drawn ? withDrawnBrowserPages(current, drawn) : current
|
||||
// Main's frame already dropped a drawn tab's record.
|
||||
let owningWorktreeId: string | null = drawn?.worktreeId ?? null
|
||||
let closedWorkspace: BrowserWorkspace | null = drawn?.record ?? null
|
||||
const nextBrowserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
|
||||
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
|
||||
const removedTab = tabs.find((tab) => tab.id === tabId) ?? null
|
||||
const filtered = tabs.filter((tab) => tab.id !== tabId)
|
||||
if (filtered.length !== tabs.length) {
|
||||
owningWorktreeId = worktreeId
|
||||
closedWorkspace = removedTab
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
nextBrowserTabsByWorktree[worktreeId] = filtered
|
||||
}
|
||||
}
|
||||
if (filtered.length > 0) {
|
||||
nextBrowserTabsByWorktree[worktreeId] = filtered
|
||||
if (!owningWorktreeId || !closedWorkspace) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
if (!owningWorktreeId || !closedWorkspace) {
|
||||
return s
|
||||
}
|
||||
|
||||
const closedPages = s.browserPagesByWorkspace[tabId] ?? []
|
||||
const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace }
|
||||
delete nextBrowserPagesByWorkspace[tabId]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
const nextBrowserCertificateFailuresByPageId = {
|
||||
...s.browserCertificateFailuresByPageId
|
||||
}
|
||||
for (const page of closedPages) {
|
||||
delete nextBrowserAnnotationsByPageId[page.id]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[page.id]
|
||||
delete nextBrowserCertificateFailuresByPageId[page.id]
|
||||
}
|
||||
docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id)
|
||||
remotePagesToClose = isCleanup
|
||||
? []
|
||||
: closedPages.flatMap((page) => {
|
||||
const handle = s.remoteBrowserPageHandlesByPageId[page.id]
|
||||
return handle ? [{ worktreeId: page.worktreeId, handle }] : []
|
||||
})
|
||||
const nextRemoteBrowserPageHandlesByPageId = {
|
||||
...s.remoteBrowserPageHandlesByPageId
|
||||
}
|
||||
for (const page of closedPages) {
|
||||
delete nextRemoteBrowserPageHandlesByPageId[page.id]
|
||||
}
|
||||
|
||||
const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? []
|
||||
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
|
||||
if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) {
|
||||
const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId)
|
||||
nextActiveBrowserTabIdByWorktree[owningWorktreeId] =
|
||||
(neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId)
|
||||
? neighborId
|
||||
: remainingBrowserTabs[0]?.id) ?? null
|
||||
}
|
||||
|
||||
const nextTabBarOrder = {
|
||||
...s.tabBarOrderByWorktree,
|
||||
[owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter(
|
||||
(entryId) => entryId !== tabId
|
||||
)
|
||||
}
|
||||
|
||||
const isActiveTabInOwningWorktree =
|
||||
s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId
|
||||
if (isActiveTabInOwningWorktree) {
|
||||
activeBrowserWorktreeIdToNotify = owningWorktreeId
|
||||
}
|
||||
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
let nextActiveTabType = s.activeTabType
|
||||
if (remainingBrowserTabs.length === 0) {
|
||||
const fallbackTabType = getFallbackTabTypeForWorktree(
|
||||
owningWorktreeId,
|
||||
s.openFiles,
|
||||
s.tabsByWorktree
|
||||
)
|
||||
nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType
|
||||
if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') {
|
||||
nextActiveTabType = fallbackTabType
|
||||
const closedPages = s.browserPagesByWorkspace[tabId] ?? []
|
||||
const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace }
|
||||
delete nextBrowserPagesByWorkspace[tabId]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
const nextBrowserCertificateFailuresByPageId = {
|
||||
...s.browserCertificateFailuresByPageId
|
||||
}
|
||||
for (const page of closedPages) {
|
||||
delete nextBrowserAnnotationsByPageId[page.id]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[page.id]
|
||||
delete nextBrowserCertificateFailuresByPageId[page.id]
|
||||
}
|
||||
docPageIdsToRelease = closedPages
|
||||
.filter((page) => page.docLocation)
|
||||
.map((page) => page.id)
|
||||
remotePagesToClose = isCleanup
|
||||
? []
|
||||
: closedPages.flatMap((page) => {
|
||||
const handle = s.remoteBrowserPageHandlesByPageId[page.id]
|
||||
return handle ? [{ worktreeId: page.worktreeId, handle }] : []
|
||||
})
|
||||
const nextRemoteBrowserPageHandlesByPageId = {
|
||||
...s.remoteBrowserPageHandlesByPageId
|
||||
}
|
||||
for (const page of closedPages) {
|
||||
delete nextRemoteBrowserPageHandlesByPageId[page.id]
|
||||
}
|
||||
}
|
||||
|
||||
const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree }
|
||||
if (!isCleanup) {
|
||||
const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? []
|
||||
const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId)
|
||||
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [
|
||||
{
|
||||
workspace: closedWorkspace,
|
||||
pages: closedPages,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
|
||||
].slice(0, 10)
|
||||
}
|
||||
const nextRecentlyClosedTabKindsByWorktree = isCleanup
|
||||
? s.recentlyClosedTabKindsByWorktree
|
||||
: pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? []
|
||||
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
|
||||
if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) {
|
||||
const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId)
|
||||
nextActiveBrowserTabIdByWorktree[owningWorktreeId] =
|
||||
(neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId)
|
||||
? neighborId
|
||||
: remainingBrowserTabs[0]?.id) ?? null
|
||||
}
|
||||
|
||||
const nextTabBarOrder = {
|
||||
...s.tabBarOrderByWorktree,
|
||||
[owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter(
|
||||
(entryId) => entryId !== tabId
|
||||
)
|
||||
}
|
||||
|
||||
const isActiveTabInOwningWorktree =
|
||||
s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId
|
||||
if (isActiveTabInOwningWorktree) {
|
||||
activeBrowserWorktreeIdToNotify = owningWorktreeId
|
||||
}
|
||||
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
let nextActiveTabType = s.activeTabType
|
||||
if (remainingBrowserTabs.length === 0) {
|
||||
const fallbackTabType = getFallbackTabTypeForWorktree(
|
||||
owningWorktreeId,
|
||||
'browser'
|
||||
s.openFiles,
|
||||
s.tabsByWorktree
|
||||
)
|
||||
nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType
|
||||
if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') {
|
||||
nextActiveTabType = fallbackTabType
|
||||
}
|
||||
}
|
||||
|
||||
const nextRecentlyClosedBrowserPagesByWorkspace = {
|
||||
...s.recentlyClosedBrowserPagesByWorkspace
|
||||
}
|
||||
delete nextRecentlyClosedBrowserPagesByWorkspace[tabId]
|
||||
const nextRecentlyClosedBrowserTabsByWorktree = {
|
||||
...s.recentlyClosedBrowserTabsByWorktree
|
||||
}
|
||||
if (!isCleanup) {
|
||||
const existingSnapshots =
|
||||
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? []
|
||||
const position = drawn
|
||||
? drawn.position
|
||||
: getRecentlyClosedTabPosition(s, owningWorktreeId, tabId)
|
||||
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [
|
||||
{
|
||||
workspace: closedWorkspace,
|
||||
pages: closedPages,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
|
||||
].slice(0, 10)
|
||||
}
|
||||
const nextRecentlyClosedTabKindsByWorktree = isCleanup
|
||||
? s.recentlyClosedTabKindsByWorktree
|
||||
: pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
owningWorktreeId,
|
||||
'browser'
|
||||
)
|
||||
|
||||
// Why omit rather than rebuild: only the closed ids can match, so this touches the
|
||||
// closed pages instead of every pending entry and keeps the record identity when none did.
|
||||
const closedPageIds = closedPages.map((page) => page.id)
|
||||
const nextPendingAddressBarFocusByPageId = omitRecordKeys(
|
||||
s.pendingAddressBarFocusByPageId,
|
||||
closedPageIds
|
||||
)
|
||||
const nextPendingAddressBarFocusByTabId = omitRecordKeys(s.pendingAddressBarFocusByTabId, [
|
||||
...closedPageIds,
|
||||
tabId
|
||||
])
|
||||
const nextRecentlyClosedBrowserPagesByWorkspace = {
|
||||
...s.recentlyClosedBrowserPagesByWorkspace
|
||||
}
|
||||
delete nextRecentlyClosedBrowserPagesByWorkspace[tabId]
|
||||
|
||||
return {
|
||||
browserTabsByWorktree: nextBrowserTabsByWorktree,
|
||||
browserPagesByWorkspace: nextBrowserPagesByWorkspace,
|
||||
activeBrowserTabId:
|
||||
s.activeBrowserTabId === tabId
|
||||
? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null)
|
||||
: s.activeBrowserTabId,
|
||||
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
|
||||
tabBarOrderByWorktree: nextTabBarOrder,
|
||||
activeTabType: nextActiveTabType,
|
||||
pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
|
||||
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
|
||||
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
})
|
||||
// Why omit rather than rebuild: only the closed ids can match, so this touches the
|
||||
// closed pages instead of every pending entry and keeps the record identity when none did.
|
||||
const closedPageIds = closedPages.map((page) => page.id)
|
||||
const nextPendingAddressBarFocusByPageId = omitRecordKeys(
|
||||
s.pendingAddressBarFocusByPageId,
|
||||
closedPageIds
|
||||
)
|
||||
const nextPendingAddressBarFocusByTabId = omitRecordKeys(
|
||||
s.pendingAddressBarFocusByTabId,
|
||||
[...closedPageIds, tabId]
|
||||
)
|
||||
|
||||
for (const remotePage of remotePagesToClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remotePage.worktreeId,
|
||||
remotePage.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
)
|
||||
}
|
||||
return {
|
||||
browserTabsByWorktree: nextBrowserTabsByWorktree,
|
||||
browserPagesByWorkspace: nextBrowserPagesByWorkspace,
|
||||
activeBrowserTabId:
|
||||
s.activeBrowserTabId === tabId
|
||||
? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null)
|
||||
: s.activeBrowserTabId,
|
||||
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
|
||||
tabBarOrderByWorktree: nextTabBarOrder,
|
||||
activeTabType: nextActiveTabType,
|
||||
pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
|
||||
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
|
||||
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
})
|
||||
|
||||
for (const docPageId of docPageIdsToRelease) {
|
||||
releaseDocPreviewGrant(docPageId)
|
||||
}
|
||||
|
||||
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
|
||||
const workspaceItem = tabs.find(
|
||||
(entry) => entry.contentType === 'browser' && entry.entityId === tabId
|
||||
)
|
||||
// Switch point: main records the close while the tab-bar entry still names the tab.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceItem) {
|
||||
recordBrowserTabClose(
|
||||
get(),
|
||||
workspaceItem.worktreeId,
|
||||
tabId,
|
||||
isCleanup ? 'cleanup' : 'user'
|
||||
for (const remotePage of remotePagesToClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remotePage.worktreeId,
|
||||
remotePage.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
)
|
||||
}
|
||||
if (workspaceItem) {
|
||||
get().closeUnifiedTab(
|
||||
workspaceItem.id,
|
||||
isCleanup
|
||||
? { preserveWorktreeSelection: true, recordInteraction: false }
|
||||
: options?.preserveWorktreeSelection
|
||||
? { preserveWorktreeSelection: true }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order.
|
||||
if (activeBrowserWorktreeIdToNotify) {
|
||||
const state = get()
|
||||
const activeWorkspaceId =
|
||||
state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify]
|
||||
const activeWorkspace = activeWorkspaceId
|
||||
? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId)
|
||||
: null
|
||||
const activePage = activeWorkspace?.activePageId
|
||||
? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find(
|
||||
(page) => page.id === activeWorkspace.activePageId
|
||||
for (const docPageId of docPageIdsToRelease) {
|
||||
releaseDocPreviewGrant(docPageId)
|
||||
}
|
||||
|
||||
// Main's frame already dropped a drawn tab's entry.
|
||||
for (const tabs of drawn ? [] : Object.values(get().unifiedTabsByWorktree)) {
|
||||
const workspaceItem = tabs.find(
|
||||
(entry) => entry.contentType === 'browser' && entry.entityId === tabId
|
||||
)
|
||||
if (workspaceItem) {
|
||||
get().closeUnifiedTab(
|
||||
workspaceItem.id,
|
||||
isCleanup
|
||||
? { preserveWorktreeSelection: true, recordInteraction: false }
|
||||
: options?.preserveWorktreeSelection
|
||||
? { preserveWorktreeSelection: true }
|
||||
: undefined
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
activeWorkspace?.activePageId &&
|
||||
isLocalBrowserPageOwner(
|
||||
state,
|
||||
activeBrowserWorktreeIdToNotify,
|
||||
activePage?.browserRuntimeEnvironmentId
|
||||
) &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.api?.browser
|
||||
) {
|
||||
window.api.browser
|
||||
.notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order.
|
||||
if (activeBrowserWorktreeIdToNotify) {
|
||||
const state = get()
|
||||
const activeWorkspaceId =
|
||||
state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify]
|
||||
const activeWorkspace = activeWorkspaceId
|
||||
? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId)
|
||||
: null
|
||||
const activePage = activeWorkspace?.activePageId
|
||||
? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find(
|
||||
(page) => page.id === activeWorkspace.activePageId
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
activeWorkspace?.activePageId &&
|
||||
isLocalBrowserPageOwner(
|
||||
state,
|
||||
activeBrowserWorktreeIdToNotify,
|
||||
activePage?.browserRuntimeEnvironmentId
|
||||
) &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.api?.browser
|
||||
) {
|
||||
window.api.browser
|
||||
.notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Switch point: main closes the tab first; today's close then runs over the tab as drawn.
|
||||
const owningWorktreeId = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? findWorkspace(get().browserTabsByWorktree, tabId)?.worktreeId
|
||||
: undefined
|
||||
if (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN &&
|
||||
closeBrowserTabThroughLayoutOwner(
|
||||
get,
|
||||
tabId,
|
||||
isCleanup ? 'cleanup' : 'user',
|
||||
(drawn) => closeHere(drawn),
|
||||
// As today's tab-bar close: a cleanup keeps the selection and counts no interaction.
|
||||
isCleanup || options?.preserveWorktreeSelection || !owningWorktreeId
|
||||
? undefined
|
||||
: () => landOnEmptiedWorktree(set, get, owningWorktreeId)
|
||||
)
|
||||
) {
|
||||
if (!isCleanup) {
|
||||
get().recordFeatureInteraction?.('terminal-tabs')
|
||||
}
|
||||
return
|
||||
}
|
||||
closeHere()
|
||||
},
|
||||
|
||||
shutdownWorktreeBrowsers: (worktreeId, options) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { projectHydratedBrowserSession } from './browser-hydration-state'
|
||||
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
|
||||
import { findWorkspace } from '../browser-page-records'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { sendBrowserLayoutCommand } from './browser-layout-owner-commands'
|
||||
import { selectFolderWorkspaces } from '../../workspace-record-selectors'
|
||||
|
||||
export function createBrowserHydrationActions(
|
||||
@@ -76,38 +76,48 @@ export function createBrowserHydrationActions(
|
||||
},
|
||||
|
||||
switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => {
|
||||
set((s) => {
|
||||
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
|
||||
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
|
||||
if (tabIndex !== -1) {
|
||||
const updatedTabs = [...tabs]
|
||||
updatedTabs[tabIndex] = {
|
||||
...updatedTabs[tabIndex],
|
||||
sessionProfileId: profileId,
|
||||
sessionPartition: sessionPartition ?? null
|
||||
}
|
||||
return {
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[worktreeId]: updatedTabs
|
||||
const switchHere = (): void =>
|
||||
set((s) => {
|
||||
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
|
||||
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
|
||||
if (tabIndex !== -1) {
|
||||
const updatedTabs = [...tabs]
|
||||
updatedTabs[tabIndex] = {
|
||||
...updatedTabs[tabIndex],
|
||||
sessionProfileId: profileId,
|
||||
sessionPartition: sessionPartition ?? null
|
||||
}
|
||||
return {
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[worktreeId]: updatedTabs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
})
|
||||
// Switch point: main records the tab's new profile.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const tab = findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
if (tab) {
|
||||
recordLayoutApply(get(), tab.worktreeId, {
|
||||
return s
|
||||
})
|
||||
// Switch point: main switches the tab's profile; its frame shows the change.
|
||||
const tab = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
: null
|
||||
if (
|
||||
tab &&
|
||||
sendBrowserLayoutCommand(
|
||||
get,
|
||||
tab.worktreeId,
|
||||
{
|
||||
type: 'setBrowserTabProfile',
|
||||
browserTabId: workspaceId,
|
||||
profileId,
|
||||
sessionPartition: sessionPartition ?? null
|
||||
})
|
||||
}
|
||||
},
|
||||
{ here: switchHere }
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
switchHere()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ export function projectHydratedBrowserSession(
|
||||
| 'tabsByWorktree'
|
||||
>,
|
||||
session: WorkspaceSessionState,
|
||||
validWorktreeIds: ReadonlySet<string>
|
||||
validWorktreeIds: ReadonlySet<string>,
|
||||
/** A workspace with no remembered browser tab keeps none, instead of restoring its first. */
|
||||
options: { unchosenStaysUnset?: true } = {}
|
||||
) {
|
||||
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
|
||||
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
|
||||
@@ -113,7 +115,9 @@ export function projectHydratedBrowserSession(
|
||||
activeBrowserTabIdByWorktree[worktreeId] =
|
||||
persistedTabId && validBrowserTabIds.has(persistedTabId)
|
||||
? persistedTabId
|
||||
: (tabs[0]?.id ?? null)
|
||||
: !persistedTabId && options.unchosenStaysUnset
|
||||
? null
|
||||
: (tabs[0]?.id ?? null)
|
||||
}
|
||||
|
||||
const activeWorktreeId = s.activeWorktreeId
|
||||
|
||||
@@ -1,56 +1,52 @@
|
||||
// Browser tab and page changes in a layout-owner build (phase C5). The window still draws first
|
||||
// and saves its whole copy; main records each change under the window's own ids, so a later
|
||||
// command naming the tab or page finds it. Nothing to undo on any answer: the window's next save
|
||||
// carries the change when main cannot take it.
|
||||
// A browser page create, close, convert or profile switch in a layout-owner build: the window sends
|
||||
// the command first and changes nothing until main answers. Main's frame shows the tab's own
|
||||
// fields (its pages, its profile); the window then writes what only it holds (each page's state).
|
||||
// A refusal leaves the tab as it was.
|
||||
|
||||
import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import { recordLayoutApply, type sendLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement'
|
||||
import type { AppState } from '../../types'
|
||||
import { sendLayoutApply, type WindowLayoutCommand } from '@/runtime/layout-apply-send'
|
||||
import { beginPendingTab } from '@/runtime/layout-owner-pending-tabs'
|
||||
import type { BrowserSliceGet } from './browser-slice-contract'
|
||||
|
||||
type BrowserCommandState = Parameters<typeof sendLayoutApply>[0] &
|
||||
Pick<AppState, 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'layoutByWorktree'>
|
||||
type BrowserLayoutCommand = Extract<
|
||||
WindowLayoutCommand,
|
||||
{ type: 'openBrowserPage' | 'closeBrowserPage' | 'replaceBrowserPage' | 'setBrowserTabProfile' }
|
||||
>
|
||||
|
||||
/** The open of a browser tab the window just drew, placed where the window drew it. */
|
||||
export function recordBrowserTabOpen(state: BrowserCommandState, tab: BrowserWorkspace): void {
|
||||
const entry = browserTabEntry(state, tab.worktreeId, tab.id)
|
||||
const pageId = tab.pageIds?.[0]
|
||||
if (!entry || !pageId) {
|
||||
return
|
||||
}
|
||||
recordLayoutApply(state, tab.worktreeId, {
|
||||
type: 'openBrowserTab',
|
||||
tabId: entry.id,
|
||||
browserTabId: tab.id,
|
||||
pageId,
|
||||
profileId: tab.sessionProfileId ?? null,
|
||||
sessionPartition: tab.sessionPartition ?? null,
|
||||
// Open to the side: main makes the window's new split with the tab.
|
||||
...drawnTabPlacement(state, tab.worktreeId, entry.groupId, entry.id),
|
||||
createdAt: entry.createdAt,
|
||||
browserCreatedAt: tab.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
/** The close of a browser tab whose tab-bar entry the window is about to remove. */
|
||||
export function recordBrowserTabClose(
|
||||
state: BrowserCommandState,
|
||||
/**
|
||||
* False when main is not asked (a paired server owns the workspace's layout): the caller writes
|
||||
* the change as today. `here` is today's write, for a twin main may route to a paired server;
|
||||
* `shown` runs once main's frame is in.
|
||||
*/
|
||||
export function sendBrowserLayoutCommand(
|
||||
get: BrowserSliceGet,
|
||||
worktreeId: string,
|
||||
browserTabId: string,
|
||||
reason: 'user' | 'cleanup'
|
||||
): void {
|
||||
const entry = browserTabEntry(state, worktreeId, browserTabId)
|
||||
if (entry) {
|
||||
recordLayoutApply(state, worktreeId, {
|
||||
type: 'closeTabs',
|
||||
tabIds: [entry.id],
|
||||
recordClose: reason
|
||||
})
|
||||
command: BrowserLayoutCommand,
|
||||
write: { here: () => void; shown?: () => void }
|
||||
): boolean {
|
||||
// A page being added is pending on its tab: a caller acting on the tab's pages waits for it.
|
||||
const settle =
|
||||
command.type === 'openBrowserPage'
|
||||
? beginPendingTab(command.pageId, worktreeId, command.browserTabId, { kind: 'open' })
|
||||
: () => {}
|
||||
const sent = sendLayoutApply(get(), worktreeId, command, {
|
||||
apply: () => {
|
||||
try {
|
||||
write.shown?.()
|
||||
} finally {
|
||||
settle(command.browserTabId)
|
||||
}
|
||||
},
|
||||
asToday: () => {
|
||||
try {
|
||||
write.here()
|
||||
} finally {
|
||||
settle(command.browserTabId)
|
||||
}
|
||||
},
|
||||
refused: () => settle(null)
|
||||
})
|
||||
if (!sent) {
|
||||
settle(null)
|
||||
}
|
||||
}
|
||||
|
||||
function browserTabEntry(state: BrowserCommandState, worktreeId: string, browserTabId: string) {
|
||||
return state.unifiedTabsByWorktree[worktreeId]?.find(
|
||||
(entry) => entry.contentType === 'browser' && entry.entityId === browserTabId
|
||||
)
|
||||
return sent
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos
|
||||
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
|
||||
import { isLocalBrowserPageOwner } from './browser-host-state'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { sendBrowserLayoutCommand } from './browser-layout-owner-commands'
|
||||
|
||||
export function createBrowserPageConversionActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -40,102 +40,122 @@ export function createBrowserPageConversionActions(
|
||||
? runtimeEnvironmentIdOrLocalUnlessTwin(get(), oldPageForOwnership.worktreeId)
|
||||
: null
|
||||
)
|
||||
let converted: BrowserPage | null = null
|
||||
let docPageIdToRelease: string | null = null
|
||||
let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null
|
||||
set((s) => {
|
||||
const plan = planBrowserPageConversion(s, pageId, target, options)
|
||||
if (!plan) {
|
||||
return s
|
||||
}
|
||||
converted = plan.newPage
|
||||
// Why collected rather than released inside the reducer: revoking is main-process work, and
|
||||
// it must happen exactly once even if a later set() retries the reducer.
|
||||
if (plan.oldPage.docLocation) {
|
||||
docPageIdToRelease = plan.oldPage.id
|
||||
}
|
||||
const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id]
|
||||
if (remoteHandle) {
|
||||
remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle }
|
||||
}
|
||||
const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId }
|
||||
delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
delete nextBrowserAnnotationsByPageId[plan.oldPage.id]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id]
|
||||
const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId }
|
||||
delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id]
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[plan.workspace.id]: plan.nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[plan.workspace.worktreeId]: (
|
||||
s.browserTabsByWorktree[plan.workspace.worktreeId] ?? []
|
||||
).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab))
|
||||
},
|
||||
pendingAddressBarFocusByPageId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByPageId).filter(
|
||||
([pendingPageId]) => pendingPageId !== plan.oldPage.id
|
||||
)
|
||||
),
|
||||
pendingAddressBarFocusByTabId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByTabId).filter(
|
||||
([pendingPageId]) => pendingPageId !== plan.oldPage.id
|
||||
)
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
})
|
||||
// Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does
|
||||
// not track, so the initializers' null narrowing would otherwise read these as never.
|
||||
const newPage = converted as BrowserPage | null
|
||||
const remoteClose = remotePageToClose as {
|
||||
worktreeId: string
|
||||
handle: RemoteBrowserPageHandle
|
||||
} | null
|
||||
if (!newPage) {
|
||||
return null
|
||||
}
|
||||
if (remoteClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remoteClose.worktreeId,
|
||||
remoteClose.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
)
|
||||
}
|
||||
// Why after the reducer: a closed document must stop being readable, and the grant is the only
|
||||
// authority the preview scheme honors — but the store row has to stop naming it first.
|
||||
if (docPageIdToRelease) {
|
||||
releaseDocPreviewGrant(docPageIdToRelease)
|
||||
}
|
||||
const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId)
|
||||
// Switch point: main puts the new page in the old one's place.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceAfter) {
|
||||
recordLayoutApply(get(), workspaceAfter.worktreeId, {
|
||||
type: 'replaceBrowserPage',
|
||||
browserTabId: workspaceAfter.id,
|
||||
pageId,
|
||||
newPageId: newPage.id
|
||||
const convertHere = (newPageId?: string): BrowserPage | null => {
|
||||
let converted: BrowserPage | null = null
|
||||
let docPageIdToRelease: string | null = null
|
||||
let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null
|
||||
set((s) => {
|
||||
const plan = planBrowserPageConversion(s, pageId, target, {
|
||||
...options,
|
||||
...(newPageId ? { newPageId } : {})
|
||||
})
|
||||
if (!plan) {
|
||||
return s
|
||||
}
|
||||
converted = plan.newPage
|
||||
// Why collected rather than released inside the reducer: revoking is main-process work, and
|
||||
// it must happen exactly once even if a later set() retries the reducer.
|
||||
if (plan.oldPage.docLocation) {
|
||||
docPageIdToRelease = plan.oldPage.id
|
||||
}
|
||||
const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id]
|
||||
if (remoteHandle) {
|
||||
remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle }
|
||||
}
|
||||
const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId }
|
||||
delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
delete nextBrowserAnnotationsByPageId[plan.oldPage.id]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id]
|
||||
const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId }
|
||||
delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id]
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[plan.workspace.id]: plan.nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[plan.workspace.worktreeId]: (
|
||||
s.browserTabsByWorktree[plan.workspace.worktreeId] ?? []
|
||||
).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab))
|
||||
},
|
||||
pendingAddressBarFocusByPageId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByPageId).filter(
|
||||
([pendingPageId]) => pendingPageId !== plan.oldPage.id
|
||||
)
|
||||
),
|
||||
pendingAddressBarFocusByTabId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByTabId).filter(
|
||||
([pendingPageId]) => pendingPageId !== plan.oldPage.id
|
||||
)
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
})
|
||||
}
|
||||
if (workspaceAfter?.activePageId === newPage.id) {
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find(
|
||||
(entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId
|
||||
)
|
||||
if (item) {
|
||||
get().setTabLabel(item.id, newPage.title)
|
||||
// Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does
|
||||
// not track, so the initializers' null narrowing would otherwise read these as never.
|
||||
const newPage = converted as BrowserPage | null
|
||||
const remoteClose = remotePageToClose as {
|
||||
worktreeId: string
|
||||
handle: RemoteBrowserPageHandle
|
||||
} | null
|
||||
if (!newPage) {
|
||||
return null
|
||||
}
|
||||
if (remoteClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remoteClose.worktreeId,
|
||||
remoteClose.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
)
|
||||
}
|
||||
// Why after the reducer: a closed document must stop being readable, and the grant is the only
|
||||
// authority the preview scheme honors — but the store row has to stop naming it first.
|
||||
if (docPageIdToRelease) {
|
||||
releaseDocPreviewGrant(docPageIdToRelease)
|
||||
}
|
||||
const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId)
|
||||
if (workspaceAfter?.activePageId === newPage.id) {
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find(
|
||||
(entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId
|
||||
)
|
||||
if (item) {
|
||||
get().setTabLabel(item.id, newPage.title)
|
||||
}
|
||||
}
|
||||
return newPage
|
||||
}
|
||||
return newPage
|
||||
// Switch point: main puts the new page in the old one's place; the page shows once it did.
|
||||
const sentPlan = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? planBrowserPageConversion(get(), pageId, target, options)
|
||||
: null
|
||||
if (
|
||||
sentPlan &&
|
||||
sendBrowserLayoutCommand(
|
||||
get,
|
||||
sentPlan.workspace.worktreeId,
|
||||
{
|
||||
type: 'replaceBrowserPage',
|
||||
browserTabId: sentPlan.workspace.id,
|
||||
pageId,
|
||||
newPageId: sentPlan.newPage.id
|
||||
},
|
||||
{
|
||||
here: () => convertHere(sentPlan.newPage.id),
|
||||
shown: () => convertHere(sentPlan.newPage.id)
|
||||
}
|
||||
)
|
||||
) {
|
||||
return sentPlan.newPage
|
||||
}
|
||||
return convertHere()
|
||||
},
|
||||
|
||||
reopenClosedBrowserPage: (workspaceId) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos
|
||||
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
|
||||
import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { sendBrowserLayoutCommand } from './browser-layout-owner-commands'
|
||||
|
||||
export function createBrowserPageCreateActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -39,189 +39,212 @@ export function createBrowserPageCreateActions(
|
||||
if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) {
|
||||
admitBrowserPageMount(page.id)
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const nextPages = [...pages, page]
|
||||
const nextWorkspace = mirrorWorkspaceFromActivePage(
|
||||
{
|
||||
...workspace,
|
||||
activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id),
|
||||
pageIds: nextPages.map((entry) => entry.id)
|
||||
},
|
||||
nextPages
|
||||
)
|
||||
const shouldUpdateGlobalActiveSurface =
|
||||
shouldActivate &&
|
||||
s.activeWorktreeId === workspace.worktreeId &&
|
||||
s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId
|
||||
const shouldFocusAddressBar =
|
||||
shouldUpdateGlobalActiveSurface &&
|
||||
!page.docLocation &&
|
||||
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)
|
||||
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[workspaceId]: nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
|
||||
(tab) => (tab.id === workspaceId ? nextWorkspace : tab)
|
||||
)
|
||||
},
|
||||
pendingAddressBarFocusByPageId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByPageId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByTabId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByTabId
|
||||
const addPage = (): void => {
|
||||
// The tab may have closed while main was asked.
|
||||
const current = findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
})
|
||||
set((s) => {
|
||||
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const nextPages = [...pages, page]
|
||||
const nextWorkspace = mirrorWorkspaceFromActivePage(
|
||||
{
|
||||
...current,
|
||||
activePageId: shouldActivate ? page.id : (current.activePageId ?? page.id),
|
||||
pageIds: nextPages.map((entry) => entry.id)
|
||||
},
|
||||
nextPages
|
||||
)
|
||||
const shouldUpdateGlobalActiveSurface =
|
||||
shouldActivate &&
|
||||
s.activeWorktreeId === current.worktreeId &&
|
||||
s.activeBrowserTabIdByWorktree[current.worktreeId] === workspaceId
|
||||
const shouldFocusAddressBar =
|
||||
shouldUpdateGlobalActiveSurface &&
|
||||
!page.docLocation &&
|
||||
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)
|
||||
|
||||
// Switch point: main records the page under its id before any later command names it.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordLayoutApply(get(), workspace.worktreeId, {
|
||||
type: 'openBrowserPage',
|
||||
browserTabId: workspaceId,
|
||||
pageId: page.id
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[workspaceId]: nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[current.worktreeId]: (s.browserTabsByWorktree[current.worktreeId] ?? []).map(
|
||||
(tab) => (tab.id === workspaceId ? nextWorkspace : tab)
|
||||
)
|
||||
},
|
||||
pendingAddressBarFocusByPageId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByPageId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByTabId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByTabId
|
||||
}
|
||||
})
|
||||
}
|
||||
const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
if (nextWorkspace?.activePageId === page.id) {
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
|
||||
if (item) {
|
||||
get().setTabLabel(item.id, page.title)
|
||||
|
||||
const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
|
||||
if (nextWorkspace?.activePageId === page.id) {
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
|
||||
if (item) {
|
||||
get().setTabLabel(item.id, page.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Switch point: main adds the page to the tab first; the window shows it once main did.
|
||||
if (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN &&
|
||||
sendBrowserLayoutCommand(
|
||||
get,
|
||||
workspace.worktreeId,
|
||||
{ type: 'openBrowserPage', browserTabId: workspaceId, pageId: page.id },
|
||||
{ here: addPage, shown: addPage }
|
||||
)
|
||||
) {
|
||||
return page
|
||||
}
|
||||
addPage()
|
||||
return page
|
||||
},
|
||||
|
||||
closeBrowserPage: (pageId) => {
|
||||
let closedWorkspaceIdForLabel: string | null = null
|
||||
let docPageIdToRelease: string | null = null
|
||||
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
|
||||
set((s) => {
|
||||
const page = findPage(s.browserPagesByWorkspace, pageId)
|
||||
if (!page) {
|
||||
return s
|
||||
}
|
||||
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
|
||||
if (!workspace) {
|
||||
return s
|
||||
}
|
||||
closedWorkspaceIdForLabel = page.workspaceId
|
||||
docPageIdToRelease = page.docLocation ? page.id : null
|
||||
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
|
||||
const nextPages = currentPages.filter((entry) => entry.id !== pageId)
|
||||
const closedIdx = currentPages.findIndex((entry) => entry.id === pageId)
|
||||
const nextActivePageId =
|
||||
workspace.activePageId === pageId
|
||||
? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null)
|
||||
: workspace.activePageId
|
||||
const nextWorkspace = mirrorWorkspaceFromActivePage(
|
||||
{
|
||||
...workspace,
|
||||
activePageId: nextActivePageId,
|
||||
pageIds: nextPages.map((entry) => entry.id)
|
||||
},
|
||||
nextPages
|
||||
)
|
||||
const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId]
|
||||
if (remoteHandle) {
|
||||
remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle })
|
||||
}
|
||||
const nextRemoteBrowserPageHandlesByPageId = {
|
||||
...s.remoteBrowserPageHandlesByPageId
|
||||
}
|
||||
delete nextRemoteBrowserPageHandlesByPageId[pageId]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
delete nextBrowserAnnotationsByPageId[pageId]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[pageId]
|
||||
const nextBrowserCertificateFailuresByPageId = {
|
||||
...s.browserCertificateFailuresByPageId
|
||||
}
|
||||
delete nextBrowserCertificateFailuresByPageId[pageId]
|
||||
const closeHere = (): void => {
|
||||
let closedWorkspaceIdForLabel: string | null = null
|
||||
let docPageIdToRelease: string | null = null
|
||||
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
|
||||
set((s) => {
|
||||
const page = findPage(s.browserPagesByWorkspace, pageId)
|
||||
if (!page) {
|
||||
return s
|
||||
}
|
||||
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
|
||||
if (!workspace) {
|
||||
return s
|
||||
}
|
||||
closedWorkspaceIdForLabel = page.workspaceId
|
||||
docPageIdToRelease = page.docLocation ? page.id : null
|
||||
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
|
||||
const nextPages = currentPages.filter((entry) => entry.id !== pageId)
|
||||
const closedIdx = currentPages.findIndex((entry) => entry.id === pageId)
|
||||
const nextActivePageId =
|
||||
workspace.activePageId === pageId
|
||||
? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null)
|
||||
: workspace.activePageId
|
||||
const nextWorkspace = mirrorWorkspaceFromActivePage(
|
||||
{
|
||||
...workspace,
|
||||
activePageId: nextActivePageId,
|
||||
pageIds: nextPages.map((entry) => entry.id)
|
||||
},
|
||||
nextPages
|
||||
)
|
||||
const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId]
|
||||
if (remoteHandle) {
|
||||
remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle })
|
||||
}
|
||||
const nextRemoteBrowserPageHandlesByPageId = {
|
||||
...s.remoteBrowserPageHandlesByPageId
|
||||
}
|
||||
delete nextRemoteBrowserPageHandlesByPageId[pageId]
|
||||
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
|
||||
const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId }
|
||||
delete nextBrowserAnnotationsByPageId[pageId]
|
||||
delete nextBrowserAnnotationMarkerIdsByPageId[pageId]
|
||||
const nextBrowserCertificateFailuresByPageId = {
|
||||
...s.browserCertificateFailuresByPageId
|
||||
}
|
||||
delete nextBrowserCertificateFailuresByPageId[pageId]
|
||||
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[workspace.id]: nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
|
||||
(tab) => (tab.id === workspace.id ? nextWorkspace : tab)
|
||||
)
|
||||
},
|
||||
recentlyClosedBrowserPagesByWorkspace: {
|
||||
...s.recentlyClosedBrowserPagesByWorkspace,
|
||||
[workspace.id]: [
|
||||
page,
|
||||
...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter(
|
||||
(entry) => entry.id !== page.id
|
||||
return {
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[workspace.id]: nextPages
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
|
||||
(tab) => (tab.id === workspace.id ? nextWorkspace : tab)
|
||||
)
|
||||
].slice(0, 10)
|
||||
},
|
||||
pendingAddressBarFocusByPageId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByPageId).filter(
|
||||
([pendingPageId]) => pendingPageId !== pageId
|
||||
)
|
||||
),
|
||||
pendingAddressBarFocusByTabId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByTabId).filter(
|
||||
([pendingPageId]) => pendingPageId !== pageId
|
||||
)
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
},
|
||||
recentlyClosedBrowserPagesByWorkspace: {
|
||||
...s.recentlyClosedBrowserPagesByWorkspace,
|
||||
[workspace.id]: [
|
||||
page,
|
||||
...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter(
|
||||
(entry) => entry.id !== page.id
|
||||
)
|
||||
].slice(0, 10)
|
||||
},
|
||||
pendingAddressBarFocusByPageId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByPageId).filter(
|
||||
([pendingPageId]) => pendingPageId !== pageId
|
||||
)
|
||||
),
|
||||
pendingAddressBarFocusByTabId: Object.fromEntries(
|
||||
Object.entries(s.pendingAddressBarFocusByTabId).filter(
|
||||
([pendingPageId]) => pendingPageId !== pageId
|
||||
)
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
|
||||
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
|
||||
}
|
||||
})
|
||||
|
||||
for (const remotePage of remotePagesToClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remotePage.worktreeId,
|
||||
remotePage.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
for (const remotePage of remotePagesToClose) {
|
||||
closeRemoteBrowserPageInOwningEnvironment(
|
||||
remotePage.worktreeId,
|
||||
remotePage.handle,
|
||||
get().recordClientHostedBrowserCloseIntents
|
||||
if (docPageIdToRelease) {
|
||||
releaseDocPreviewGrant(docPageIdToRelease)
|
||||
}
|
||||
|
||||
const closedWorkspaceId = closedWorkspaceIdForLabel
|
||||
if (!closedWorkspaceId) {
|
||||
return
|
||||
}
|
||||
const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId)
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId)
|
||||
if (item && workspace) {
|
||||
get().setTabLabel(item.id, workspace.title)
|
||||
}
|
||||
}
|
||||
const page = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? findPage(get().browserPagesByWorkspace, pageId)
|
||||
: null
|
||||
const owner = page ? findWorkspace(get().browserTabsByWorktree, page.workspaceId) : null
|
||||
// Switch point: main drops the page from its tab first; the window closes it once main did.
|
||||
if (
|
||||
page &&
|
||||
owner &&
|
||||
sendBrowserLayoutCommand(
|
||||
get,
|
||||
owner.worktreeId,
|
||||
{ type: 'closeBrowserPage', browserTabId: owner.id, pageId },
|
||||
{ here: closeHere, shown: closeHere }
|
||||
)
|
||||
}
|
||||
|
||||
if (docPageIdToRelease) {
|
||||
releaseDocPreviewGrant(docPageIdToRelease)
|
||||
}
|
||||
|
||||
const closedWorkspaceId = closedWorkspaceIdForLabel
|
||||
if (!closedWorkspaceId) {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId)
|
||||
// Switch point: main drops the page from its tab.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && workspace) {
|
||||
recordLayoutApply(get(), workspace.worktreeId, {
|
||||
type: 'closeBrowserPage',
|
||||
browserTabId: closedWorkspaceId,
|
||||
pageId
|
||||
})
|
||||
}
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId)
|
||||
if (item && workspace) {
|
||||
get().setTabLabel(item.id, workspace.title)
|
||||
}
|
||||
closeHere()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
ORCA_BROWSER_BLANK_URL
|
||||
} from '../../../../../shared/constants'
|
||||
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
|
||||
import type {
|
||||
BrowserSlice,
|
||||
BrowserSliceGet,
|
||||
BrowserSliceSet,
|
||||
CreateBrowserTabOptions
|
||||
} from './browser-slice-contract'
|
||||
import {
|
||||
assertManagedBrowserMaterializationAllowed,
|
||||
getClientCreationActionPolicy
|
||||
@@ -18,14 +19,20 @@ import {
|
||||
import { getBrowserSessionProfileHostId } from './browser-host-state'
|
||||
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner'
|
||||
import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
|
||||
import { ownsGlobalSelection } from '../../global-selection-owner'
|
||||
import {
|
||||
getRegisteredPairedBrowserTabCreator,
|
||||
loadPairedBrowserTabCreator
|
||||
} from './paired-browser-tab-creator'
|
||||
import { findGroupAndWorktree } from '../tab-group-state'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
|
||||
import { recordBrowserTabOpen } from './browser-layout-owner-commands'
|
||||
import { openBrowserTabThroughLayoutOwner } from './browser-tab-open-through-layout-owner'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
ORCA_BROWSER_BLANK_URL
|
||||
} from '../../../../../shared/constants'
|
||||
import type { AppState } from '../../types'
|
||||
import { ownsGlobalSelection } from '../../global-selection-owner'
|
||||
|
||||
export function createBrowserTabActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -80,105 +87,56 @@ export function createBrowserTabActions(
|
||||
options?.sessionPartition
|
||||
)
|
||||
|
||||
set((s) => {
|
||||
const existingTabs = s.browserTabsByWorktree[worktreeId] ?? []
|
||||
const nextTabBarOrder = (() => {
|
||||
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
|
||||
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
|
||||
const editorIds = s.openFiles
|
||||
.filter((file) => file.worktreeId === worktreeId)
|
||||
.map((file) => file.id)
|
||||
const browserIds = existingTabs.map((tab) => tab.id)
|
||||
const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds])
|
||||
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
|
||||
const inBase = new Set(base)
|
||||
for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) {
|
||||
if (!inBase.has(entryId)) {
|
||||
base.push(entryId)
|
||||
inBase.add(entryId)
|
||||
}
|
||||
}
|
||||
base.push(workspaceId)
|
||||
return base
|
||||
})()
|
||||
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId)
|
||||
const shouldFocusFloatingTab =
|
||||
shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
const shouldFocusAddressBar =
|
||||
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
|
||||
// Why the doc check and not just the url: a document page is blank by construction, and
|
||||
// the blank url is exactly what marks a New Tab as wanting the address bar it does not have.
|
||||
!page.docLocation &&
|
||||
(options?.focusAddressBar ??
|
||||
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
|
||||
|
||||
return {
|
||||
const drawHere = (): void => {
|
||||
set((s) => ({
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[worktreeId]: [...existingTabs, browserTab]
|
||||
[worktreeId]: [...(s.browserTabsByWorktree[worktreeId] ?? []), browserTab]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
...s.browserPagesByWorkspace,
|
||||
[workspaceId]: [page]
|
||||
},
|
||||
tabBarOrderByWorktree: {
|
||||
...s.tabBarOrderByWorktree,
|
||||
[worktreeId]: nextTabBarOrder
|
||||
},
|
||||
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
|
||||
activeBrowserTabIdByWorktree: {
|
||||
...s.activeBrowserTabIdByWorktree,
|
||||
[worktreeId]: shouldActivate
|
||||
? workspaceId
|
||||
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
|
||||
},
|
||||
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
|
||||
activeTabTypeByWorktree: shouldActivate
|
||||
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
|
||||
: s.activeTabTypeByWorktree,
|
||||
pendingAddressBarFocusByPageId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByPageId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByTabId,
|
||||
[workspaceId]: true,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByTabId
|
||||
}
|
||||
})
|
||||
...browserTabOpenView(s, worktreeId, browserTab, page, options)
|
||||
}))
|
||||
|
||||
const state = get()
|
||||
const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(t) => t.contentType === 'browser' && t.entityId === workspaceId
|
||||
)
|
||||
if (!alreadyHasUnifiedTab) {
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const created = state.createUnifiedTab(worktreeId, 'browser', {
|
||||
entityId: workspaceId,
|
||||
label: browserTab.title,
|
||||
targetGroupId: options?.targetGroupId,
|
||||
...(options?.split ? { split: options.split } : {}),
|
||||
...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}),
|
||||
// Why no routing-host default: a substituted host would disown the wrapper from its worktree.
|
||||
...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}),
|
||||
activate: shouldActivate
|
||||
})
|
||||
// Why: unified creation already selected the tab and recorded the visit; only the group moves.
|
||||
if (shouldActivate && created) {
|
||||
get().focusGroup(worktreeId, created.groupId)
|
||||
const state = get()
|
||||
const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(t) => t.contentType === 'browser' && t.entityId === workspaceId
|
||||
)
|
||||
if (!alreadyHasUnifiedTab) {
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const created = state.createUnifiedTab(worktreeId, 'browser', {
|
||||
entityId: workspaceId,
|
||||
label: browserTab.title,
|
||||
targetGroupId: options?.targetGroupId,
|
||||
...(options?.split ? { split: options.split } : {}),
|
||||
...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}),
|
||||
// Why no routing-host default: a substituted host would disown the wrapper from its worktree.
|
||||
...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}),
|
||||
activate: shouldActivate
|
||||
})
|
||||
// Why: unified creation already selected the tab and recorded the visit; only the group moves.
|
||||
if (shouldActivate && created) {
|
||||
get().focusGroup(worktreeId, created.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Switch point: main records the tab under these ids before any later command names it.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordBrowserTabOpen(get(), browserTab)
|
||||
// Switch point: main opens the tab under these ids and its frame draws it.
|
||||
if (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN &&
|
||||
openBrowserTabThroughLayoutOwner(get, {
|
||||
worktreeId,
|
||||
browserTab,
|
||||
page,
|
||||
options,
|
||||
showView: () => set((s) => browserTabOpenView(s, worktreeId, browserTab, page, options)),
|
||||
drawHere
|
||||
})
|
||||
) {
|
||||
return browserTab
|
||||
}
|
||||
drawHere()
|
||||
return browserTab
|
||||
},
|
||||
|
||||
@@ -281,3 +239,73 @@ export function createBrowserTabActions(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window's own view of a browser tab it opens: its tab-bar order entry, browser selection and
|
||||
// the address-bar focus a blank New Tab asks for; a layout-owner build writes it once drawn.
|
||||
function browserTabOpenView(
|
||||
s: AppState,
|
||||
worktreeId: string,
|
||||
browserTab: BrowserWorkspace,
|
||||
page: BrowserPage,
|
||||
options: CreateBrowserTabOptions | undefined
|
||||
): Partial<AppState> {
|
||||
const workspaceId = browserTab.id
|
||||
// Without this tab: a frame may already have drawn its record.
|
||||
const existingTabs = (s.browserTabsByWorktree[worktreeId] ?? []).filter(
|
||||
(tab) => tab.id !== workspaceId
|
||||
)
|
||||
const nextTabBarOrder = (() => {
|
||||
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
|
||||
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
|
||||
const editorIds = s.openFiles
|
||||
.filter((file) => file.worktreeId === worktreeId)
|
||||
.map((file) => file.id)
|
||||
const ids = [...terminalIds, ...editorIds, ...existingTabs.map((tab) => tab.id)]
|
||||
const allExistingIds = new Set(ids)
|
||||
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
|
||||
const inBase = new Set(base)
|
||||
return [...base, ...[...allExistingIds].filter((entryId) => !inBase.has(entryId)), workspaceId]
|
||||
})()
|
||||
|
||||
const shouldActivate = options?.activate ?? true
|
||||
const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId)
|
||||
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
const shouldFocusAddressBar =
|
||||
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
|
||||
// Why the doc check and not just the url: a document page is blank by construction, and
|
||||
// the blank url is exactly what marks a New Tab as wanting the address bar it does not have.
|
||||
!page.docLocation &&
|
||||
(options?.focusAddressBar ??
|
||||
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
|
||||
|
||||
return {
|
||||
tabBarOrderByWorktree: {
|
||||
...s.tabBarOrderByWorktree,
|
||||
[worktreeId]: nextTabBarOrder
|
||||
},
|
||||
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
|
||||
activeBrowserTabIdByWorktree: {
|
||||
...s.activeBrowserTabIdByWorktree,
|
||||
[worktreeId]: shouldActivate
|
||||
? workspaceId
|
||||
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
|
||||
},
|
||||
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
|
||||
activeTabTypeByWorktree: shouldActivate
|
||||
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
|
||||
: s.activeTabTypeByWorktree,
|
||||
pendingAddressBarFocusByPageId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByPageId,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByPageId,
|
||||
pendingAddressBarFocusByTabId: shouldFocusAddressBar
|
||||
? {
|
||||
...s.pendingAddressBarFocusByTabId,
|
||||
[workspaceId]: true,
|
||||
[page.id]: true
|
||||
}
|
||||
: s.pendingAddressBarFocusByTabId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// A browser tab close in a layout-owner build: main closes the tab-bar entry and its record first,
|
||||
// and its frame drops both. The window then runs today's close over the tab as it drew it (the
|
||||
// reopen list, host pages, document grants), since the frame already took the record away.
|
||||
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order'
|
||||
import type { AppState } from '../../types'
|
||||
import { pickNextActiveTab } from '../tab-group-state'
|
||||
import {
|
||||
getRecentlyClosedTabPosition,
|
||||
type RecentlyClosedTabPosition
|
||||
} from '../recently-closed-tab-position'
|
||||
import { closeContentTabThroughLayoutOwner } from '../tabs/content-tab-close-through-layout-owner'
|
||||
import type { BrowserSliceGet, RemoteBrowserPageHandle } from './browser-slice-contract'
|
||||
|
||||
/** A browser tab as the window drew it when it asked main to close it. */
|
||||
export type DrawnBrowserTab = {
|
||||
worktreeId: string
|
||||
record: BrowserWorkspace
|
||||
pages: BrowserPage[]
|
||||
handles: Record<string, RemoteBrowserPageHandle>
|
||||
/** Where it sat, for the reopen list; the frame has moved the rest on. */
|
||||
position: RecentlyClosedTabPosition | undefined
|
||||
}
|
||||
|
||||
/** `state` with the tab's pages and host handles back as drawn, for today's close to read. */
|
||||
export function withDrawnBrowserPages(state: AppState, drawn: DrawnBrowserTab): AppState {
|
||||
const { record } = drawn
|
||||
return {
|
||||
...state,
|
||||
browserPagesByWorkspace: {
|
||||
...state.browserPagesByWorkspace,
|
||||
[record.id]: state.browserPagesByWorkspace[record.id] ?? drawn.pages
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
...drawn.handles,
|
||||
...state.remoteBrowserPageHandlesByPageId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the close of the browser tab showing `browserTabId`; false when it shows in no tab-bar
|
||||
* entry or main is not asked (a paired server owns the workspace's layout): close it as today.
|
||||
* `closeHere` is today's close; given the drawn tab, it runs once main's frame dropped it.
|
||||
*/
|
||||
export function closeBrowserTabThroughLayoutOwner(
|
||||
get: BrowserSliceGet,
|
||||
browserTabId: string,
|
||||
reason: 'user' | 'cleanup',
|
||||
closeHere: (drawn?: DrawnBrowserTab) => void,
|
||||
landIfEmptied?: () => boolean
|
||||
): boolean {
|
||||
const state = get()
|
||||
const entry = Object.values(state.unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((tab) => tab.contentType === 'browser' && tab.entityId === browserTabId)
|
||||
const record = entry
|
||||
? state.browserTabsByWorktree[entry.worktreeId]?.find((tab) => tab.id === browserTabId)
|
||||
: undefined
|
||||
if (!entry || !record) {
|
||||
return false
|
||||
}
|
||||
const pages = state.browserPagesByWorkspace[browserTabId] ?? []
|
||||
const drawn: DrawnBrowserTab = {
|
||||
worktreeId: entry.worktreeId,
|
||||
record,
|
||||
pages,
|
||||
handles: Object.fromEntries(
|
||||
pages.flatMap((page) => {
|
||||
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
|
||||
return handle ? [[page.id, handle] as const] : []
|
||||
})
|
||||
),
|
||||
position: getRecentlyClosedTabPosition(state, entry.worktreeId, browserTabId)
|
||||
}
|
||||
const tabs = state.unifiedTabsByWorktree[entry.worktreeId] ?? []
|
||||
const group = state.groupsByWorktree[entry.worktreeId]?.find((g) => g.id === entry.groupId)
|
||||
const nextActiveTabId =
|
||||
group?.activeTabId === entry.id
|
||||
? pickNextActiveTab(dedupeTabOrder(group.tabOrder), group.recentTabIds, entry.id, tabs)
|
||||
: (group?.activeTabId ?? null)
|
||||
return closeContentTabThroughLayoutOwner(get, entry, {
|
||||
nextActiveTabId,
|
||||
recordClose: reason,
|
||||
closeHere: () => closeHere(),
|
||||
closed: () => closeHere(drawn),
|
||||
...(landIfEmptied ? { landIfEmptied } : {})
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
import { findWorkspace } from '../browser-page-records'
|
||||
import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs'
|
||||
import { isLocalBrowserPageOwner } from './browser-host-state'
|
||||
import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import { afterPendingEntityTabs, hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs'
|
||||
|
||||
export function createBrowserTabFocusActions(
|
||||
set: BrowserSliceSet,
|
||||
@@ -34,6 +36,10 @@ export function createBrowserTabFocusActions(
|
||||
if (!entryToRestore) {
|
||||
return null
|
||||
}
|
||||
/** The restored record; a layout-owner build's, before main's frame drew it, as proposed. */
|
||||
const restoredRecord = (restored: BrowserWorkspace): BrowserWorkspace | null =>
|
||||
get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ??
|
||||
(hasPendingEntityTab(worktreeId, restored.id) ? restored : null)
|
||||
|
||||
const snap = entryToRestore.workspace
|
||||
const pages = entryToRestore.pages
|
||||
@@ -49,10 +55,12 @@ export function createBrowserTabFocusActions(
|
||||
...(snap.docLocation ? { docLocation: snap.docLocation } : {}),
|
||||
targetGroupId: entryToRestore.position?.groupId
|
||||
})
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position)
|
||||
return (
|
||||
get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
|
||||
const position = entryToRestore.position
|
||||
// A layout-owner build's tab moves once main's frame drew it.
|
||||
afterPendingEntityTabs(worktreeId, restored.id, () =>
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position)
|
||||
)
|
||||
return restoredRecord(restored)
|
||||
}
|
||||
|
||||
// Why: append remaining pages in original order so multi-page workspaces preserve their page sequence.
|
||||
@@ -67,29 +75,34 @@ export function createBrowserTabFocusActions(
|
||||
...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {})
|
||||
})
|
||||
|
||||
for (const p of restPages) {
|
||||
get().createBrowserPage(restored.id, p.url, {
|
||||
activate: false,
|
||||
title: p.title,
|
||||
browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId,
|
||||
...(p.docLocation ? { docLocation: p.docLocation } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index.
|
||||
const activePageId = snap.activePageId
|
||||
if (activePageId) {
|
||||
const restoredPages = get().browserPagesByWorkspace[restored.id] ?? []
|
||||
const activePageIndex = pages.findIndex((orig) => orig.id === activePageId)
|
||||
const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null
|
||||
if (targetPage && targetPage.id !== restoredPages[0]?.id) {
|
||||
get().setActiveBrowserPage(restored.id, targetPage.id)
|
||||
const position = entryToRestore.position
|
||||
const selectRestoredPage = (): void => {
|
||||
// Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index.
|
||||
const activePageId = snap.activePageId
|
||||
if (activePageId) {
|
||||
const restoredPages = get().browserPagesByWorkspace[restored.id] ?? []
|
||||
const activePageIndex = pages.findIndex((orig) => orig.id === activePageId)
|
||||
const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null
|
||||
if (targetPage && targetPage.id !== restoredPages[0]?.id) {
|
||||
get().setActiveBrowserPage(restored.id, targetPage.id)
|
||||
}
|
||||
}
|
||||
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position)
|
||||
}
|
||||
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position)
|
||||
|
||||
return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
|
||||
// A layout-owner build adds the pages once main drew the tab, and selects once it added them.
|
||||
afterPendingEntityTabs(worktreeId, restored.id, () => {
|
||||
for (const p of restPages) {
|
||||
get().createBrowserPage(restored.id, p.url, {
|
||||
activate: false,
|
||||
title: p.title,
|
||||
browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId,
|
||||
...(p.docLocation ? { docLocation: p.docLocation } : {})
|
||||
})
|
||||
}
|
||||
afterPendingEntityTabs(worktreeId, restored.id, selectRestoredPage)
|
||||
})
|
||||
return restoredRecord(restored)
|
||||
},
|
||||
|
||||
setActiveBrowserTab: (tabId, targetWorktreeId) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// A browser tab open in a layout-owner build: the window proposes the tab-bar entry, record and
|
||||
// page ids and sends the open; main's frame draws the tab with the window's page state, and only
|
||||
// then does the window select it. A caller acting on the record waits with `afterPendingTab`.
|
||||
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open'
|
||||
import { contentTabOpenPlacement } from '../tabs/content-tab-open-placement'
|
||||
import type { BrowserSliceGet, CreateBrowserTabOptions } from './browser-slice-contract'
|
||||
|
||||
type BrowserTabOpen = {
|
||||
worktreeId: string
|
||||
browserTab: BrowserWorkspace
|
||||
page: BrowserPage
|
||||
options: CreateBrowserTabOptions | undefined
|
||||
/** The window's own view of the tab, written once main's frame drew it. */
|
||||
showView: () => void
|
||||
/** Today's draw, for a twin main may route to a paired server. */
|
||||
drawHere: () => void
|
||||
}
|
||||
|
||||
/** False when main is not asked (a paired server owns the workspace's layout): draw as today. */
|
||||
export function openBrowserTabThroughLayoutOwner(
|
||||
get: BrowserSliceGet,
|
||||
open: BrowserTabOpen
|
||||
): boolean {
|
||||
const { worktreeId, browserTab, page, options } = open
|
||||
const state = get()
|
||||
const tabId = createBrowserUuid()
|
||||
const placement = contentTabOpenPlacement(state, worktreeId, tabId, {
|
||||
targetGroupId: options?.targetGroupId,
|
||||
afterTabId: options?.afterTabId,
|
||||
split: options?.split,
|
||||
executionHostId: options?.executionHostId
|
||||
})
|
||||
if (!placement) {
|
||||
return false
|
||||
}
|
||||
const activate = options?.activate ?? true
|
||||
return openContentTabThroughLayoutOwner(state, {
|
||||
worktreeId,
|
||||
entityId: browserTab.id,
|
||||
command: {
|
||||
type: 'openBrowserTab',
|
||||
tabId,
|
||||
browserTabId: browserTab.id,
|
||||
pageId: page.id,
|
||||
profileId: browserTab.sessionProfileId ?? null,
|
||||
sessionPartition: browserTab.sessionPartition ?? null,
|
||||
...placement
|
||||
},
|
||||
facts: { label: browserTab.title, browser: { record: browserTab, pages: [page] } },
|
||||
shown: (shownId) => {
|
||||
open.showView()
|
||||
if (!activate) {
|
||||
return
|
||||
}
|
||||
get().activateTab(shownId, { worktreeId })
|
||||
const groupId = get().unifiedTabsByWorktree[worktreeId]?.find(
|
||||
(t) => t.id === shownId
|
||||
)?.groupId
|
||||
if (groupId) {
|
||||
get().focusGroup(worktreeId, groupId)
|
||||
}
|
||||
},
|
||||
drawHere: () => {
|
||||
open.drawHere()
|
||||
return (
|
||||
get().unifiedTabsByWorktree[worktreeId]?.find(
|
||||
(t) => t.contentType === 'browser' && t.entityId === browserTab.id
|
||||
)?.id ?? null
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// Browser, chat and simulator tabs in a layout-owner build (ORCA_LAYOUT_OWNER=main): the window
|
||||
// sends each open, close and page change to main first and draws the tab only from main's frame,
|
||||
// so nothing shows before main answers and a refused close leaves the tab. Switched off, the
|
||||
// window draws them itself and sends nothing.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted((): { store: unknown } => ({ store: null }))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
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('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab'
|
||||
import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab'
|
||||
import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply'
|
||||
import { resetLocalStructuredSessionVersionForTests } from '@/runtime/local-structured-session-tabs-sync/inventory-generation-fence'
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
const ROOT = 'group-root'
|
||||
|
||||
let store: ReturnType<typeof createTestStore>
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
store = createTestStore()
|
||||
mocks.store = store
|
||||
seedStore(store, {
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT,
|
||||
groupsByWorktree: { [WT]: [{ id: ROOT, worktreeId: WT, activeTabId: null, tabOrder: [] }] },
|
||||
activeGroupIdByWorktree: { [WT]: ROOT },
|
||||
layoutByWorktree: { [WT]: { type: 'leaf', groupId: ROOT } },
|
||||
unifiedTabsByWorktree: { [WT]: [] }
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main.close()
|
||||
resetLocalStructuredSessionVersionForTests()
|
||||
})
|
||||
|
||||
const tabs = () => store.getState().unifiedTabsByWorktree[WT] ?? []
|
||||
const tabOf = (entityId: string) => tabs().find((tab) => tab.entityId === entityId)
|
||||
const mainTabs = () => layoutApplyTestTransport.mainModel?.workspaces[WT]?.tabs ?? []
|
||||
|
||||
function hostListing(chats: string[]): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WT,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const TERMINAL = 'terminal-1'
|
||||
|
||||
/** The workspace with one terminal in the root group, drawn from main again. */
|
||||
async function redrawWithTerminal(): Promise<void> {
|
||||
main.close()
|
||||
seedStore(store, {
|
||||
tabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: TERMINAL,
|
||||
ptyId: null,
|
||||
worktreeId: WT,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: TERMINAL,
|
||||
entityId: TERMINAL,
|
||||
groupId: ROOT,
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal 1',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [{ id: ROOT, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL] }]
|
||||
}
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
}
|
||||
|
||||
/** Opens with main's answer held back, then answers it; returns the opened entity id. */
|
||||
async function openHeld(open: () => string): Promise<string> {
|
||||
layoutApplyTestTransport.answerWith('pending')
|
||||
const entityId = open()
|
||||
await main.settle()
|
||||
// Nothing drawn before main answers.
|
||||
expect(tabOf(entityId)).toBeUndefined()
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
layoutApplyTestTransport.answerPending()
|
||||
await main.settle()
|
||||
return entityId
|
||||
}
|
||||
|
||||
describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs drawn from main’s frames', () => {
|
||||
it('shows a browser tab, its label and page only once main’s frame drew it, then selects it', async () => {
|
||||
const id = await openHeld(
|
||||
() => store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Example' }).id
|
||||
)
|
||||
const entry = tabOf(id)!
|
||||
expect(entry).toMatchObject({ contentType: 'browser', label: 'Example', groupId: ROOT })
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserTab')[0]).toMatchObject({
|
||||
tabId: entry.id,
|
||||
browserTabId: id,
|
||||
groupId: ROOT
|
||||
})
|
||||
const pages = store.getState().browserPagesByWorkspace[id]!
|
||||
expect(pages.map((page) => page.url)).toEqual(['https://example.com/'])
|
||||
expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({
|
||||
id,
|
||||
pageIds: [pages[0]!.id],
|
||||
title: 'Example'
|
||||
})
|
||||
expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(entry.id)
|
||||
expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(id)
|
||||
})
|
||||
|
||||
it('closes a browser tab once main did, keeps it on the reopen list, and reopens it', async () => {
|
||||
const id = store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Ex' }).id
|
||||
await main.settle()
|
||||
layoutApplyTestTransport.answerWith('pending')
|
||||
store.getState().closeBrowserTab(id)
|
||||
await main.settle()
|
||||
expect(tabOf(id)).toBeDefined()
|
||||
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
layoutApplyTestTransport.answerPending()
|
||||
await main.settle()
|
||||
expect(tabOf(id)).toBeUndefined()
|
||||
expect(store.getState().browserTabsByWorktree[WT] ?? []).toEqual([])
|
||||
expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({
|
||||
recordClose: 'user'
|
||||
})
|
||||
expect(
|
||||
store.getState().recentlyClosedBrowserTabsByWorktree[WT]?.map((entry) => entry.workspace.id)
|
||||
).toEqual([id])
|
||||
|
||||
const reopened = store.getState().reopenClosedBrowserTab(WT)
|
||||
await main.settle()
|
||||
expect(reopened).not.toBeNull()
|
||||
expect(tabOf(reopened!.id)).toMatchObject({ label: 'Ex' })
|
||||
expect(store.getState().browserPagesByWorkspace[reopened!.id]?.[0]?.url).toBe(
|
||||
'https://example.com/'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a cleanup close off the reopen list, in main and in the window', async () => {
|
||||
const id = store.getState().createBrowserTab(WT, 'about:blank').id
|
||||
await main.settle()
|
||||
store.getState().closeBrowserTab(id, { reason: 'cleanup' })
|
||||
await main.settle()
|
||||
expect(tabOf(id)).toBeUndefined()
|
||||
expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({
|
||||
recordClose: 'cleanup'
|
||||
})
|
||||
expect(store.getState().recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('adds, converts and closes a page and switches a profile only once main did', async () => {
|
||||
const id = store.getState().createBrowserTab(WT, 'https://a.test/').id
|
||||
await main.settle()
|
||||
layoutApplyTestTransport.answerWith('pending')
|
||||
const page = store.getState().createBrowserPage(id, 'https://b.test/')!
|
||||
await main.settle()
|
||||
expect(store.getState().browserPagesByWorkspace[id]).toHaveLength(1)
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
layoutApplyTestTransport.answerPending()
|
||||
await main.settle()
|
||||
expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).toContain(page.id)
|
||||
expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual([
|
||||
expect.objectContaining({ browserTabId: id, pageId: page.id })
|
||||
])
|
||||
|
||||
store.getState().closeBrowserPage(page.id)
|
||||
await main.settle()
|
||||
expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).not.toContain(page.id)
|
||||
|
||||
store.getState().switchBrowserTabProfile(id, 'profile-2', 'persist:profile-2')
|
||||
await main.settle()
|
||||
expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({
|
||||
sessionProfileId: 'profile-2',
|
||||
sessionPartition: 'persist:profile-2'
|
||||
})
|
||||
expect(mainTabs().filter((tab) => tab.kind === 'browser')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens and closes a chat tab through main, and keeps one main refused to close', async () => {
|
||||
const sessionId = await openHeld(() => {
|
||||
openStructuredAgentSessionProvisionalTab({
|
||||
worktreeId: WT,
|
||||
executionHostId: 'local',
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
return 'chat-1'
|
||||
})
|
||||
const chat = tabOf(sessionId)!
|
||||
expect(chat).toMatchObject({ contentType: 'agent-session', label: expect.any(String) })
|
||||
expect(chat.label).not.toBe('')
|
||||
expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(chat.id)
|
||||
|
||||
// A deliberate freeze refuses before anything changes, so the tab stays.
|
||||
layoutApplyTestTransport.answerWith({ refuse: 'wsl_move_in_progress' })
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
expect(tabOf(sessionId)).toBeDefined()
|
||||
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
expect(tabOf(sessionId)).toBeUndefined()
|
||||
expect(mainTabs().some((tab) => tab.entityId === sessionId)).toBe(false)
|
||||
})
|
||||
|
||||
it('closes a window-only tab of a workspace main does not hold, as main’s tab_not_found does', async () => {
|
||||
const OTHER = 'repo1::/path/wt2'
|
||||
const tab = {
|
||||
id: 'browser-tab-1',
|
||||
entityId: 'browser-1',
|
||||
groupId: 'group-other',
|
||||
worktreeId: OTHER,
|
||||
contentType: 'browser' as const,
|
||||
label: 'Page',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
seedStore(store, {
|
||||
unifiedTabsByWorktree: { ...store.getState().unifiedTabsByWorktree, [OTHER]: [tab] },
|
||||
groupsByWorktree: {
|
||||
...store.getState().groupsByWorktree,
|
||||
[OTHER]: [{ id: 'group-other', worktreeId: OTHER, activeTabId: tab.id, tabOrder: [tab.id] }]
|
||||
}
|
||||
})
|
||||
layoutApplyTestTransport.answerWith({ refuse: 'workspace_not_found' })
|
||||
|
||||
store.getState().closeUnifiedTab(tab.id)
|
||||
await main.settle()
|
||||
|
||||
expect(layoutApplyTestTransport.sentOfType('closeTabs')).toEqual([
|
||||
expect.objectContaining({ workspace: OTHER, tabIds: [tab.id] })
|
||||
])
|
||||
expect(store.getState().unifiedTabsByWorktree[OTHER] ?? []).toEqual([])
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
})
|
||||
|
||||
it('draws a listing’s new chats in order once main places them; the listing sends nothing', async () => {
|
||||
await redrawWithTerminal()
|
||||
applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])])
|
||||
await main.settle()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
expect(tabOf('chat-a')).toBeUndefined()
|
||||
|
||||
// Main places listed chats itself (chatTabLayoutCommand): in the active
|
||||
// group, in listing order.
|
||||
const commands = ['chat-a', 'chat-b'].map((sessionId) => ({
|
||||
type: 'openAgentSessionTab' as const,
|
||||
workspace: WT,
|
||||
tabId: structuredAgentSessionTabId(sessionId),
|
||||
sessionId,
|
||||
agent: 'codex' as const,
|
||||
groupId: store.getState().activeGroupIdByWorktree[WT]
|
||||
}))
|
||||
for (const command of commands) {
|
||||
await layoutApplyTestTransport.call({ command })
|
||||
}
|
||||
await main.settle()
|
||||
expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b'])
|
||||
expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([
|
||||
TERMINAL,
|
||||
structuredAgentSessionTabId('chat-a'),
|
||||
structuredAgentSessionTabId('chat-b')
|
||||
])
|
||||
expect(mainTabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b'])
|
||||
})
|
||||
|
||||
it('opens a simulator tab to the right through main and surfaces it once drawn', async () => {
|
||||
store.getState().createBrowserTab(WT, 'about:blank')
|
||||
await main.settle()
|
||||
layoutApplyTestTransport.answerWith('pending')
|
||||
const tabId = ensureSimulatorTab(WT, { placement: 'rightSplit' })!
|
||||
// A second ask while main opens it names the same tab.
|
||||
expect(ensureSimulatorTab(WT, { placement: 'rightSplit' })).toBe(tabId)
|
||||
await main.settle()
|
||||
expect(tabs().some((tab) => tab.id === tabId)).toBe(false)
|
||||
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
layoutApplyTestTransport.answerPending()
|
||||
await main.settle()
|
||||
const simulator = tabs().find((tab) => tab.id === tabId)!
|
||||
expect(simulator).toMatchObject({ contentType: 'simulator' })
|
||||
expect(simulator.groupId).not.toBe(ROOT)
|
||||
expect(store.getState().activeGroupIdByWorktree[WT]).toBe(simulator.groupId)
|
||||
expect(layoutApplyTestTransport.sentOfType('openSimulatorTab')).toHaveLength(1)
|
||||
|
||||
store.getState().closeUnifiedTab(tabId)
|
||||
await main.settle()
|
||||
expect(tabs().some((tab) => tab.id === tabId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs with the switch off', () => {
|
||||
it('draws browser, chat and simulator tabs at once and sends nothing', () => {
|
||||
const id = store.getState().createBrowserTab(WT, 'https://example.com/').id
|
||||
openStructuredAgentSessionProvisionalTab({
|
||||
worktreeId: WT,
|
||||
executionHostId: 'local',
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
const simulator = ensureSimulatorTab(WT)
|
||||
expect(tabOf(id)).toBeDefined()
|
||||
expect(tabOf('chat-1')).toBeDefined()
|
||||
expect(tabs().some((tab) => tab.id === simulator)).toBe(true)
|
||||
store.getState().closeBrowserTab(id)
|
||||
expect(tabOf(id)).toBeUndefined()
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a listing’s new chats itself, in order, and sends nothing', async () => {
|
||||
await redrawWithTerminal()
|
||||
applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])])
|
||||
expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b'])
|
||||
expect(layoutApplyTestTransport.sent).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
// A workspace a paired server owns (its row names the server, as the app's catalog sets it): its
|
||||
// browser and chat tabs open and close as today in both builds, and never reach main's layout.
|
||||
// An SSH worktree is laid out by this machine's main but has no host to run a chat on.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import type * as RpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
store: ((): unknown => null)(),
|
||||
hostCalls: ((): { target: RuntimeClientTarget; method: string }[] => [])()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof RpcClientModule>()
|
||||
return {
|
||||
...actual,
|
||||
callRuntimeRpc: (target: RuntimeClientTarget, method: string, params?: unknown) => {
|
||||
if (target.kind === 'local' && method === 'layout.apply') {
|
||||
return layoutApplyTestTransport.call(params)
|
||||
}
|
||||
mocks.hostCalls.push({ target, method })
|
||||
return Promise.resolve({ ok: true })
|
||||
},
|
||||
ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined)
|
||||
}
|
||||
})
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
vi.mock('@/store', () => ({
|
||||
get useAppStore() {
|
||||
return mocks.store
|
||||
}
|
||||
}))
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
|
||||
import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab'
|
||||
import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner'
|
||||
|
||||
const SERVER = 'server-1'
|
||||
const SERVER_WT = 'repo-s::/srv/app'
|
||||
const LOCAL_WT = 'repo-l::/work/app'
|
||||
const SSH_WT = 'repo-h::/home/app'
|
||||
const SSH_HOST = toSshExecutionHostId('box')
|
||||
|
||||
let store: ReturnType<typeof createTestStore>
|
||||
let main: TabsDrawnFromMain
|
||||
|
||||
function group(worktreeId: string) {
|
||||
return [{ id: `group-${worktreeId}`, worktreeId, activeTabId: null, tabOrder: [] }]
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.hostCalls = []
|
||||
store = createTestStore()
|
||||
mocks.store = store
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
'repo-s': [
|
||||
makeWorktree({
|
||||
id: SERVER_WT,
|
||||
repoId: 'repo-s',
|
||||
path: '/srv/app',
|
||||
hostId: toRuntimeExecutionHostId(SERVER)
|
||||
})
|
||||
],
|
||||
'repo-l': [makeWorktree({ id: LOCAL_WT, repoId: 'repo-l', path: '/work/app' })],
|
||||
'repo-h': [
|
||||
makeWorktree({ id: SSH_WT, repoId: 'repo-h', path: '/home/app', hostId: SSH_HOST })
|
||||
]
|
||||
},
|
||||
activeWorktreeId: SERVER_WT,
|
||||
groupsByWorktree: {
|
||||
[SERVER_WT]: group(SERVER_WT),
|
||||
[LOCAL_WT]: group(LOCAL_WT),
|
||||
[SSH_WT]: group(SSH_WT)
|
||||
},
|
||||
activeGroupIdByWorktree: {
|
||||
[SERVER_WT]: `group-${SERVER_WT}`,
|
||||
[LOCAL_WT]: `group-${LOCAL_WT}`,
|
||||
[SSH_WT]: `group-${SSH_WT}`
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[SERVER_WT]: { type: 'leaf', groupId: `group-${SERVER_WT}` },
|
||||
[LOCAL_WT]: { type: 'leaf', groupId: `group-${LOCAL_WT}` },
|
||||
[SSH_WT]: { type: 'leaf', groupId: `group-${SSH_WT}` }
|
||||
},
|
||||
unifiedTabsByWorktree: { [SERVER_WT]: [], [LOCAL_WT]: [], [SSH_WT]: [] }
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main.close()
|
||||
})
|
||||
|
||||
const tabsOf = (worktreeId: string) => store.getState().unifiedTabsByWorktree[worktreeId] ?? []
|
||||
const sentFor = (worktreeId: string) =>
|
||||
layoutApplyTestTransport.sent.filter((command) => command.workspace === worktreeId)
|
||||
|
||||
describe("a paired server's workspace", () => {
|
||||
it('opens and closes a browser tab without main', async () => {
|
||||
const browserTab = store.getState().createBrowserTab(SERVER_WT, 'https://example.com')
|
||||
await main.settle()
|
||||
const drawn = tabsOf(SERVER_WT).find((tab) => tab.entityId === browserTab.id)
|
||||
expect(drawn).toMatchObject({ contentType: 'browser' })
|
||||
|
||||
store.getState().closeBrowserTab(browserTab.id)
|
||||
await main.settle()
|
||||
|
||||
expect(tabsOf(SERVER_WT).some((tab) => tab.entityId === browserTab.id)).toBe(false)
|
||||
expect(sentFor(SERVER_WT)).toEqual([])
|
||||
})
|
||||
|
||||
it('opens and closes a chat tab without main', async () => {
|
||||
const chat = openStructuredAgentSessionProvisionalTab({
|
||||
worktreeId: SERVER_WT,
|
||||
executionHostId: toRuntimeExecutionHostId(SERVER),
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
await main.settle()
|
||||
expect(tabsOf(SERVER_WT).map((tab) => tab.id)).toEqual([chat.id])
|
||||
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
|
||||
expect(tabsOf(SERVER_WT)).toEqual([])
|
||||
expect(sentFor(SERVER_WT)).toEqual([])
|
||||
// The close still reaches the server that runs the chat.
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.hostCalls).toContainEqual({
|
||||
target: { kind: 'environment', environmentId: SERVER },
|
||||
method: 'session.tabs.close'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(!ORCA_LAYOUT_OWNER_IS_MAIN)(
|
||||
"presence: this machine's workspace sends the same opens and closes to main",
|
||||
async () => {
|
||||
const browserTab = store.getState().createBrowserTab(LOCAL_WT, 'https://example.com')
|
||||
await main.settle()
|
||||
store.getState().closeBrowserTab(browserTab.id)
|
||||
const chat = openStructuredAgentSessionProvisionalTab({
|
||||
worktreeId: LOCAL_WT,
|
||||
executionHostId: 'local',
|
||||
sessionId: 'chat-2',
|
||||
agent: 'codex'
|
||||
})
|
||||
await main.settle()
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
|
||||
expect(sentFor(LOCAL_WT).map((command) => command.type)).toEqual([
|
||||
'openBrowserTab',
|
||||
'closeTabs',
|
||||
'openAgentSessionTab',
|
||||
'closeTabs'
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('an SSH worktree', () => {
|
||||
it("has no host to run a chat on, where this machine's worktree has its own", () => {
|
||||
expect(resolveStructuredAgentSessionOwner(store.getState(), SSH_WT)).toBeNull()
|
||||
expect(resolveStructuredAgentSessionOwner(store.getState(), LOCAL_WT)).toBe('local')
|
||||
})
|
||||
})
|
||||
@@ -363,6 +363,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('editor tabs drawn from main’s frame
|
||||
expect(store.getState().openFiles.map((file) => file.id)).toContain(a)
|
||||
})
|
||||
|
||||
it('drops a tab’s content when main lost its workspace before the close came in', async () => {
|
||||
const a = openFile('/a.ts')
|
||||
await main.settle()
|
||||
store.getState().setEditorDraft(a, 'draft')
|
||||
const tabId = tabFor(a)!.id
|
||||
layoutApplyTestTransport.answerWith((command) =>
|
||||
command.type === 'closeTabs' ? 'pending' : 'apply'
|
||||
)
|
||||
|
||||
store.getState().closeFile(a)
|
||||
// Main drops the tab on its own (its workspace went away), then answers the window's close.
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
await layoutApplyTestTransport.call({
|
||||
command: { type: 'closeTabs', workspace: WT, tabIds: [tabId] }
|
||||
})
|
||||
layoutApplyTestTransport.answerPending({ ok: false, code: 'workspace_not_found' })
|
||||
await main.settle()
|
||||
|
||||
expect(tabFor(a)).toBeUndefined()
|
||||
expect(store.getState().openFiles.map((file) => file.id)).not.toContain(a)
|
||||
expect(store.getState().editorDrafts[a]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reopens a closed tab at its place with one open (index)', async () => {
|
||||
const a = openFile('/a.ts')
|
||||
const b = openFile('/b.ts')
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// Editor tab closes in a layout-owner build: main closes the tab and its frame drops it; the window
|
||||
// changes no tab first, so a close main refuses leaves the tab. Once the frame is in, the group
|
||||
// shows the tab today's close picks (its previous one). Waiters on the tab learn the outcome.
|
||||
// Editor tabs whose file record the window dropped, in a layout-owner build: main closes them.
|
||||
// A single tab's close goes through `closeContentTabThroughLayoutOwner`.
|
||||
|
||||
import type { Tab } from '../../../../../../shared/tab-types'
|
||||
import { sendLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import {
|
||||
afterPendingTabs,
|
||||
beginPendingTab,
|
||||
hasPendingEntityTab,
|
||||
isTabClosePending,
|
||||
markPendingCloseCleansFile,
|
||||
@@ -14,92 +11,8 @@ import {
|
||||
} from '@/runtime/layout-owner-pending-tabs'
|
||||
import type { AppState } from '../../../types'
|
||||
import type { OpenFile } from '../types/open-file'
|
||||
import { pickNextActiveTab } from '../../tab-group-state'
|
||||
import { isEditorTabContentType } from './editor-tab-content-type'
|
||||
|
||||
/**
|
||||
* Once main's frame dropped the closed tab, its group shows the tab today's close picks (one
|
||||
* picked before that is itself gone falls back to the group's history), and the workspace's
|
||||
* surface follows its active group. Nothing while the tab still shows.
|
||||
*/
|
||||
function selectAfterClose(
|
||||
get: () => AppState,
|
||||
tab: Pick<Tab, 'id' | 'worktreeId' | 'groupId'>,
|
||||
nextActiveTabId: string | null
|
||||
): void {
|
||||
const state = get()
|
||||
const worktreeId = tab.worktreeId
|
||||
if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) {
|
||||
return
|
||||
}
|
||||
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
|
||||
const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId)
|
||||
const next =
|
||||
group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId)
|
||||
? nextActiveTabId
|
||||
: group
|
||||
? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id)
|
||||
: null
|
||||
if (next && group?.activeTabId !== next) {
|
||||
state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false })
|
||||
}
|
||||
// Activation focuses the tab's group; the user's focused group stays, and shows its surface.
|
||||
if (activeGroupId) {
|
||||
get().focusGroup(worktreeId, activeGroupId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an editor tab's close; false when it is no editor tab or main is not asked (a paired
|
||||
* server owns the workspace's layout), and the caller closes it as today. `closeHere` is today's
|
||||
* close, for a twin main may route to a paired server.
|
||||
*/
|
||||
export function closeEditorTabThroughLayoutOwner(
|
||||
get: () => AppState,
|
||||
tab: Pick<Tab, 'id' | 'entityId' | 'worktreeId' | 'groupId' | 'contentType'>,
|
||||
/** The tab today's close would show next in the group, picked before the close. */
|
||||
nextActiveTabId: string | null,
|
||||
closeHere: () => void
|
||||
): boolean {
|
||||
if (!isEditorTabContentType(tab.contentType)) {
|
||||
return false
|
||||
}
|
||||
// Its waiters (closeFile's cleanup) stay on the close already in flight.
|
||||
if (isTabClosePending(tab.id)) {
|
||||
return true
|
||||
}
|
||||
const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' })
|
||||
const sent = sendLayoutApply(
|
||||
get(),
|
||||
tab.worktreeId,
|
||||
{ type: 'closeTabs', tabIds: [tab.id] },
|
||||
{
|
||||
apply: (result) => {
|
||||
const closed = result.closed?.includes(tab.id) ?? true
|
||||
// Waiters first: a closeFile's content cleanup must not undo the selection.
|
||||
settle(closed ? tab.id : null)
|
||||
if (closed) {
|
||||
selectAfterClose(get, tab, nextActiveTabId)
|
||||
}
|
||||
},
|
||||
asToday: () => {
|
||||
let closedId: string | null = null
|
||||
try {
|
||||
closeHere()
|
||||
closedId = tab.id
|
||||
} finally {
|
||||
settle(closedId)
|
||||
}
|
||||
},
|
||||
refused: () => settle(null)
|
||||
}
|
||||
)
|
||||
if (!sent) {
|
||||
settle(null)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* closeFile's tabs: main closes each, and the file's content goes once every close is in, unless
|
||||
* one was refused or the file reopened meanwhile. A repeat close joins the one in flight; so does
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep,
|
||||
* the bulk worktree purge, and the single removeWorktree teardown.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
@@ -24,6 +25,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// Main answers the window's chat-tab commands and its frames draw them (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
@@ -85,10 +98,15 @@ function seedDrafts(store: ReturnType<typeof createTestStore>): void {
|
||||
}
|
||||
|
||||
describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApi.worktrees.remove.mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
it('closeTab drops the closed tab’s draft only', () => {
|
||||
const store = createTestStore()
|
||||
@@ -241,15 +259,18 @@ describe('nativeChatLaunchDraftByTabId teardown', () => {
|
||||
return chat
|
||||
}
|
||||
|
||||
it('closing a structured chat tab keeps its conversation’s unsent draft', () => {
|
||||
it('closing a structured chat tab keeps its conversation’s unsent draft', async () => {
|
||||
const store = createTestStore()
|
||||
const chat = seedStructuredChat(store)
|
||||
main = await drawTabsFromMain(store)
|
||||
const conversation = structuredAgentSessionDraftScopeKey('session-1')
|
||||
updateNativeChatComposerDraft(conversation, { text: 'unsent' }, 'immediate')
|
||||
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
|
||||
expect(store.getState().unifiedTabsByWorktree[WT1]).toEqual([])
|
||||
// Main's frame drops a workspace with no tabs left; the window keeps an empty list.
|
||||
expect(store.getState().unifiedTabsByWorktree[WT1] ?? []).toEqual([])
|
||||
expect(readNativeChatComposerDraft(conversation).text).toBe('unsent')
|
||||
})
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
restoreRecentlyClosedTabPosition
|
||||
} from './recently-closed-tabs'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture'
|
||||
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
|
||||
import { endLayoutFramePositions } from '@/runtime/layout-frame-position'
|
||||
@@ -440,6 +441,8 @@ describe('restoreRecentlyClosedTabPosition', () => {
|
||||
describe('reopenClosedTab cross-type MRU', () => {
|
||||
it('reopens closed tabs of mixed kinds in most-recent-first order', async () => {
|
||||
const store = makeSeededStore()
|
||||
// A layout-owner build draws the browser tab from main's frames.
|
||||
const main = await drawTabsFromMain(store)
|
||||
|
||||
// Close order: editor → terminal → browser.
|
||||
store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] })
|
||||
@@ -450,7 +453,9 @@ describe('reopenClosedTab cross-type MRU', () => {
|
||||
store.getState().closeTab(terminal.id)
|
||||
await layoutApplyTestTransport.settle()
|
||||
const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' })
|
||||
await main.settle()
|
||||
store.getState().closeBrowserTab(browser.id)
|
||||
await main.settle()
|
||||
|
||||
expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([
|
||||
'browser',
|
||||
@@ -460,6 +465,7 @@ describe('reopenClosedTab cross-type MRU', () => {
|
||||
|
||||
// First press: browser comes back.
|
||||
expect(store.getState().reopenClosedTab(WT)).toBe(true)
|
||||
await main.settle()
|
||||
expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com')
|
||||
expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0)
|
||||
|
||||
@@ -474,6 +480,7 @@ describe('reopenClosedTab cross-type MRU', () => {
|
||||
|
||||
// Nothing left.
|
||||
expect(store.getState().reopenClosedTab(WT)).toBe(false)
|
||||
main.close()
|
||||
})
|
||||
|
||||
it('skips kind entries whose per-type snapshot has drained', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { installActiveWorktreeRemovalSuccessor } from '@/lib/active-worktree-rem
|
||||
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { clearSshHostWorkspaces } from '@/components/sidebar/ssh-host-remove-workspaces'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const storeAccess = vi.hoisted(() => ({ getState: vi.fn() }))
|
||||
vi.mock('@/store', () => ({ useAppStore: storeAccess }))
|
||||
@@ -20,6 +21,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() }))
|
||||
// Main answers the window's browser-tab commands and its frames draw them (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
|
||||
@@ -34,10 +46,13 @@ beforeEach(() => {
|
||||
removeForHost.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
let dispose: (() => void) | null = null
|
||||
let layoutMain: TabsDrawnFromMain | undefined
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
dispose = null
|
||||
layoutMain?.close()
|
||||
layoutMain = undefined
|
||||
})
|
||||
|
||||
const active = makeWorktree({ id: 'repo1::/active', repoId: 'repo1', hostId: 'ssh:host' })
|
||||
@@ -83,9 +98,12 @@ describe('deleting the active workspace', () => {
|
||||
|
||||
it('respects a user closing the last browser tab after startup dropped its row', async () => {
|
||||
const store = setup()
|
||||
layoutMain = await drawTabsFromMain(store)
|
||||
const tabId = store.getState().createBrowserTab(active.id, 'https://example.com')
|
||||
await layoutMain.settle()
|
||||
store.setState({ worktreesByRepo: { repo1: [sibling] } })
|
||||
store.getState().closeBrowserTab(tabId.id)
|
||||
await layoutMain.settle()
|
||||
await Promise.resolve()
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import {
|
||||
createTestStore,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './store-test-helpers'
|
||||
import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
|
||||
const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => []))
|
||||
@@ -36,8 +37,26 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Main answers the window's browser-tab commands and its frames draw them (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const mockApi = createStoreCascadesMockApi()
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
|
||||
const itSwitchOn = it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)
|
||||
|
||||
@@ -197,7 +216,7 @@ describe('setActiveWorktree', () => {
|
||||
expect(s.activeTabId).toBe(terminalTabId)
|
||||
})
|
||||
|
||||
it('promotes the next tab in the focused split into the global active surface on close', () => {
|
||||
it('promotes the next tab in the focused split into the global active surface on close', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const terminalTabId = 'terminal-1'
|
||||
@@ -268,8 +287,10 @@ describe('setActiveWorktree', () => {
|
||||
},
|
||||
activeGroupIdByWorktree: { [wt]: groupId }
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
store.getState().closeBrowserTab(browserTabId)
|
||||
await main.settle()
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.groupsByWorktree[wt]?.[0]?.activeTabId).toBe(terminalTabId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import {
|
||||
createTestStore,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
seedStore
|
||||
} from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture'
|
||||
|
||||
const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => []))
|
||||
const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn())
|
||||
@@ -33,8 +34,31 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Main answers the window's browser-tab commands and its frames draw them (switch-on builds).
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
const mockApi = createStoreCascadesMockApi()
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
})
|
||||
|
||||
/** Every change is drawn once this resolves; at once with the switch off. */
|
||||
async function settled(): Promise<void> {
|
||||
await main?.settle()
|
||||
}
|
||||
|
||||
describe('setActiveWorktree', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -394,7 +418,7 @@ describe('setActiveWorktree', () => {
|
||||
expect(s.activeFileId).toBe(fileId)
|
||||
})
|
||||
|
||||
it('does not switch the global surface when creating a browser tab for a background worktree', () => {
|
||||
it('does not switch the global surface when creating a browser tab for a background worktree', async () => {
|
||||
const store = createTestStore()
|
||||
const activeWt = 'repo1::/path/wt1'
|
||||
const backgroundWt = 'repo1::/path/wt2'
|
||||
@@ -413,10 +437,12 @@ describe('setActiveWorktree', () => {
|
||||
[backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })]
|
||||
}
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const browserTab = store
|
||||
.getState()
|
||||
.createBrowserTab(backgroundWt, 'https://example.com', { activate: true })
|
||||
await settled()
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.activeTabType).toBe('terminal')
|
||||
@@ -424,7 +450,7 @@ describe('setActiveWorktree', () => {
|
||||
expect(s.activeBrowserTabIdByWorktree[backgroundWt]).toBe(browserTab.id)
|
||||
})
|
||||
|
||||
it('uses unified MRU selection when closing an active browser tab', () => {
|
||||
it('uses unified MRU selection when closing an active browser tab', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -434,20 +460,27 @@ describe('setActiveWorktree', () => {
|
||||
},
|
||||
activeWorktreeId: wt
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com')
|
||||
await settled()
|
||||
store.getState().createBrowserTab(wt, 'https://neighbor.example.com')
|
||||
await settled()
|
||||
const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com')
|
||||
await settled()
|
||||
store.getState().setActiveBrowserTab(previous.id)
|
||||
await settled()
|
||||
store.getState().setActiveBrowserTab(closing.id)
|
||||
await settled()
|
||||
|
||||
store.getState().closeBrowserTab(closing.id)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeBrowserTabId).toBe(previous.id)
|
||||
expect(store.getState().activeBrowserTabIdByWorktree[wt]).toBe(previous.id)
|
||||
})
|
||||
|
||||
it('keeps the unified MRU target when closing the last browser tab', () => {
|
||||
it('keeps the unified MRU target when closing the last browser tab', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -457,18 +490,23 @@ describe('setActiveWorktree', () => {
|
||||
},
|
||||
activeWorktreeId: wt
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
store.getState().createTab(wt)
|
||||
await settled()
|
||||
const previous = store.getState().createTab(wt)
|
||||
await settled()
|
||||
const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com')
|
||||
await settled()
|
||||
|
||||
store.getState().closeBrowserTab(closing.id)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeTabId).toBe(previous.id)
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
})
|
||||
|
||||
it('keeps a valid browser target for an inactive worktree after close', () => {
|
||||
it('keeps a valid browser target for an inactive worktree after close', async () => {
|
||||
const store = createTestStore()
|
||||
const activeWt = 'repo1::/path/active'
|
||||
const backgroundWt = 'repo1::/path/background'
|
||||
@@ -482,23 +520,30 @@ describe('setActiveWorktree', () => {
|
||||
},
|
||||
activeWorktreeId: activeWt
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const previous = store
|
||||
.getState()
|
||||
.createBrowserTab(backgroundWt, 'https://previous.example.com', {
|
||||
activate: false
|
||||
})
|
||||
await settled()
|
||||
const closing = store.getState().createBrowserTab(backgroundWt, 'https://closing.example.com', {
|
||||
activate: false
|
||||
})
|
||||
await settled()
|
||||
store.setState({ activeBrowserTabIdByWorktree: { [backgroundWt]: closing.id } })
|
||||
// Main loads the window's edited layout, as it would from a saved session.
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
store.getState().closeBrowserTab(closing.id)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeBrowserTabIdByWorktree[backgroundWt]).toBe(previous.id)
|
||||
})
|
||||
|
||||
it('keeps the global browser target when closing a legacy tab without a unified wrapper', () => {
|
||||
it('keeps the global browser target when closing a legacy tab without a unified wrapper', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -508,25 +553,32 @@ describe('setActiveWorktree', () => {
|
||||
},
|
||||
activeWorktreeId: wt
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com', {
|
||||
activate: false
|
||||
})
|
||||
await settled()
|
||||
const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com', {
|
||||
activate: false
|
||||
})
|
||||
await settled()
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {},
|
||||
activeBrowserTabId: closing.id,
|
||||
activeBrowserTabIdByWorktree: { [wt]: closing.id }
|
||||
})
|
||||
// Main loads the window's edited layout, as it would from a saved session.
|
||||
main?.close()
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
store.getState().closeBrowserTab(closing.id)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().activeBrowserTabId).toBe(previous.id)
|
||||
})
|
||||
|
||||
it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', () => {
|
||||
it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -540,15 +592,17 @@ describe('setActiveWorktree', () => {
|
||||
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
|
||||
}
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true })
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true)
|
||||
expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(true)
|
||||
expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not queue address-bar focus for background or already-navigated browser tabs', () => {
|
||||
it('does not queue address-bar focus for background or already-navigated browser tabs', async () => {
|
||||
const store = createTestStore()
|
||||
const activeWt = 'repo1::/path/wt1'
|
||||
const backgroundWt = 'repo1::/path/wt2'
|
||||
@@ -567,19 +621,22 @@ describe('setActiveWorktree', () => {
|
||||
[backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })]
|
||||
}
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const backgroundBlankTab = store
|
||||
.getState()
|
||||
.createBrowserTab(backgroundWt, 'about:blank', { activate: true })
|
||||
await settled()
|
||||
const activeNavigatedTab = store
|
||||
.getState()
|
||||
.createBrowserTab(activeWt, 'https://example.com', { activate: true })
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[backgroundBlankTab.id]).toBeUndefined()
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[activeNavigatedTab.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pending address-bar focus request when the new browser tab closes before mount', () => {
|
||||
it('drops a pending address-bar focus request when the new browser tab closes before mount', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -593,11 +650,14 @@ describe('setActiveWorktree', () => {
|
||||
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
|
||||
}
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
|
||||
const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true })
|
||||
await settled()
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true)
|
||||
|
||||
store.getState().closeBrowserTab(browserTab.id)
|
||||
await settled()
|
||||
|
||||
expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split'
|
||||
import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner'
|
||||
import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open'
|
||||
import { contentTabOpenPlacement } from './tabs/content-tab-open-placement'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
@@ -133,11 +134,15 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu
|
||||
split: { sourceGroupId: editorGroup, splitDirection: 'down' }
|
||||
})
|
||||
const terminalGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id
|
||||
store.getState().createBrowserTab(WT, 'about:blank', {
|
||||
// The browser tab and its split are drawn from main's frame.
|
||||
const browser = store.getState().createBrowserTab(WT, 'about:blank', {
|
||||
split: { sourceGroupId: ROOT, splitDirection: 'left' }
|
||||
})
|
||||
const browserGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id
|
||||
expect(await settledSent()).toEqual([
|
||||
const sentNow = await settledSent()
|
||||
const browserGroup = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === browser.id)!.groupId
|
||||
expect(sentNow).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'createTerminalTab',
|
||||
tabId: onlyTabOf(terminalGroup),
|
||||
@@ -191,17 +196,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu
|
||||
[
|
||||
'chat',
|
||||
() => {
|
||||
// As openStructuredAgentSessionProvisionalTab does, on this test's store.
|
||||
const tab = store.getState().createUnifiedTab(WT, 'agent-session', {
|
||||
entityId: 'chat-1',
|
||||
agentSessionAgent: 'codex',
|
||||
// As openAgentSessionTabThroughLayoutOwner does, on this test's store.
|
||||
const tabId = 'agent-session:chat-1'
|
||||
const placement = contentTabOpenPlacement(store.getState(), WT, tabId, {
|
||||
targetGroupId: ROOT
|
||||
})!
|
||||
openContentTabThroughLayoutOwner(store.getState(), {
|
||||
worktreeId: WT,
|
||||
entityId: 'chat-1',
|
||||
command: {
|
||||
type: 'openAgentSessionTab',
|
||||
tabId,
|
||||
sessionId: 'chat-1',
|
||||
agent: 'codex',
|
||||
...placement
|
||||
},
|
||||
facts: { label: 'Codex' },
|
||||
shown: () => {},
|
||||
drawHere: () => null
|
||||
})
|
||||
recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab)
|
||||
}
|
||||
]
|
||||
])(
|
||||
'records a %s tab opened into the emptied root after its neighbour split again',
|
||||
'opens a %s tab into the emptied root after its neighbour split again',
|
||||
async (_kind, openIntoRoot) => {
|
||||
// The splits beside the emptied root hold tabs, so main has only those.
|
||||
store.getState().createTab(WT, undefined, undefined, {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { _resetLayoutFramePositionForTests } from '@/runtime/layout-frame-position'
|
||||
import { _resetPendingTabsForTests } from '@/runtime/layout-owner-pending-tabs'
|
||||
import { _resetPendingTabFactsForTests } from '@/runtime/layout-owner-pending-tab-facts'
|
||||
import { _resetContentRekeysForTests } from '@/runtime/layout-owner-content-rekeys'
|
||||
import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture'
|
||||
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
|
||||
@@ -71,6 +72,7 @@ export async function drawTabsFromMain(store: DrawnStore): Promise<TabsDrawnFrom
|
||||
layoutApplyTestTransport.reset()
|
||||
_resetLayoutFramePositionForTests()
|
||||
_resetPendingTabsForTests()
|
||||
_resetPendingTabFactsForTests()
|
||||
_resetContentRekeysForTests()
|
||||
layoutApplyTestTransport.answerWith('apply')
|
||||
store.setState(missingFrameSlices(store.getState()))
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Editor, browser, chat and simulator tab closes in a layout-owner build: main closes the tab and
|
||||
// its frame drops it; the window changes no tab first, so a close main refuses leaves the tab.
|
||||
// Once the frame is in, the group shows the tab today's close picks (its previous one). Waiters on
|
||||
// the tab learn the outcome.
|
||||
|
||||
import type { Tab } from '../../../../../shared/tab-types'
|
||||
import { sendLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import {
|
||||
afterPendingTab,
|
||||
beginPendingTab,
|
||||
isPendingTab,
|
||||
isTabClosePending
|
||||
} from '@/runtime/layout-owner-pending-tabs'
|
||||
import type { AppState } from '../../types'
|
||||
import { pickNextActiveTab } from '../tab-group-state'
|
||||
import { isEditorTabContentType } from '../editor/tabs/editor-tab-content-type'
|
||||
|
||||
/** Tab kinds whose close main decides; a terminal's goes through its own close. */
|
||||
export function closesThroughLayoutOwner(contentType: Tab['contentType']): boolean {
|
||||
return (
|
||||
isEditorTabContentType(contentType) ||
|
||||
contentType === 'browser' ||
|
||||
contentType === 'agent-session' ||
|
||||
contentType === 'simulator'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Once main's frame dropped the closed tab, its group shows the tab today's close picks (one
|
||||
* picked before that is itself gone falls back to the group's history), and the workspace's
|
||||
* surface follows its active group. Nothing while the tab still shows.
|
||||
*/
|
||||
function selectAfterClose(
|
||||
get: () => AppState,
|
||||
tab: Pick<Tab, 'id' | 'worktreeId' | 'groupId'>,
|
||||
nextActiveTabId: string | null
|
||||
): void {
|
||||
const state = get()
|
||||
const worktreeId = tab.worktreeId
|
||||
if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) {
|
||||
return
|
||||
}
|
||||
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
|
||||
const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId)
|
||||
const next =
|
||||
group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId)
|
||||
? nextActiveTabId
|
||||
: group
|
||||
? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id)
|
||||
: null
|
||||
if (next && group?.activeTabId !== next) {
|
||||
state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false })
|
||||
}
|
||||
// Activation focuses the tab's group; the user's focused group stays, and shows its surface.
|
||||
if (activeGroupId) {
|
||||
get().focusGroup(worktreeId, activeGroupId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tabs whose twin close main answered as today, while today's close runs. */
|
||||
const closingAsToday = new Set<string>()
|
||||
|
||||
export type ContentTabClose = {
|
||||
/** The tab today's close would show next in the group, picked before the close. */
|
||||
nextActiveTabId: string | null
|
||||
/** Today's close, for a twin main may route to a paired server. */
|
||||
closeHere: () => void
|
||||
/** Why a browser tab closes: a cleanup close stays off main's reopen list. */
|
||||
recordClose?: 'user' | 'cleanup'
|
||||
/** The window's own cleanup once main's frame dropped the tab, before the selection moves. */
|
||||
closed?: () => void
|
||||
/** Lands an emptied active worktree on the welcome screen; true when it did. */
|
||||
landIfEmptied?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a tab's close; false when main does not decide its kind or is not asked (a paired server
|
||||
* owns the workspace's layout), and the caller closes it as today.
|
||||
*/
|
||||
export function closeContentTabThroughLayoutOwner(
|
||||
get: () => AppState,
|
||||
tab: Pick<Tab, 'id' | 'entityId' | 'worktreeId' | 'groupId' | 'contentType'>,
|
||||
close: ContentTabClose
|
||||
): boolean {
|
||||
// Today's close of a twin's tab closes its tab-bar entry as today too.
|
||||
if (!closesThroughLayoutOwner(tab.contentType) || closingAsToday.has(tab.id)) {
|
||||
return false
|
||||
}
|
||||
// A repeat close joins the one in flight; its waiters (closeFile's cleanup) stay on it.
|
||||
if (isTabClosePending(tab.id)) {
|
||||
return true
|
||||
}
|
||||
// A close of a tab still opening goes once main answered the open.
|
||||
if (isPendingTab(tab.id)) {
|
||||
afterPendingTab(tab.id, () => {
|
||||
const drawn = get().unifiedTabsByWorktree[tab.worktreeId]?.find((e) => e.id === tab.id)
|
||||
if (drawn) {
|
||||
closeContentTabThroughLayoutOwner(get, drawn, close)
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' })
|
||||
const dropped = (): void => {
|
||||
// Waiters first: a closeFile's content cleanup must not undo the selection.
|
||||
settle(tab.id)
|
||||
close.closed?.()
|
||||
if (!close.landIfEmptied?.()) {
|
||||
selectAfterClose(get, tab, close.nextActiveTabId)
|
||||
}
|
||||
}
|
||||
const closeAsToday = (): void => {
|
||||
closingAsToday.add(tab.id)
|
||||
let closedId: string | null = null
|
||||
try {
|
||||
close.closeHere()
|
||||
closedId = tab.id
|
||||
} finally {
|
||||
closingAsToday.delete(tab.id)
|
||||
settle(closedId)
|
||||
}
|
||||
}
|
||||
const sent = sendLayoutApply(
|
||||
get(),
|
||||
tab.worktreeId,
|
||||
{
|
||||
type: 'closeTabs',
|
||||
tabIds: [tab.id],
|
||||
...(close.recordClose ? { recordClose: close.recordClose } : {})
|
||||
},
|
||||
{
|
||||
apply: (result) => {
|
||||
if (result.closed?.includes(tab.id) ?? true) {
|
||||
dropped()
|
||||
} else {
|
||||
settle(null)
|
||||
}
|
||||
},
|
||||
asToday: closeAsToday,
|
||||
// A tab main no longer holds, or whose workspace it no longer holds, was closed there first
|
||||
// (a host's chat close, a removed workspace). A workspace main never held draws only here.
|
||||
refused: (code) => {
|
||||
const shows = get().unifiedTabsByWorktree[tab.worktreeId]?.some((e) => e.id === tab.id)
|
||||
if ((code === 'tab_not_found' || code === 'workspace_not_found') && !shows) {
|
||||
dropped()
|
||||
} else if (code === 'workspace_not_found') {
|
||||
closeAsToday()
|
||||
} else {
|
||||
settle(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!sent) {
|
||||
settle(null)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Where a tab the window asks main to open goes, resolved as today's create resolves it: the
|
||||
// requested live group, else the anchor's, else the active one, else the workspace's first (a
|
||||
// workspace with none gets its root group). An open to the side names a new group beside its
|
||||
// source, and an empty group only the window holds names the split that made it.
|
||||
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree'
|
||||
import type { NewGroupSplit } from '../../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { AppState } from '../../types'
|
||||
import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split'
|
||||
import { selectFolderWorkspaces } from '../../workspace-record-selectors'
|
||||
import { resolveUnifiedTabCreatePlacement } from './tabs-create-placement'
|
||||
import type { TabSplitTarget } from './tabs-slice-contract'
|
||||
|
||||
export type ContentTabOpenPlacement = {
|
||||
groupId: string
|
||||
afterTabId?: string
|
||||
split?: NewGroupSplit
|
||||
}
|
||||
|
||||
export function contentTabOpenPlacement(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
request: {
|
||||
targetGroupId?: string
|
||||
afterTabId?: string
|
||||
split?: TabSplitTarget
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
): ContentTabOpenPlacement | null {
|
||||
if (request.split) {
|
||||
return {
|
||||
groupId: createBrowserUuid(),
|
||||
split: { besideGroupId: request.split.sourceGroupId, direction: request.split.splitDirection }
|
||||
}
|
||||
}
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const placement = resolveUnifiedTabCreatePlacement({
|
||||
groups,
|
||||
tabs: state.unifiedTabsByWorktree[worktreeId] ?? [],
|
||||
activeGroupId: state.activeGroupIdByWorktree[worktreeId],
|
||||
targetGroupId: request.targetGroupId,
|
||||
afterTabId: request.afterTabId,
|
||||
executionHostId: request.executionHostId,
|
||||
lookupWorktrees: () =>
|
||||
[
|
||||
...(state.allWorktrees?.() ?? []),
|
||||
...(selectFolderWorkspaces(state) ?? []).map(folderWorkspaceToWorktree)
|
||||
].filter((worktree) => worktree.id === worktreeId)
|
||||
})
|
||||
const groupId = placement.groupId ?? groups[0]?.id ?? state.ensureWorktreeRootGroup?.(worktreeId)
|
||||
if (!groupId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
groupId,
|
||||
...(placement.anchorTabId ? { afterTabId: placement.anchorTabId } : {}),
|
||||
...newGroupSplitOf(state, worktreeId, groupId, tabId)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user