mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Leave adopted live terminals' tabs to the layout updates in the layout-owner build (dark, build switch off by default) (#29710)
* Leave adopted live terminals' tabs to main's frames in a layout-owner build (dark, build switch off by default) * Adopt only into a pane main's frames hold for that terminal; test a tab-bar id that differs from its terminal id * Count only a pane main binds to the adopted terminal; check the pane key's tab * Read frame-held panes through a small module so window-owned builds keep the frame sync out
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY,
|
||||
leaf
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { provideLayoutFrameTerminalPanes } from '../runtime/layout-frame-terminal-panes'
|
||||
import { openWorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync'
|
||||
import type { WorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync'
|
||||
import { reportWindowSessionWriteActivity } from './window-session-write-activity'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { adoptLiveWorkspacePtySurfaces } from './worktree-agent-live-surface-adoption'
|
||||
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
type FrameDouble = { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null }
|
||||
const frames = vi.hoisted((): FrameDouble => ({ onFrame: null }))
|
||||
vi.mock('../runtime/runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
|
||||
) => {
|
||||
frames.onFrame = onFrame
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const OTHER_PTY = `${GIT_KEY}@@aaaa0001`
|
||||
const LEFT_PTY = `${GIT_KEY}@@aaaa0002`
|
||||
const RIGHT_PTY = `${GIT_KEY}@@aaaa0003`
|
||||
|
||||
type TerminalSpec = {
|
||||
id: string
|
||||
entityId?: string
|
||||
leaves: [string, string?][]
|
||||
split?: 'vertical'
|
||||
}
|
||||
|
||||
/** The window's copy: one shell tab. Main's: that tab and a split agent tab the window lacks,
|
||||
* whose tab-bar id differs from its terminal id, as for a tab adopted from another window. */
|
||||
function sessions(splitPtyIds: [string?, string?] = [LEFT_PTY, RIGHT_PTY]): {
|
||||
window: WorkspaceSessionState
|
||||
main: WorkspaceSessionState
|
||||
} {
|
||||
const other: TerminalSpec = { id: 'tab-other', leaves: [[leaf(1), OTHER_PTY]] }
|
||||
const split: TerminalSpec = {
|
||||
id: 'entry-split',
|
||||
entityId: 'tab-split',
|
||||
leaves: [
|
||||
[leaf(2), splitPtyIds[0]],
|
||||
[leaf(3), splitPtyIds[1]]
|
||||
],
|
||||
split: 'vertical'
|
||||
}
|
||||
return {
|
||||
window: addWorkspace(emptySession(), GIT_KEY, [{ id: 'group-a', tabs: [other] }]),
|
||||
main: addWorkspace(emptySession(), GIT_KEY, [{ id: 'group-a', tabs: [other, split] }])
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOf(session: WorkspaceSessionState): WorkspaceLayoutChangeFrame {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext())
|
||||
return {
|
||||
type: 'snapshot',
|
||||
workspaces: Object.entries(layout.workspaces).map(([key, workspace]) => ({
|
||||
key,
|
||||
layout: publishWorkspaceLayout(workspace, 'local')
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const owners: LiveTerminalSurfaceOwnerIndex = new Map([
|
||||
[LEFT_PTY, { tabId: 'tab-split', paneKey: `tab-split:${leaf(2)}`, ptyId: LEFT_PTY }],
|
||||
[RIGHT_PTY, { tabId: 'tab-split', paneKey: `tab-split:${leaf(3)}`, ptyId: RIGHT_PTY }]
|
||||
])
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('adopting live terminals in a layout-owner build', () => {
|
||||
let sync: WorkspaceLayoutFrameSync | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
reportWindowSessionWriteActivity(false)
|
||||
sync?.close()
|
||||
sync = null
|
||||
provideLayoutFrameTerminalPanes(() => false)
|
||||
})
|
||||
|
||||
it('makes no tab or pane of its own while frames are held; the frame then draws the bound tab', async () => {
|
||||
const { window: windowSession, main } = sessions()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync.holdsTerminalPane)
|
||||
sync.start()
|
||||
// The window owes main a save, so main's tree waits.
|
||||
reportWindowSessionWriteActivity(true)
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
const setTabLayout = vi.spyOn(store.getState(), 'setTabLayout')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[LEFT_PTY, RIGHT_PTY],
|
||||
async () => owners
|
||||
)
|
||||
|
||||
expect(adoption).toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other'])
|
||||
|
||||
reportWindowSessionWriteActivity(false)
|
||||
await flushMicrotasks()
|
||||
const layout = store.getState().terminalLayoutsByTabId['tab-split']
|
||||
expect(layout?.root).toMatchObject({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: leaf(2) },
|
||||
second: { type: 'leaf', leafId: leaf(3) }
|
||||
})
|
||||
expect(layout?.ptyIdsByLeafId).toEqual({ [leaf(2)]: LEFT_PTY, [leaf(3)]: RIGHT_PTY })
|
||||
// A later sweep finds both terminals owned.
|
||||
await expect(
|
||||
adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY, RIGHT_PTY], async () => {
|
||||
throw new Error('not asked: nothing is unbound')
|
||||
})
|
||||
).resolves.toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['binds to another terminal', [`${GIT_KEY}@@aaaa0008`, `${GIT_KEY}@@aaaa0009`]],
|
||||
['leaves unbound', [undefined, undefined]]
|
||||
] satisfies [string, [string?, string?]][])(
|
||||
'declines a terminal whose pane main %s',
|
||||
async (_, ptyIds) => {
|
||||
const { window: windowSession, main } = sessions(ptyIds)
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync.holdsTerminalPane)
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
|
||||
await expect(
|
||||
adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY], async () => owners)
|
||||
).resolves.toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] })
|
||||
}
|
||||
)
|
||||
|
||||
it('declines a terminal whose tab main does not hold, rather than making one', async () => {
|
||||
const { window: windowSession } = sessions()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync.holdsTerminalPane)
|
||||
sync.start()
|
||||
frames.onFrame!(snapshotOf(windowSession))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[LEFT_PTY],
|
||||
async () => owners
|
||||
)
|
||||
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import type { useAppStore } from '@/store'
|
||||
import { layoutFramesHoldTerminalPane } from '@/runtime/layout-frame-terminal-panes'
|
||||
import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id'
|
||||
import type {
|
||||
LiveTerminalSurfaceOwner,
|
||||
@@ -105,6 +107,15 @@ function adoptHostOwnedSurface(
|
||||
): boolean {
|
||||
const store = getState()
|
||||
const known = tabExists(store, owner.tabId)
|
||||
// Switch point: main's frames draw a pane main holds for this terminal; the window makes no tab
|
||||
// or pane of its own. Declined when main lacks it (main-first follow-up) or before the snapshot.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && !known) {
|
||||
const pane = parsePaneKey(owner.paneKey)
|
||||
return (
|
||||
pane?.tabId === owner.tabId &&
|
||||
layoutFramesHoldTerminalPane(worktreeId, { ...owner, leafId: pane.leafId })
|
||||
)
|
||||
}
|
||||
if (bindLivePtyToExactSurface(store, worktreeId, owner)) {
|
||||
if (!known) {
|
||||
materializedTabIds.add(owner.tabId)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Which terminal panes main's layout frames hold, for readers outside the frame sync. Its own
|
||||
// module so those readers do not pull the frame sync into window-owned builds.
|
||||
|
||||
type TerminalPane = { tabId: string; leafId: string; ptyId: string }
|
||||
type TerminalPaneQuery = (worktreeId: string, pane: TerminalPane) => boolean
|
||||
|
||||
let query: TerminalPaneQuery | null = null
|
||||
|
||||
/** Set by the window's layout stream once it opens. */
|
||||
export function provideLayoutFrameTerminalPanes(next: TerminalPaneQuery): void {
|
||||
query = next
|
||||
}
|
||||
|
||||
/** Whether main's frames will draw this terminal's pane; false before the window's stream opens. */
|
||||
export function layoutFramesHoldTerminalPane(worktreeId: string, pane: TerminalPane): boolean {
|
||||
return query?.(worktreeId, pane) ?? false
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { stableJson } from '../../../shared/workspace-layout/workspace-layout-lo
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { endLayoutFramePositions, noteLayoutFramePosition } from './layout-frame-position'
|
||||
import { provideLayoutFrameTerminalPanes } from './layout-frame-terminal-panes'
|
||||
import { subscribeRuntimeLayout } from './runtime-layout-client'
|
||||
|
||||
type FrameSyncStore = {
|
||||
@@ -46,6 +47,12 @@ export type WorkspaceLayoutFrameSync = {
|
||||
/** Shows every workspace the frames own once the snapshot is in, then keeps them current. */
|
||||
start: () => void
|
||||
close: () => void
|
||||
/** Whether main's latest frames show this terminal in this pane, drawn yet or not. An unbound
|
||||
* pane does not count: nothing binds a frame-drawn pane to a live terminal later. */
|
||||
holdsTerminalPane: (
|
||||
worktreeId: string,
|
||||
pane: { tabId: string; leafId: string; ptyId: string }
|
||||
) => boolean
|
||||
}
|
||||
|
||||
/** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */
|
||||
@@ -235,7 +242,14 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
live = false
|
||||
stopIdleListener()
|
||||
stream.close()
|
||||
}
|
||||
},
|
||||
holdsTerminalPane: (worktreeId, { tabId, leafId, ptyId }) =>
|
||||
cache[worktreeId]?.tabs.some(
|
||||
(tab) =>
|
||||
tab.entityId === tabId &&
|
||||
(tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ??
|
||||
false)
|
||||
) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +258,9 @@ let startupFrames: WorkspaceLayoutFrameSync | null = null
|
||||
/** After restore: the window's one layout stream shows the frames' workspaces from now on. */
|
||||
export function showStartupLayoutFrames(): void {
|
||||
// Kept for the document's life: a local stream ends only with main, and a re-run boot reuses it.
|
||||
startupFrames ??= openWorkspaceLayoutFrameSync(useAppStore)
|
||||
if (!startupFrames) {
|
||||
startupFrames = openWorkspaceLayoutFrameSync(useAppStore)
|
||||
provideLayoutFrameTerminalPanes(startupFrames.holdsTerminalPane)
|
||||
}
|
||||
startupFrames.start()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user