mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
The window applies each layout update record by record instead of rebuilding from its whole session (dark, layout-owner build only) (#31152)
* 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) * Name the pane tree the window compares by what it holds: the tree without its panes' bindings or live titles (dark, layout-owner build only) --------- Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
@@ -96,7 +96,6 @@
|
||||
1 tests/e2e/tab-rename-paste-ownership.spec.ts
|
||||
1 tests/e2e/tab-rename.spec.ts
|
||||
3 tests/e2e/tasks-page.spec.ts
|
||||
5 tests/e2e/terminal-account-restart.unit.test.ts
|
||||
1 tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts
|
||||
4 tests/e2e/terminal-codex-hidden-startup-background.spec.ts
|
||||
1 tests/e2e/terminal-codex-local-typing-latency.spec.ts
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
export type StoreState = {
|
||||
activeWorktreeId: string | null
|
||||
@@ -20,6 +21,8 @@ export type StoreState = {
|
||||
>
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
|
||||
/** Pty id → the agent its pane restarts as once the account change lands. */
|
||||
pendingPaneAccountRestarts?: AppState['pendingPaneAccountRestarts']
|
||||
unreadTerminalTabs?: Record<string, true>
|
||||
deleteStateByWorktreeId?: Record<string, { isDeleting?: boolean; phase?: string }>
|
||||
worktreesByRepo: Record<
|
||||
|
||||
@@ -17,10 +17,23 @@ import type {
|
||||
LayoutCommandResult
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout,
|
||||
type WorkspaceLayoutRecordChange
|
||||
} from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { layoutSnapshotFrame, publishedLayoutsOf } from './main-loaded-layout-frames.test-fixture'
|
||||
import { throwLayoutFrameApplyErrors } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
import {
|
||||
layoutFrameApplyObserver,
|
||||
observeLayoutFrameApply
|
||||
} from './workspace-layout-frame-apply-observer'
|
||||
import type * as LayoutClient from './runtime-layout-client'
|
||||
import type * as RpcClient from './runtime-rpc-client'
|
||||
|
||||
@@ -125,6 +138,23 @@ function emit(frame: WorkspaceLayoutChangeFrame): void {
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_EPOCH = 'layout-apply-test-epoch'
|
||||
|
||||
/** One workspace's records an operation changed, as main's stream publishes them. */
|
||||
function recordChanges(
|
||||
key: string,
|
||||
before: PublishedWorkspaceLayout | undefined,
|
||||
after: PublishedWorkspaceLayout | undefined
|
||||
): WorkspaceLayoutRecordChange[] {
|
||||
if (!after) {
|
||||
return [{ key, change: 'removed' }]
|
||||
}
|
||||
const change = before
|
||||
? diffPublishedLayouts(key, indexPublishedLayout(before), indexPublishedLayout(after), after)
|
||||
: { key, change: 'layout' as const, layout: after }
|
||||
return change ? [change] : []
|
||||
}
|
||||
|
||||
/** Main's apply without its routing: no host partitions, `host_not_owned`, forced closes, or a
|
||||
* frame position on refusals. */
|
||||
function applyToMainModel(command: LayoutCommand): LayoutApplyReply {
|
||||
@@ -144,13 +174,19 @@ function applyToMainModel(command: LayoutCommand): LayoutApplyReply {
|
||||
state.seq += 1
|
||||
const seq = state.seq
|
||||
const key = command.workspace
|
||||
const before = model.workspaces[key]
|
||||
const workspace = applied.model.workspaces[key]
|
||||
// Main publishes before it replies.
|
||||
emit(
|
||||
workspace
|
||||
? { type: 'workspace', key, layout: publishWorkspaceLayout(workspace, model.hostId), seq }
|
||||
: { type: 'removed', key, seq }
|
||||
)
|
||||
// Main publishes the operation's records before it replies.
|
||||
emit({
|
||||
type: 'records',
|
||||
epoch: TEST_EPOCH,
|
||||
seq,
|
||||
workspaces: recordChanges(
|
||||
key,
|
||||
before && publishWorkspaceLayout(before, model.hostId),
|
||||
workspace && publishWorkspaceLayout(workspace, model.hostId)
|
||||
)
|
||||
})
|
||||
return { ok: true, result: applied.result, frameSeq: seq }
|
||||
}
|
||||
|
||||
@@ -284,6 +320,11 @@ export function runtimeLayoutClientWithTestFrames(actual: LayoutClientModule): L
|
||||
}
|
||||
const sink: FrameSink = { onFrame, onPosition }
|
||||
state.sinks.add(sink)
|
||||
// Every flush of the window's frames must show what the full build shows; a test's own
|
||||
// installed check keeps reporting it.
|
||||
const previousCheck = layoutFrameApplyObserver()
|
||||
const installedCheck = previousCheck ?? throwLayoutFrameApplyErrors
|
||||
observeLayoutFrameApply(installedCheck)
|
||||
onStatus({ state: 'connecting' })
|
||||
queueMicrotask(() => {
|
||||
if (!state.sinks.has(sink)) {
|
||||
@@ -296,6 +337,10 @@ export function runtimeLayoutClientWithTestFrames(actual: LayoutClientModule): L
|
||||
return {
|
||||
close: () => {
|
||||
state.sinks.delete(sink)
|
||||
// A check installed after this one (another stream or test) stays in place.
|
||||
if (layoutFrameApplyObserver() === installedCheck) {
|
||||
observeLayoutFrameApply(previousCheck)
|
||||
}
|
||||
onStatus({ state: 'closed' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
/** The model main loads from one stored partition, with the ids it mints at load. */
|
||||
@@ -51,3 +55,48 @@ export function layoutSnapshotFrame(
|
||||
...(seq === undefined ? {} : { seq })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main's frames as a layout-owner host sends them: a snapshot, then for each later layout one
|
||||
* records frame of the records that changed since the layout it last sent for that workspace.
|
||||
*/
|
||||
export function mainLayoutFrameSender(): {
|
||||
snapshot: (
|
||||
layouts: Record<string, PublishedWorkspaceLayout>,
|
||||
seq?: number
|
||||
) => WorkspaceLayoutChangeFrame
|
||||
records: (
|
||||
key: string,
|
||||
layout: PublishedWorkspaceLayout,
|
||||
seq?: number
|
||||
) => WorkspaceLayoutChangeFrame
|
||||
} {
|
||||
let sent = new Map<string, PublishedWorkspaceLayout>()
|
||||
let position = 0
|
||||
return {
|
||||
snapshot: (layouts, seq) => {
|
||||
sent = new Map(Object.entries(layouts).map(([key, layout]) => [key, structuredClone(layout)]))
|
||||
position = seq ?? position
|
||||
return layoutSnapshotFrame(layouts, seq)
|
||||
},
|
||||
records: (key, layout, seq) => {
|
||||
const before = sent.get(key)
|
||||
sent.set(key, structuredClone(layout))
|
||||
position = seq ?? position + 1
|
||||
const change = before
|
||||
? diffPublishedLayouts(
|
||||
key,
|
||||
indexPublishedLayout(before),
|
||||
indexPublishedLayout(layout),
|
||||
layout
|
||||
)
|
||||
: { key, change: 'layout' as const, layout }
|
||||
return {
|
||||
type: 'records',
|
||||
epoch: 'main-epoch',
|
||||
seq: position,
|
||||
workspaces: change ? [change] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// The window's frame-apply check for one test's life: after every flush, the store must show what
|
||||
// the full build (the window's view and facts read back through its save and the Loader) shows
|
||||
// for the same cache and the store as the flush found it. Mirrors main's model check.
|
||||
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
|
||||
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
|
||||
import {
|
||||
observeLayoutFrameApply,
|
||||
type LayoutFrameApplyObservation
|
||||
} from './workspace-layout-frame-apply-observer'
|
||||
import { fullLayoutFrameBuildPatch } from './workspace-layout-frame-builds'
|
||||
import type { LayoutFrameSlices } from '@/store/workspace-layout-frame-slices'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
const FRAME_SLICES: readonly (keyof LayoutFrameSlices)[] = [
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId',
|
||||
'activeTabIdByWorktree',
|
||||
'unifiedTabsByWorktree',
|
||||
'groupsByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'layoutByWorktree',
|
||||
'openFiles',
|
||||
'editorDrafts',
|
||||
'markdownFrontmatterVisible',
|
||||
'activeFileIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'browserPagesByWorkspace',
|
||||
'remoteBrowserPageHandlesByPageId',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
]
|
||||
|
||||
/** The slices' records of `keys` alone: each workspace's entries, and its tabs', files' and pages'. */
|
||||
function recordsOf(state: AppState, keys: readonly string[]): Record<string, unknown> {
|
||||
const keySet = new Set(keys)
|
||||
const byKey = <T>(map: Record<string, T>): Record<string, T> =>
|
||||
Object.fromEntries(Object.entries(map).filter(([key]) => keySet.has(key)))
|
||||
const tabIds = new Set(keys.flatMap((key) => (state.tabsByWorktree[key] ?? []).map((t) => t.id)))
|
||||
const files = state.openFiles.filter((file) => keySet.has(file.worktreeId))
|
||||
const fileIds = new Set(files.map((file) => file.id))
|
||||
const browserTabIds = new Set(
|
||||
keys.flatMap((key) => (state.browserTabsByWorktree[key] ?? []).map((tab) => tab.id))
|
||||
)
|
||||
const pageIds = new Set(
|
||||
[...browserTabIds].flatMap((id) => (state.browserPagesByWorkspace[id] ?? []).map((p) => p.id))
|
||||
)
|
||||
const only = <T>(map: Record<string, T>, ids: ReadonlySet<string>): Record<string, T> =>
|
||||
Object.fromEntries(Object.entries(map).filter(([id]) => ids.has(id)))
|
||||
return {
|
||||
tabsByWorktree: byKey(state.tabsByWorktree),
|
||||
terminalLayoutsByTabId: only(state.terminalLayoutsByTabId, tabIds),
|
||||
activeTabIdByWorktree: byKey(state.activeTabIdByWorktree),
|
||||
unifiedTabsByWorktree: byKey(state.unifiedTabsByWorktree),
|
||||
groupsByWorktree: byKey(state.groupsByWorktree),
|
||||
activeGroupIdByWorktree: byKey(state.activeGroupIdByWorktree),
|
||||
layoutByWorktree: byKey(state.layoutByWorktree),
|
||||
openFiles: files,
|
||||
editorDrafts: only(state.editorDrafts, fileIds),
|
||||
markdownFrontmatterVisible: only(state.markdownFrontmatterVisible, fileIds),
|
||||
activeFileIdByWorktree: byKey(state.activeFileIdByWorktree),
|
||||
browserTabsByWorktree: byKey(state.browserTabsByWorktree),
|
||||
browserPagesByWorkspace: only(state.browserPagesByWorkspace, browserTabIds),
|
||||
remoteBrowserPageHandlesByPageId: only(state.remoteBrowserPageHandlesByPageId, pageIds),
|
||||
activeBrowserTabIdByWorktree: byKey(state.activeBrowserTabIdByWorktree),
|
||||
activeTabTypeByWorktree: byKey(state.activeTabTypeByWorktree)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty when the flush left the store as the full build of its workspaces leaves it. A workspace
|
||||
* the flush built in full ran the reference itself (and a second run mints other ids for a new
|
||||
* page), so a flush that built one in full is checked on its other workspaces' records.
|
||||
*/
|
||||
export function layoutFrameApplyErrors({
|
||||
before,
|
||||
after,
|
||||
cache,
|
||||
changed,
|
||||
fullBuilt,
|
||||
rowFieldsTaken
|
||||
}: LayoutFrameApplyObservation): string[] {
|
||||
const keys = changed.filter((key) => key in cache && !fullBuilt.includes(key))
|
||||
if (keys.length === 0) {
|
||||
return []
|
||||
}
|
||||
const built: AppState = {
|
||||
...before,
|
||||
...fullLayoutFrameBuildPatch(before, cache, keys, {
|
||||
keepsTerminalLayout: isTabPaneTreeHeld,
|
||||
newRowFields: (tabId) => rowFieldsTaken.get(tabId) ?? null,
|
||||
held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld }
|
||||
})
|
||||
}
|
||||
const whole = fullBuilt.length === 0
|
||||
const expected: Record<string, unknown> = whole ? { ...built } : recordsOf(built, keys)
|
||||
const shown: Record<string, unknown> = whole ? { ...after } : recordsOf(after, keys)
|
||||
return FRAME_SLICES.flatMap((slice) =>
|
||||
stableJson(shown[slice]) === stableJson(expected[slice])
|
||||
? []
|
||||
: [firstDifference(slice, shown[slice], expected[slice])]
|
||||
)
|
||||
}
|
||||
|
||||
/** For a shared transport with no test of its own to report to: the flush itself fails. */
|
||||
export function throwLayoutFrameApplyErrors(observation: LayoutFrameApplyObservation): void {
|
||||
const errors = layoutFrameApplyErrors(observation)
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`layout frame apply differs from the full build:\n${errors.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The first path where the store and the full build differ, with both values there. */
|
||||
function firstDifference(path: string, shown: unknown, built: unknown): string {
|
||||
if (isObject(shown) && isObject(built)) {
|
||||
for (const key of new Set([...Object.keys(shown), ...Object.keys(built)])) {
|
||||
if (stableJson(shown[key]) !== stableJson(built[key])) {
|
||||
return firstDifference(`${path}.${key}`, shown[key], built[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${path}: store ${stableJson(shown)} != full build ${stableJson(built)}`
|
||||
}
|
||||
|
||||
export function installLayoutFrameApplyCheck(): {
|
||||
/** Every mismatch found, flush by flush. */
|
||||
failures: string[]
|
||||
/** Workspaces built in full and from the store's view, in flush order. */
|
||||
fullBuilt: string[]
|
||||
workspaceBuilt: string[]
|
||||
checked: () => number
|
||||
/** Forgets the builds so far, such as startup's. */
|
||||
forgetBuilds: () => void
|
||||
uninstall: () => void
|
||||
} {
|
||||
const failures: string[] = []
|
||||
const fullBuilt: string[] = []
|
||||
const workspaceBuilt: string[] = []
|
||||
let checked = 0
|
||||
observeLayoutFrameApply((observation) => {
|
||||
checked += 1
|
||||
fullBuilt.push(...observation.fullBuilt)
|
||||
workspaceBuilt.push(...observation.workspaceBuilt)
|
||||
failures.push(...layoutFrameApplyErrors(observation))
|
||||
})
|
||||
return {
|
||||
failures,
|
||||
fullBuilt,
|
||||
workspaceBuilt,
|
||||
checked: () => checked,
|
||||
forgetBuilds: () => {
|
||||
fullBuilt.length = 0
|
||||
workspaceBuilt.length = 0
|
||||
},
|
||||
uninstall: () => observeLayoutFrameApply(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Test hook on the window's frame apply: `installLayoutFrameApplyCheck` (installed by the frame
|
||||
// tests) checks after every flush that the store shows what the full build shows for the same
|
||||
// cache, view and facts. Release and dev builds install none.
|
||||
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
|
||||
export type LayoutFrameApplyObservation = {
|
||||
/** The store before and after the flush. */
|
||||
before: AppState
|
||||
after: AppState
|
||||
cache: WorkspaceLayoutCache
|
||||
/** Workspaces whose frames the flush showed. */
|
||||
changed: readonly string[]
|
||||
/** Of those, the ones built in full and the ones built from the store's view. */
|
||||
fullBuilt: readonly string[]
|
||||
workspaceBuilt: readonly string[]
|
||||
/** The window's own fields each new row took (each intent is taken once). */
|
||||
rowFieldsTaken: ReadonlyMap<string, Partial<TerminalTab>>
|
||||
}
|
||||
|
||||
type LayoutFrameApplyObserver = (observation: LayoutFrameApplyObservation) => void
|
||||
|
||||
let observer: LayoutFrameApplyObserver | null = null
|
||||
|
||||
/** Null removes the observer. */
|
||||
export function observeLayoutFrameApply(next: LayoutFrameApplyObserver | null): void {
|
||||
observer = next
|
||||
}
|
||||
|
||||
export function layoutFrameApplyObserver(): LayoutFrameApplyObserver | null {
|
||||
return observer
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, 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 WorkspaceSessionModule from '@/lib/workspace-session'
|
||||
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'
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
GIT_KEY,
|
||||
leaf
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
@@ -23,17 +21,14 @@ import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { holdTabPaneTree } from './layout-owner-pane-intents'
|
||||
import { holdDrawnTabs } from './layout-owner-drawn-tab-holds'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture'
|
||||
|
||||
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) }
|
||||
})
|
||||
// The rebuild reads the window's session back first: a call means a workspace rebuild ran.
|
||||
vi.mock('@/lib/workspace-session', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceSessionModule>()
|
||||
return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) }
|
||||
})
|
||||
|
||||
const stream = vi.hoisted(
|
||||
(): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null })
|
||||
@@ -99,15 +94,14 @@ async function boot() {
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const first = publishedOf(session)
|
||||
stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }] })
|
||||
const frames = mainLayoutFrameSender()
|
||||
stream.onFrame!(frames.snapshot({ [GIT_KEY]: first }))
|
||||
sync.start()
|
||||
await settle()
|
||||
vi.mocked(buildWorkspaceSessionPayload).mockClear()
|
||||
check.forgetBuilds()
|
||||
const send = async (layout: PublishedWorkspaceLayout, asSnapshot = false): Promise<void> => {
|
||||
stream.onFrame!(
|
||||
asSnapshot
|
||||
? { type: 'snapshot', workspaces: [{ key: GIT_KEY, layout }] }
|
||||
: { type: 'workspace', key: GIT_KEY, layout }
|
||||
asSnapshot ? frames.snapshot({ [GIT_KEY]: layout }) : frames.records(GIT_KEY, layout)
|
||||
)
|
||||
await settle()
|
||||
}
|
||||
@@ -127,7 +121,15 @@ async function rebuiltState(frames: PublishedWorkspaceLayout[]) {
|
||||
const slicesOf = (state: Record<string, unknown>) =>
|
||||
Object.fromEntries(SLICES.map((slice) => [slice, state[slice]]))
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
endLayoutFramePositions()
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
// A respawn of a gone process: its pane is unbound, then bound again.
|
||||
@@ -154,9 +156,9 @@ describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
const shown = store.getState()
|
||||
sync.close()
|
||||
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
const rebuilt = await rebuiltState(frames)
|
||||
expect(buildWorkspaceSessionPayload).toHaveBeenCalled()
|
||||
expect(check.fullBuilt).not.toEqual([])
|
||||
expect(slicesOf(shown)).toEqual(slicesOf(rebuilt))
|
||||
// Presence: the binding the frame names is the store's.
|
||||
const [leafId, ptyId] = steps.at(-1)!
|
||||
@@ -182,11 +184,11 @@ describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
|
||||
stop()
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a tab the window’s own pane command holds as the store has it', async () => {
|
||||
it('keeps a tab the window’s own pane command holds as the store has it, then shows its binding', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const before = store.getState().terminalLayoutsByTabId['tab-shell']
|
||||
const release = holdTabPaneTree('tab-shell')
|
||||
@@ -195,10 +197,15 @@ describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before)
|
||||
release()
|
||||
await settle()
|
||||
// Released, the tab shows the binding main wrote meanwhile.
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe(
|
||||
'pty-fresh'
|
||||
)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a tab the window drew before main answered as the store has it', async () => {
|
||||
it('keeps a tab the window drew before main answered as the store has it, then shows its binding', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const before = store.getState().terminalLayoutsByTabId['tab-shell']
|
||||
const release = holdDrawnTabs([{ workspace: GIT_KEY, ids: ['tab-shell'] }])
|
||||
@@ -207,17 +214,23 @@ describe('a frame that changes only panes’ terminal bindings', () => {
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before)
|
||||
release()
|
||||
await settle()
|
||||
// Released, the tab shows the binding main wrote meanwhile.
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe(
|
||||
'pty-fresh'
|
||||
)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('still rebuilds a frame that also changes structure', async () => {
|
||||
it('builds only its workspace, from the store’s view, for a frame that also renames', async () => {
|
||||
const { store, sync, first, send } = await boot()
|
||||
const renamed = rebound(first, 'tab-shell', leaf(1), 'pty-fresh')
|
||||
renamed.tabs.find((tab) => tab.entityId === 'tab-shell')!.customTitle = 'renamed'
|
||||
|
||||
await send(renamed)
|
||||
|
||||
expect(buildWorkspaceSessionPayload).toHaveBeenCalled()
|
||||
expect(check.fullBuilt).toEqual([])
|
||||
expect(check.workspaceBuilt).toEqual([GIT_KEY])
|
||||
expect(
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')
|
||||
).toMatchObject({ customTitle: 'renamed' })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// A frame that changes only its panes' terminal bindings shows without a workspace rebuild: the
|
||||
// store's pane trees take the frame's bindings, and each tab's focused pane is resolved as the
|
||||
// rebuild resolves it. Anything only a rebuild shows exactly falls back to one.
|
||||
// Panes' terminal bindings a frame changed show without a workspace build: the store's pane trees
|
||||
// take the frame's bindings, and each tab's focused pane is resolved as the build resolves it.
|
||||
// Anything only a build shows exactly falls back to one.
|
||||
|
||||
import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -19,14 +19,16 @@ function sharesATerminal(layout: PublishedWorkspaceLayout): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* `layouts` with the frame's bindings shown, the same object when it already shows them. Null
|
||||
* when only a rebuild shows them as the rebuild would: a terminal two panes name (restore releases
|
||||
* one), a pane tree the store lacks or draws otherwise, or a tab the window's own command holds.
|
||||
* `layouts` with the frame's bindings of the `rebound` terminal tabs shown, the same object when it
|
||||
* already shows them. A tab the window's own command holds stays as the store has it, as a build
|
||||
* keeps it. Null when only a build shows them as it would: a terminal two panes name (restore
|
||||
* releases one), or a pane tree the store lacks or draws otherwise.
|
||||
*/
|
||||
export function terminalLayoutsWithFrameBindings(
|
||||
layouts: TerminalLayouts,
|
||||
workspace: string,
|
||||
frame: PublishedWorkspaceLayout
|
||||
frame: PublishedWorkspaceLayout,
|
||||
rebound: ReadonlySet<string>
|
||||
): TerminalLayouts | null {
|
||||
if (sharesATerminal(frame)) {
|
||||
return null
|
||||
@@ -34,7 +36,13 @@ export function terminalLayoutsWithFrameBindings(
|
||||
let next = layouts
|
||||
for (const tab of frame.tabs) {
|
||||
const panes = tab.terminal?.panes
|
||||
if (!panes) {
|
||||
if (
|
||||
!panes ||
|
||||
!rebound.has(tab.entityId) ||
|
||||
isTabPaneTreeHeld(tab.entityId) ||
|
||||
isDrawnTabHeld(workspace, tab.id) ||
|
||||
isDrawnTabHeld(workspace, tab.entityId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const current = layouts[tab.entityId]
|
||||
@@ -46,10 +54,7 @@ export function terminalLayoutsWithFrameBindings(
|
||||
if (
|
||||
!current?.root ||
|
||||
panes.some((pane) => !layoutContainsLeafId(current.root, pane.leafId)) ||
|
||||
Object.keys(bound).some((leafId) => !leafIds.has(leafId)) ||
|
||||
isTabPaneTreeHeld(tab.entityId) ||
|
||||
isDrawnTabHeld(workspace, tab.id) ||
|
||||
isDrawnTabHeld(workspace, tab.entityId)
|
||||
Object.keys(bound).some((leafId) => !leafIds.has(leafId))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// The two ways a workspace's store slices are built from its frame: the full build reads this
|
||||
// window's view and facts back through its save and the Loader; the workspace build reads them
|
||||
// from the store's records. Both run every restore builder over the named workspaces only, and
|
||||
// lay the result over the store record by record (workspace-layout-frame-store-patch).
|
||||
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices'
|
||||
import { buildLayoutFrameStorePatch } from '@/store/workspace-layout-frame-store-patch'
|
||||
import type { HeldDrawnTabs } from '@/store/workspace-layout-frame-held-tabs'
|
||||
import { layoutViewAndFactsOfStore } from '@/store/workspace-layout-frame-store-view'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type {
|
||||
DesktopLayoutView,
|
||||
LayoutContentFacts
|
||||
} 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'
|
||||
|
||||
/** What the window's own commands hold as drawn; see buildLayoutFrameStorePatch. */
|
||||
export type LayoutFrameBuildHolds = {
|
||||
keepsTerminalLayout: (tabId: string) => boolean
|
||||
newRowFields: (tabId: string) => Partial<TerminalTab> | null
|
||||
held: HeldDrawnTabs
|
||||
}
|
||||
|
||||
export type LayoutFrameBuildPatch = Partial<AppState>
|
||||
|
||||
/** A bucket, not a host: the view read-back routes the workspaces a build skips here. */
|
||||
const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces'
|
||||
|
||||
function patchFromSlices(
|
||||
state: AppState,
|
||||
cache: WorkspaceLayoutCache,
|
||||
keys: readonly string[],
|
||||
{ view, facts }: { view: DesktopLayoutView; facts: LayoutContentFacts },
|
||||
holds: LayoutFrameBuildHolds
|
||||
): LayoutFrameBuildPatch {
|
||||
const heldFiles = new Set(state.openFiles.map((file) => `${file.worktreeId}\0${file.id}`))
|
||||
const built = buildLayoutFrameSlices({
|
||||
layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])),
|
||||
facts,
|
||||
view,
|
||||
holdsEditorFile: (worktreeId, fileId) => heldFiles.has(`${worktreeId}\0${fileId}`)
|
||||
})
|
||||
return buildLayoutFrameStorePatch(
|
||||
state,
|
||||
built,
|
||||
keys,
|
||||
holds.keepsTerminalLayout,
|
||||
holds.newRowFields,
|
||||
holds.held
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup, a reopened stream and a workspace new to the stream: the Loader also repairs the
|
||||
* legacy shapes a session restored from disk can hold, which the store's records then still have.
|
||||
*/
|
||||
export function fullLayoutFrameBuildPatch(
|
||||
state: AppState,
|
||||
cache: WorkspaceLayoutCache,
|
||||
keys: readonly string[],
|
||||
holds: LayoutFrameBuildHolds
|
||||
): LayoutFrameBuildPatch {
|
||||
const built = new Set(keys)
|
||||
const own = splitWorkspaceSessionByHost(buildWorkspaceSessionPayload(state), (key) =>
|
||||
built.has(key) ? LOCAL_EXECUTION_HOST_ID : OTHER_WORKSPACES
|
||||
)[LOCAL_EXECUTION_HOST_ID]!
|
||||
const { facts, desktopView } = loadWorkspaceLayoutWithoutReport(
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
own,
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
return patchFromSlices(state, cache, keys, { view: desktopView, facts }, holds)
|
||||
}
|
||||
|
||||
/** A workspace the store already shows from frames, whose records a frame changed. */
|
||||
export function workspaceLayoutFrameBuildPatch(
|
||||
state: AppState,
|
||||
cache: WorkspaceLayoutCache,
|
||||
keys: readonly string[],
|
||||
holds: LayoutFrameBuildHolds
|
||||
): LayoutFrameBuildPatch {
|
||||
return patchFromSlices(state, cache, keys, layoutViewAndFactsOfStore(state, keys), holds)
|
||||
}
|
||||
@@ -1,69 +1,22 @@
|
||||
// What changed between two frames of one workspace: its structure, only its panes' terminal
|
||||
// bindings, or only their live titles. Each JSON is kept per frame: title frames arrive at spinner
|
||||
// rate, and a respawned terminal's binding frames one per restored pane.
|
||||
// Live title changes of the terminal tabs a frame retitled: title frames arrive at spinner rate,
|
||||
// so each shows as a title update of its tab alone.
|
||||
|
||||
import { shownLiveTitle } from '@/store/workspace-layout-frame-slices'
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type {
|
||||
PublishedPane,
|
||||
PublishedTab,
|
||||
PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
|
||||
const structureJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
const unboundJsonByLayout = new WeakMap<PublishedWorkspaceLayout, string>()
|
||||
|
||||
function jsonWithoutPaneFields(
|
||||
layout: PublishedWorkspaceLayout,
|
||||
strip: (pane: PublishedPane) => object
|
||||
): string {
|
||||
return stableJson({
|
||||
...layout,
|
||||
tabs: layout.tabs.map((tab) =>
|
||||
tab.terminal
|
||||
? { ...tab, terminal: { ...tab.terminal, panes: tab.terminal.panes.map(strip) } }
|
||||
: tab
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function cached(
|
||||
cache: WeakMap<PublishedWorkspaceLayout, string>,
|
||||
layout: PublishedWorkspaceLayout,
|
||||
strip: (pane: PublishedPane) => object
|
||||
): string {
|
||||
let json = cache.get(layout)
|
||||
if (json === undefined) {
|
||||
json = jsonWithoutPaneFields(layout, strip)
|
||||
cache.set(layout, json)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/** A frame's JSON without live titles. */
|
||||
export function structureJson(layout: PublishedWorkspaceLayout): string {
|
||||
return cached(structureJsonByLayout, layout, ({ liveTitle: _liveTitle, ...pane }) => pane)
|
||||
}
|
||||
|
||||
/** A frame's JSON without live titles or terminal bindings: a pane's binding is its status. */
|
||||
export function unboundStructureJson(layout: PublishedWorkspaceLayout): string {
|
||||
return cached(
|
||||
unboundJsonByLayout,
|
||||
layout,
|
||||
({ liveTitle: _liveTitle, ptyId: _ptyId, ptyGeneration: _ptyGeneration, ...pane }) => pane
|
||||
)
|
||||
}
|
||||
|
||||
/** Title changes between two frames of one workspace, for the pane each tab shows. */
|
||||
/** Each retitled tab's change for the pane it shows, against the tab as the store showed it. */
|
||||
export function liveTitleUpdates(
|
||||
shown: PublishedWorkspaceLayout,
|
||||
titled: ReadonlyMap<string, PublishedTab>,
|
||||
next: PublishedWorkspaceLayout,
|
||||
focusedLeafId: (tabId: string) => string | null | undefined
|
||||
): TerminalTabTitleUpdate[] {
|
||||
const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab]))
|
||||
const updates: TerminalTabTitleUpdate[] = []
|
||||
for (const tab of next.tabs) {
|
||||
const shownTab = shownById.get(tab.id)
|
||||
const shownTab = titled.get(tab.entityId)
|
||||
if (!tab.terminal || !shownTab) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layou
|
||||
import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture'
|
||||
|
||||
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() } }))
|
||||
@@ -91,11 +93,12 @@ function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout {
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
let seq = 0
|
||||
let frames = mainLayoutFrameSender()
|
||||
/** A frame main sent before it had the window's change: another tab got renamed. */
|
||||
function sendStaleFrame(layout: PublishedWorkspaceLayout): void {
|
||||
const stale = structuredClone(layout)
|
||||
stale.tabs.find((tab) => tab.id === 'tab-unbound')!.customTitle = `renamed ${++seq}`
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: stale, seq })
|
||||
stream.onFrame!(frames.records(GIT_KEY, stale, seq))
|
||||
stream.onPosition!(seq)
|
||||
}
|
||||
|
||||
@@ -105,7 +108,8 @@ async function boot() {
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const before = publishedOf(session)
|
||||
stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: before }], seq: ++seq })
|
||||
frames = mainLayoutFrameSender()
|
||||
stream.onFrame!(frames.snapshot({ [GIT_KEY]: before }, ++seq))
|
||||
stream.onPosition!(seq)
|
||||
sync.start()
|
||||
await settle()
|
||||
@@ -119,7 +123,7 @@ async function boot() {
|
||||
const frame = publishedOf(buildWorkspaceSessionPayload(store.getState()))
|
||||
mainChange(frame)
|
||||
seq += 1
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: frame, seq })
|
||||
stream.onFrame!(frames.records(GIT_KEY, frame, seq))
|
||||
stream.onPosition!(seq)
|
||||
rpc.calls[index]!.answer({ ...reply, frameSeq: seq })
|
||||
await settle()
|
||||
@@ -168,11 +172,17 @@ function drawTab(
|
||||
return drawn
|
||||
}
|
||||
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
rpc.calls = []
|
||||
vi.clearAllMocks()
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
endLayoutFramePositions()
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
|
||||
describe('a tab the window drew, closed or moved, with a frame from before main had it', () => {
|
||||
it('keeps a just-created terminal tab, then shows main’s copy', async () => {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Main's operations as the window draws them from their records frames (design E2a): each shows
|
||||
// from the store's own view, without reading the window's session back, and leaves the store as
|
||||
// the full build does (the apply check, installed below).
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
|
||||
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { testContext } from '../../../shared/workspace-layout/workspace-layout-command.test-fixture'
|
||||
import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import {
|
||||
loadMainLayoutModel,
|
||||
mainLayoutFrameSender,
|
||||
publishedLayoutsOf
|
||||
} from './main-loaded-layout-frames.test-fixture'
|
||||
import {
|
||||
installLayoutFrameApplyCheck,
|
||||
layoutFrameApplyErrors
|
||||
} from './workspace-layout-frame-apply-check.test-fixture'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
|
||||
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) }
|
||||
})
|
||||
|
||||
const stream = vi.hoisted(
|
||||
(): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null })
|
||||
)
|
||||
vi.mock('./runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const SHELL_LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
endLayoutFramePositions()
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
/** A window shown from main's snapshot; `apply` runs a command on main's model and sends its frame. */
|
||||
async function boot() {
|
||||
const session = localDesktopSession()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
let model: WorkspaceLayoutModel = loadMainLayoutModel('local', session)
|
||||
const context = testContext()
|
||||
const frames = mainLayoutFrameSender()
|
||||
stream.onFrame!(frames.snapshot(publishedLayoutsOf(model)))
|
||||
sync.start()
|
||||
await settle()
|
||||
check.forgetBuilds()
|
||||
const apply = async (...commands: LayoutCommand[]): Promise<void> => {
|
||||
for (const command of commands) {
|
||||
const applied = applyLayoutCommand(model, command, context)
|
||||
if (!applied.ok) {
|
||||
throw new Error(`main refused ${command.type}: ${applied.code}`)
|
||||
}
|
||||
model = applied.model
|
||||
stream.onFrame!(frames.records(GIT_KEY, publishedLayoutsOf(model)[GIT_KEY]!))
|
||||
}
|
||||
await settle()
|
||||
}
|
||||
return { store, apply, close: () => sync.close() }
|
||||
}
|
||||
|
||||
const on = { workspace: GIT_KEY }
|
||||
|
||||
describe('main’s operations drawn record by record', () => {
|
||||
it.each<[string, LayoutCommand[]]>([
|
||||
['a rename', [{ ...on, type: 'renameTab', tabId: 'tab-shell', kind: 'custom', title: 'api' }]],
|
||||
['a color and a pin', [{ ...on, type: 'setTabProps', tabId: 'tab-unbound', color: '#22c55e' }]],
|
||||
[
|
||||
'a split',
|
||||
[{ ...on, type: 'splitPane', tabId: 'tab-shell', leafId: SHELL_LEAF, direction: 'vertical' }]
|
||||
],
|
||||
['a pane close', [{ ...on, type: 'closePane', tabId: 'tab-shell', leafId: SHELL_LEAF }]],
|
||||
['a tab close', [{ ...on, type: 'closeTabs', tabIds: ['tab-unbound'] }]],
|
||||
['a move', [{ ...on, type: 'moveTab', tabId: 'tab-unbound', toGroupId: 'group-a', index: 0 }]],
|
||||
[
|
||||
'a group change',
|
||||
[
|
||||
{
|
||||
...on,
|
||||
type: 'splitGroup',
|
||||
tabId: 'tab-unbound',
|
||||
besideGroupId: 'group-b',
|
||||
direction: 'down'
|
||||
}
|
||||
]
|
||||
],
|
||||
['a new terminal', [{ ...on, type: 'createTerminalTab', groupId: 'group-b', title: 'new' }]],
|
||||
[
|
||||
'an editor open',
|
||||
[
|
||||
{
|
||||
...on,
|
||||
type: 'openEditorTab',
|
||||
fileId: '/Users/dev/orca/src/main.ts',
|
||||
contentType: 'editor',
|
||||
file: {
|
||||
filePath: '/Users/dev/orca/src/main.ts',
|
||||
relativePath: 'src/main.ts',
|
||||
language: 'typescript'
|
||||
},
|
||||
groupId: 'group-a'
|
||||
}
|
||||
]
|
||||
]
|
||||
])('shows %s without the full build', async (_, commands) => {
|
||||
const { store, apply, close } = await boot()
|
||||
const before = store.getState()
|
||||
|
||||
await apply(...commands)
|
||||
|
||||
expect(check.fullBuilt).toEqual([])
|
||||
expect(check.workspaceBuilt).toEqual([GIT_KEY])
|
||||
// Presence: the frame changed what the store shows.
|
||||
expect(store.getState()).not.toBe(before)
|
||||
close()
|
||||
})
|
||||
})
|
||||
|
||||
type Store = Awaited<ReturnType<typeof boot>>['store']
|
||||
|
||||
const groupsOf = (store: Store) => store.getState().groupsByWorktree[GIT_KEY]!
|
||||
|
||||
describe('a frame over view state the save trims', () => {
|
||||
it.each<[string, (store: Store) => void]>([
|
||||
[
|
||||
'a focused diff tab, which the save does not keep',
|
||||
(store) => {
|
||||
const file = store.getState().openFiles.find((open) => open.worktreeId === GIT_KEY)!
|
||||
const diff = { ...file, id: `${file.id}::diff`, mode: 'diff' as const }
|
||||
store.setState({
|
||||
openFiles: [...store.getState().openFiles, diff],
|
||||
activeFileIdByWorktree: {
|
||||
...store.getState().activeFileIdByWorktree,
|
||||
[GIT_KEY]: diff.id
|
||||
},
|
||||
activeTabTypeByWorktree: {
|
||||
...store.getState().activeTabTypeByWorktree,
|
||||
[GIT_KEY]: 'editor'
|
||||
}
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'a group whose shown tab is gone',
|
||||
(store) =>
|
||||
store.setState({
|
||||
groupsByWorktree: {
|
||||
[GIT_KEY]: groupsOf(store).map((group, index) =>
|
||||
index === 0 ? { ...group, activeTabId: 'gone' } : group
|
||||
)
|
||||
}
|
||||
})
|
||||
],
|
||||
[
|
||||
'an empty group, active',
|
||||
(store) =>
|
||||
store.setState({
|
||||
groupsByWorktree: {
|
||||
[GIT_KEY]: [
|
||||
...groupsOf(store),
|
||||
{ id: 'group-empty', worktreeId: GIT_KEY, activeTabId: null, tabOrder: [] }
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [GIT_KEY]: 'group-empty' }
|
||||
})
|
||||
],
|
||||
[
|
||||
'a group id twice, where the Loader keeps the first',
|
||||
(store) =>
|
||||
store.setState({
|
||||
groupsByWorktree: {
|
||||
[GIT_KEY]: [...groupsOf(store), { ...groupsOf(store)[0]!, activeTabId: 'tab-shell' }]
|
||||
}
|
||||
})
|
||||
]
|
||||
])('shows a rename over %s as the full build does', async (_, drawState) => {
|
||||
const { store, apply, close } = await boot()
|
||||
drawState(store)
|
||||
|
||||
await apply({ ...on, type: 'renameTab', tabId: 'tab-shell', kind: 'custom', title: 'api' })
|
||||
|
||||
expect(check.fullBuilt).toEqual([])
|
||||
expect(check.workspaceBuilt).toEqual([GIT_KEY])
|
||||
close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the apply check on a flush that built one workspace in full', () => {
|
||||
it('still checks the workspace built from the store view', async () => {
|
||||
const { store, close } = await boot()
|
||||
const shown = store.getState()
|
||||
const cache = publishedLayoutsOf(loadMainLayoutModel('local', localDesktopSession()))
|
||||
const observe = (after: typeof shown) =>
|
||||
layoutFrameApplyErrors({
|
||||
before: shown,
|
||||
after,
|
||||
cache,
|
||||
changed: [GIT_KEY, 'repo-other::/elsewhere'],
|
||||
fullBuilt: ['repo-other::/elsewhere'],
|
||||
workspaceBuilt: [GIT_KEY],
|
||||
rowFieldsTaken: new Map()
|
||||
})
|
||||
// Precondition: the store as booted is what the full build shows.
|
||||
expect(observe(shown)).toEqual([])
|
||||
|
||||
const wrong = {
|
||||
...shown,
|
||||
unifiedTabsByWorktree: {
|
||||
...shown.unifiedTabsByWorktree,
|
||||
[GIT_KEY]: shown.unifiedTabsByWorktree[GIT_KEY]!.map((tab) => ({
|
||||
...tab,
|
||||
customTitle: 'drawn wrong'
|
||||
}))
|
||||
}
|
||||
}
|
||||
expect(observe(wrong)).toEqual([expect.stringContaining('unifiedTabsByWorktree')])
|
||||
close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// Main's pane trees shown at once, against the store rather than the frame: a tab the window's own
|
||||
// pane command held may lag the trees drawn since. Only a tab it still holds waits.
|
||||
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
|
||||
|
||||
type TerminalLayouts = AppState['terminalLayoutsByTabId']
|
||||
|
||||
/** `layouts` with the frames' trees of `keys`, and the workspaces a held tab still keeps waiting. */
|
||||
export function terminalLayoutsWithFramePaneTrees(
|
||||
layouts: TerminalLayouts,
|
||||
cache: WorkspaceLayoutCache,
|
||||
keys: Iterable<string>,
|
||||
drawn: (key: string) => boolean
|
||||
): { layouts: TerminalLayouts; waiting: Set<string> } {
|
||||
let next = layouts
|
||||
const waiting = new Set<string>()
|
||||
for (const key of keys) {
|
||||
const layout = cache[key]
|
||||
if (!layout || !drawn(key)) {
|
||||
continue
|
||||
}
|
||||
for (const tab of layout.tabs) {
|
||||
const root = tab.terminal?.root
|
||||
const current = root ? next[tab.entityId] : undefined
|
||||
if (!root || !current) {
|
||||
continue
|
||||
}
|
||||
if (isTabPaneTreeHeld(tab.entityId)) {
|
||||
waiting.add(key)
|
||||
} else if (stableJson(current.root) !== stableJson(root)) {
|
||||
next = { ...next, [tab.entityId]: { ...current, root } }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { layouts: next, waiting }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// What a workspace's records frames changed since the store last showed it, kept per record so a
|
||||
// flush shows only those records: panes' terminal bindings and live titles apply alone; any other
|
||||
// record kind takes a workspace build.
|
||||
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import type { WorkspaceLayoutRecordsFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import type {
|
||||
PublishedPane,
|
||||
PublishedTab,
|
||||
PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type {
|
||||
PublishedPaneTree,
|
||||
WorkspaceLayoutRecordChange
|
||||
} from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
|
||||
export type PendingWorkspaceChanges = {
|
||||
/** Tab rows, the tab set or order, groups, a pane tree's shape, editor files or browser tabs. */
|
||||
rebuild: boolean
|
||||
/** Terminal entity id → the tab as the store last showed it, for tabs whose live titles changed. */
|
||||
titled: Map<string, PublishedTab>
|
||||
/** Terminal entity ids whose panes' bindings changed. */
|
||||
rebound: Set<string>
|
||||
}
|
||||
|
||||
/** The workspace's pending changes, started empty. */
|
||||
export function pendingWorkspaceChanges(
|
||||
pending: Map<string, PendingWorkspaceChanges>,
|
||||
key: string
|
||||
): PendingWorkspaceChanges {
|
||||
let changes = pending.get(key)
|
||||
if (!changes) {
|
||||
changes = { rebuild: false, titled: new Map(), rebound: new Set() }
|
||||
pending.set(key, changes)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/** A pane tree without its panes' live titles or bindings. */
|
||||
function unboundPaneTreeJson(
|
||||
tree: Pick<PublishedPaneTree, 'root' | 'chatLeafId' | 'panes'>
|
||||
): string {
|
||||
return stableJson({
|
||||
root: tree.root,
|
||||
chatLeafId: tree.chatLeafId,
|
||||
panes: tree.panes.map(
|
||||
({
|
||||
liveTitle: _liveTitle,
|
||||
ptyId: _ptyId,
|
||||
ptyGeneration: _ptyGeneration,
|
||||
...pane
|
||||
}: PublishedPane) => pane
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function tabOwningLeaf(
|
||||
layouts: readonly PublishedWorkspaceLayout[],
|
||||
leafId: string
|
||||
): PublishedTab | undefined {
|
||||
for (const layout of layouts) {
|
||||
const tab = layout.tabs.find((candidate) =>
|
||||
candidate.terminal?.panes.some((pane) => pane.leafId === leafId)
|
||||
)
|
||||
if (tab) {
|
||||
return tab
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Adds one records change to what `pending` holds; `before` is the cache's copy it applied to. */
|
||||
function notePendingRecordChange(
|
||||
pending: PendingWorkspaceChanges,
|
||||
before: PublishedWorkspaceLayout,
|
||||
after: PublishedWorkspaceLayout,
|
||||
change: Extract<WorkspaceLayoutRecordChange, { change: 'records' }>
|
||||
): void {
|
||||
if (change.workspace || change.tabs || change.editorFiles || change.browserTabs) {
|
||||
pending.rebuild = true
|
||||
}
|
||||
if (change.paneTrees?.removed?.length) {
|
||||
pending.rebuild = true
|
||||
}
|
||||
for (const tree of change.paneTrees?.set ?? []) {
|
||||
const shown = before.tabs.find((tab) => tab.id === tree.tabId)
|
||||
if (!shown?.terminal || unboundPaneTreeJson(shown.terminal) !== unboundPaneTreeJson(tree)) {
|
||||
pending.rebuild = true
|
||||
} else if (!pending.titled.has(shown.entityId)) {
|
||||
pending.titled.set(shown.entityId, shown)
|
||||
}
|
||||
}
|
||||
for (const leafId of [
|
||||
...(change.bindings?.set ?? []).map((binding) => binding.leafId),
|
||||
...(change.bindings?.removed ?? [])
|
||||
]) {
|
||||
const tab = tabOwningLeaf([after, before], leafId)
|
||||
if (tab) {
|
||||
pending.rebound.add(tab.entityId)
|
||||
} else {
|
||||
pending.rebuild = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a records frame's changes, each against the cache's copy `before` it applied to. A
|
||||
* workspace new to the stream, sent whole or gone leaves `shown`: only a full build shows it.
|
||||
*/
|
||||
export function notePendingRecordsFrame(
|
||||
pending: Map<string, PendingWorkspaceChanges>,
|
||||
shown: Set<string>,
|
||||
before: WorkspaceLayoutCache,
|
||||
after: WorkspaceLayoutCache,
|
||||
frame: WorkspaceLayoutRecordsFrame
|
||||
): void {
|
||||
for (const change of frame.workspaces) {
|
||||
const shownLayout = before[change.key]
|
||||
const next = after[change.key]
|
||||
if (change.change !== 'records' || !shownLayout || !next) {
|
||||
shown.delete(change.key)
|
||||
} else {
|
||||
notePendingRecordChange(
|
||||
pendingWorkspaceChanges(pending, change.key),
|
||||
shownLayout,
|
||||
next,
|
||||
change
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, 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 WorkspaceSessionModule from '@/lib/workspace-session'
|
||||
import type * as WorkspaceLayoutLoadModule from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
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'
|
||||
@@ -8,33 +10,39 @@ import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
diffPublishedLayouts,
|
||||
indexPublishedLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-records'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY
|
||||
} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture'
|
||||
|
||||
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) }
|
||||
})
|
||||
// The rebuild reads the window's session back first: a call means a workspace rebuild ran.
|
||||
|
||||
// The full build reads the window's session back and runs the Loader: a call means it ran.
|
||||
vi.mock('@/lib/workspace-session', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceSessionModule>()
|
||||
return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) }
|
||||
})
|
||||
vi.mock('../../../shared/workspace-layout/workspace-layout-load', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorkspaceLayoutLoadModule>()
|
||||
return {
|
||||
...actual,
|
||||
loadWorkspaceLayoutWithoutReport: vi.fn(actual.loadWorkspaceLayoutWithoutReport)
|
||||
}
|
||||
})
|
||||
|
||||
const stream = vi.hoisted(
|
||||
(): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null })
|
||||
@@ -53,6 +61,20 @@ vi.mock('./runtime-layout-client', () => ({
|
||||
createStoreSessionMockApi()
|
||||
|
||||
const TABS = 100
|
||||
const FRAME_SLICES: (keyof LayoutFrameSlices)[] = [
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId',
|
||||
'activeTabIdByWorktree',
|
||||
'unifiedTabsByWorktree',
|
||||
'groupsByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'layoutByWorktree',
|
||||
'openFiles',
|
||||
'activeFileIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
]
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
|
||||
|
||||
@@ -75,40 +97,36 @@ function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout {
|
||||
return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local')
|
||||
}
|
||||
|
||||
/** Main's one frame for an operation that turned `before` into `after`. */
|
||||
function recordsFrame(
|
||||
before: PublishedWorkspaceLayout,
|
||||
after: PublishedWorkspaceLayout
|
||||
): WorkspaceLayoutChangeFrame {
|
||||
const change = diffPublishedLayouts(
|
||||
GIT_KEY,
|
||||
indexPublishedLayout(before),
|
||||
indexPublishedLayout(after),
|
||||
after
|
||||
)
|
||||
return { type: 'records', epoch: 'epoch-1', seq: 1, workspaces: change ? [change] : [] }
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const store = createTestStore()
|
||||
const session = hundredTabs()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const first = publishedOf(session)
|
||||
stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }], seq: 0 })
|
||||
const frames = mainLayoutFrameSender()
|
||||
stream.onFrame!(frames.snapshot({ [GIT_KEY]: first }, 0))
|
||||
sync.start()
|
||||
await settle()
|
||||
vi.mocked(buildWorkspaceSessionPayload).mockClear()
|
||||
check.forgetBuilds()
|
||||
const writes = vi.fn()
|
||||
const stopWrites = store.subscribe(writes)
|
||||
const send = async (frame: WorkspaceLayoutChangeFrame): Promise<void> => {
|
||||
stream.onFrame!(frame)
|
||||
/** Main's one frame for an operation that left the workspace as `layout`. */
|
||||
const send = async (layout: PublishedWorkspaceLayout): Promise<void> => {
|
||||
stream.onFrame!(frames.records(GIT_KEY, layout))
|
||||
await settle()
|
||||
}
|
||||
return { store, first, writes, send, close: () => (stopWrites(), sync.close()) }
|
||||
}
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
endLayoutFramePositions()
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
describe('a records frame in the window', () => {
|
||||
it('shows 100 generated titles from one frame in one store update', async () => {
|
||||
@@ -118,7 +136,7 @@ describe('a records frame in the window', () => {
|
||||
tab.generatedTitle = `Title ${index}`
|
||||
})
|
||||
|
||||
await send(recordsFrame(first, titled))
|
||||
await send(titled)
|
||||
|
||||
expect(writes).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.generatedTitle)).toEqual(
|
||||
@@ -127,30 +145,97 @@ describe('a records frame in the window', () => {
|
||||
close()
|
||||
})
|
||||
|
||||
it('shows a frame of only tab rows without reading the window’s session back', async () => {
|
||||
const { store, first, writes, send, close } = await boot()
|
||||
// The apply check runs the full build itself; this counts only the window's own calls.
|
||||
check.uninstall()
|
||||
vi.mocked(buildWorkspaceSessionPayload).mockClear()
|
||||
vi.mocked(loadWorkspaceLayoutWithoutReport).mockClear()
|
||||
const titled = structuredClone(first)
|
||||
titled.tabs.forEach((tab, index) => {
|
||||
tab.generatedTitle = `Title ${index}`
|
||||
})
|
||||
|
||||
await send(titled)
|
||||
|
||||
expect(writes).toHaveBeenCalledTimes(1)
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect(loadWorkspaceLayoutWithoutReport).not.toHaveBeenCalled()
|
||||
// Presence: the rows changed.
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]![99]!.generatedTitle).toBe('Title 99')
|
||||
close()
|
||||
})
|
||||
|
||||
it('shows a binding record without a rebuild', async () => {
|
||||
const { store, first, send, close } = await boot()
|
||||
const rebound = structuredClone(first)
|
||||
rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh'
|
||||
rebound.tabs[7]!.terminal!.panes[0]!.ptyGeneration = 2
|
||||
|
||||
await send(recordsFrame(first, rebound))
|
||||
await send(rebound)
|
||||
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({
|
||||
[leafOf(7)]: 'pty-fresh'
|
||||
})
|
||||
close()
|
||||
})
|
||||
|
||||
it('rebinds a pane in its one store record, keeping its row and tree so the xterm stays mounted', async () => {
|
||||
const { store, first, send, close } = await boot()
|
||||
const before = store.getState()
|
||||
const rebound = structuredClone(first)
|
||||
rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh'
|
||||
|
||||
await send(rebound)
|
||||
|
||||
const after = store.getState()
|
||||
expect(FRAME_SLICES.filter((slice) => after[slice] !== before[slice])).toEqual([
|
||||
'terminalLayoutsByTabId'
|
||||
])
|
||||
const changedLayouts = Object.keys(after.terminalLayoutsByTabId).filter(
|
||||
(tabId) => after.terminalLayoutsByTabId[tabId] !== before.terminalLayoutsByTabId[tabId]
|
||||
)
|
||||
expect(changedLayouts).toEqual(['tab-7'])
|
||||
expect(after.terminalLayoutsByTabId['tab-7']!.root).toBe(
|
||||
before.terminalLayoutsByTabId['tab-7']!.root
|
||||
)
|
||||
// The pane renderer is keyed by its row's id and generation: the same row, the same mount.
|
||||
expect(after.tabsByWorktree[GIT_KEY]![7]).toBe(before.tabsByWorktree[GIT_KEY]![7])
|
||||
expect(after.terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({
|
||||
[leafOf(7)]: 'pty-fresh'
|
||||
})
|
||||
close()
|
||||
})
|
||||
|
||||
it('shows 100 single-binding frames, one per task, without building a workspace', async () => {
|
||||
const { store, first, send, close } = await boot()
|
||||
let layout = first
|
||||
for (let index = 0; index < TABS; index += 1) {
|
||||
layout = structuredClone(layout)
|
||||
layout.tabs[index]!.terminal!.panes[0]!.ptyId = `pty-respawned-${index}`
|
||||
await send(layout)
|
||||
}
|
||||
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
const bindings = store.getState().terminalLayoutsByTabId
|
||||
expect(
|
||||
Array.from({ length: TABS }, (_, index) => bindings[`tab-${index}`]!.ptyIdsByLeafId)
|
||||
).toEqual(
|
||||
Array.from({ length: TABS }, (_, index) => ({ [leafOf(index)]: `pty-respawned-${index}` }))
|
||||
)
|
||||
close()
|
||||
})
|
||||
|
||||
it('writes nothing for a respawn under the same id: only its generation rose', async () => {
|
||||
const { first, writes, send, close } = await boot()
|
||||
const respawned = structuredClone(first)
|
||||
respawned.tabs[3]!.terminal!.panes[0]!.ptyGeneration = 2
|
||||
|
||||
await send(recordsFrame(first, respawned))
|
||||
await send(respawned)
|
||||
|
||||
expect(writes).not.toHaveBeenCalled()
|
||||
expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled()
|
||||
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
|
||||
close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, 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 { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
} from './layout-frame-position'
|
||||
import { holdTabPaneTree, requestTabPaneTreeRedraw } from './layout-owner-pane-intents'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { layoutSnapshotFrame, mainLoadedLayouts } from './main-loaded-layout-frames.test-fixture'
|
||||
import {
|
||||
layoutSnapshotFrame,
|
||||
mainLayoutFrameSender,
|
||||
mainLoadedLayouts
|
||||
} from './main-loaded-layout-frames.test-fixture'
|
||||
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
@@ -87,8 +92,9 @@ async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSession
|
||||
await bootWindowFromPartitions(store, [{ hostId, session }])
|
||||
const sync = openWorkspaceLayoutFrameSync(store)
|
||||
const layouts = mainLoadedLayouts(hostId, session)
|
||||
stream.onFrame!(layoutSnapshotFrame(layouts))
|
||||
return { store, sync, layouts }
|
||||
const frames = mainLayoutFrameSender()
|
||||
stream.onFrame!(frames.snapshot(layouts))
|
||||
return { store, sync, layouts, frames }
|
||||
}
|
||||
|
||||
/** Fixtures whose frames differ from the restore, as the frame parity test lists. */
|
||||
@@ -107,7 +113,15 @@ const FRAME_DIFFERENCE_FIXTURES = new Set([
|
||||
'session from before the tab bar, a row with no terminal'
|
||||
])
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
endLayoutFramePositions()
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
describe('the window drawn from layout frames', () => {
|
||||
it.each(
|
||||
@@ -128,7 +142,7 @@ describe('the window drawn from layout frames', () => {
|
||||
|
||||
it('shows a structure change main publishes, keeping what only the window knows', async () => {
|
||||
const session = localDesktopSession()
|
||||
const { store, sync, layouts } = await bootWithFrames('local', session)
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', session)
|
||||
sync.start()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
@@ -147,7 +161,7 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
const renamed = structuredClone(layouts[GIT_KEY]!)
|
||||
renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed'
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed })
|
||||
stream.onFrame!(frames.records(GIT_KEY, renamed))
|
||||
await flushMicrotasks()
|
||||
const shell = store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!
|
||||
expect(shell).toMatchObject({
|
||||
@@ -161,12 +175,12 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
|
||||
it('keeps the window’s pane remount generation through a structure frame: it is the window’s', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
store.getState().remountTerminalTabForRecovery('tab-shell')
|
||||
const renamed = structuredClone(layouts[GIT_KEY]!)
|
||||
renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed'
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed })
|
||||
stream.onFrame!(frames.records(GIT_KEY, renamed))
|
||||
await flushMicrotasks()
|
||||
expect(
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')
|
||||
@@ -185,9 +199,9 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
|
||||
it('runs a reply waiting on a frame after the store shows it, and every waiter when the stream ends', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: layouts[GIT_KEY]!, seq: 1 })
|
||||
stream.onFrame!(frames.records(GIT_KEY, layouts[GIT_KEY]!, 1))
|
||||
stream.onPosition!(1)
|
||||
await flushMicrotasks()
|
||||
const shellTitle = () =>
|
||||
@@ -198,7 +212,7 @@ describe('the window drawn from layout frames', () => {
|
||||
|
||||
const renamed = structuredClone(layouts[GIT_KEY]!)
|
||||
renamed.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'renamed'
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: renamed, seq: 2 })
|
||||
stream.onFrame!(frames.records(GIT_KEY, renamed, 2))
|
||||
stream.onPosition!(2)
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual(['renamed'])
|
||||
@@ -217,7 +231,7 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
|
||||
it('shows main’s pane tree at once, except a tab the window’s own pane command holds', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root
|
||||
const withRatio = (ratio: number) => {
|
||||
@@ -230,12 +244,12 @@ describe('the window drawn from layout frames', () => {
|
||||
}
|
||||
expect(shellRoot()?.type).toBe('split')
|
||||
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: withRatio(0.2) })
|
||||
stream.onFrame!(frames.records(GIT_KEY, withRatio(0.2)))
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toMatchObject({ ratio: 0.2 })
|
||||
|
||||
const release = holdTabPaneTree('tab-shell')
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: withRatio(0.7) })
|
||||
stream.onFrame!(frames.records(GIT_KEY, withRatio(0.7)))
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toMatchObject({ ratio: 0.2 })
|
||||
|
||||
@@ -246,7 +260,7 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
|
||||
it('shows main’s pane tree going back to one the window showed before', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root
|
||||
const original = shellRoot()
|
||||
@@ -255,23 +269,23 @@ describe('the window drawn from layout frames', () => {
|
||||
if (root.type === 'split') {
|
||||
root.ratio = 0.15
|
||||
}
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: changed })
|
||||
stream.onFrame!(frames.records(GIT_KEY, changed))
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toMatchObject({ ratio: 0.15 })
|
||||
|
||||
// Same structure as the frame shown before: its pane tree still shows.
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) })
|
||||
stream.onFrame!(frames.records(GIT_KEY, structuredClone(layouts[GIT_KEY]!)))
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toEqual(original)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps the window’s own pane trees once the stream has ended', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
await flushMicrotasks()
|
||||
// A frame still queued when the stream ends.
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) })
|
||||
stream.onFrame!(frames.records(GIT_KEY, structuredClone(layouts[GIT_KEY]!)))
|
||||
stream.onStatus!({ state: 'lost' })
|
||||
const drawn = { type: 'leaf' as const, leafId: 'drawn-by-the-window' }
|
||||
store.setState({
|
||||
@@ -319,7 +333,7 @@ describe('the window drawn from layout frames', () => {
|
||||
})
|
||||
|
||||
it('shows a live title change of the focused pane without rebuilding structure', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
const titled = structuredClone(layouts[GIT_KEY]!)
|
||||
const shell = titled.tabs.find((tab) => tab.id === 'tab-shell')!
|
||||
@@ -329,7 +343,7 @@ describe('the window drawn from layout frames', () => {
|
||||
liveTitle: pane.leafId === focused ? 'vim notes.md' : 'other pane'
|
||||
}))
|
||||
const before = store.getState()
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: titled })
|
||||
stream.onFrame!(frames.records(GIT_KEY, titled))
|
||||
await flushMicrotasks()
|
||||
const after = store.getState()
|
||||
expect(after.tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.title).toBe(
|
||||
@@ -344,11 +358,11 @@ describe('the window drawn from layout frames', () => {
|
||||
const profile = WORKSPACE_LAYOUT_PROFILES.find(
|
||||
(entry) => entry.name === 'remote server partition'
|
||||
)!
|
||||
const { store, sync, layouts } = await bootWithFrames(profile.hostId, profile.build())
|
||||
const { store, sync, layouts, frames } = await bootWithFrames(profile.hostId, profile.build())
|
||||
sync.start()
|
||||
const before = store.getState()
|
||||
const [key, layout] = Object.entries(layouts)[0]!
|
||||
stream.onFrame!({ type: 'workspace', key, layout: { ...layout, tabs: [], groups: [] } })
|
||||
stream.onFrame!(frames.records(key, { ...layout, tabs: [], groups: [] }))
|
||||
await flushMicrotasks()
|
||||
expect(SLICES.filter((slice) => store.getState()[slice] !== before[slice])).toEqual([])
|
||||
sync.close()
|
||||
|
||||
@@ -5,35 +5,40 @@
|
||||
//
|
||||
// A tab the window drew, closed or moved before main answered stays as drawn until the answer is
|
||||
// in (layout-owner-drawn-tab-holds); everything else shows as each frame arrives.
|
||||
//
|
||||
// A records frame shows only what it changed (design E2a): panes' bindings and live titles apply
|
||||
// alone, and any other record builds its workspace from the store's view. 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, whose view the window reads back through its save and the Loader.
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache'
|
||||
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
|
||||
import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices'
|
||||
import {
|
||||
buildLayoutFrameStorePatch,
|
||||
terminalLayoutsPatch
|
||||
} from '@/store/workspace-layout-frame-store-patch'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch'
|
||||
import {
|
||||
framesDrawWorkspace,
|
||||
layoutHoldsBoundPane,
|
||||
onWorkspaceStartsDrawing
|
||||
} from './workspace-layout-frame-drawn-workspaces'
|
||||
import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
liveTitleUpdates,
|
||||
structureJson,
|
||||
unboundStructureJson
|
||||
} from './workspace-layout-frame-changes'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { liveTitleUpdates } from './workspace-layout-frame-changes'
|
||||
import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings'
|
||||
import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees'
|
||||
import {
|
||||
fullLayoutFrameBuildPatch,
|
||||
workspaceLayoutFrameBuildPatch,
|
||||
type LayoutFrameBuildHolds,
|
||||
type LayoutFrameBuildPatch
|
||||
} from './workspace-layout-frame-builds'
|
||||
import {
|
||||
notePendingRecordsFrame,
|
||||
pendingWorkspaceChanges,
|
||||
type PendingWorkspaceChanges
|
||||
} from './workspace-layout-frame-pending'
|
||||
import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer'
|
||||
import {
|
||||
beginLayoutFramePositions,
|
||||
endLayoutFramePositions,
|
||||
@@ -67,156 +72,169 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */
|
||||
const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces'
|
||||
|
||||
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
|
||||
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
|
||||
/** Workspace key → the frame the store shows. */
|
||||
let shown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
/** Workspaces the store shows from frames; any other the frames draw takes a full build. */
|
||||
const shown = new Set<string>()
|
||||
/** Workspace key → what its records frames changed since the last flush. */
|
||||
const pending = new Map<string, PendingWorkspaceChanges>()
|
||||
let started = false
|
||||
let live = false
|
||||
let snapshotReceived = false
|
||||
let flushQueued = false
|
||||
let streamOpen = true
|
||||
/** Workspaces whose pane trees the store may still lack: a structure frame came in, or a tab
|
||||
* the window's own command held skipped one. */
|
||||
/** Workspaces whose pane trees the store may still lack: a build ran for them, or a tab the
|
||||
* window's own command held skipped one. */
|
||||
let paneTreeKeys = new Set<string>()
|
||||
|
||||
const rebuild = (state: AppState, keys: string[]): void => {
|
||||
// The view and content facts are this window's: read them back out of what it saves.
|
||||
const rebuilt = new Set(keys)
|
||||
const own = splitWorkspaceSessionByHost(buildWorkspaceSessionPayload(state), (key) =>
|
||||
rebuilt.has(key) ? LOCAL_EXECUTION_HOST_ID : OTHER_WORKSPACES
|
||||
)[LOCAL_EXECUTION_HOST_ID]!
|
||||
const { facts, desktopView } = loadWorkspaceLayoutWithoutReport(
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
own,
|
||||
nameBasedLoadContext()
|
||||
)
|
||||
const heldFiles = new Set(state.openFiles.map((file) => `${file.worktreeId}\0${file.id}`))
|
||||
const built = buildLayoutFrameSlices({
|
||||
layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])),
|
||||
facts,
|
||||
view: desktopView,
|
||||
holdsEditorFile: (worktreeId, fileId) => heldFiles.has(`${worktreeId}\0${fileId}`)
|
||||
})
|
||||
const patch = buildLayoutFrameStorePatch(
|
||||
state,
|
||||
built,
|
||||
keys,
|
||||
isTabPaneTreeHeld,
|
||||
takeTabRowFields,
|
||||
HELD_DRAWN_TABS
|
||||
)
|
||||
const show = (patch: LayoutFrameBuildPatch): void => {
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
}
|
||||
|
||||
// Each change against the cache's copy it applies to, before the frame replaces it.
|
||||
const noteFrame = (before: WorkspaceLayoutCache, frame: WorkspaceLayoutChangeFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'snapshot':
|
||||
// A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile.
|
||||
if (snapshotReceived) {
|
||||
shown.clear()
|
||||
}
|
||||
return
|
||||
case 'workspace':
|
||||
case 'removed':
|
||||
// An older host's whole workspace: only a full build shows it.
|
||||
shown.delete(frame.key)
|
||||
return
|
||||
case 'records':
|
||||
notePendingRecordsFrame(pending, shown, before, cache, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// Pane trees show at once; only a tab the window's own pane command holds waits.
|
||||
const showPaneTrees = (state: AppState, drawn: (key: string) => boolean): void => {
|
||||
let layouts = state.terminalLayoutsByTabId
|
||||
const waiting = new Set<string>()
|
||||
for (const key of paneTreeKeys) {
|
||||
const layout = cache[key]
|
||||
if (!layout || !drawn(key)) {
|
||||
continue
|
||||
}
|
||||
for (const tab of layout.tabs) {
|
||||
const root = tab.terminal?.root
|
||||
const current = root ? layouts[tab.entityId] : undefined
|
||||
if (!root || !current) {
|
||||
continue
|
||||
}
|
||||
if (isTabPaneTreeHeld(tab.entityId)) {
|
||||
waiting.add(key)
|
||||
} else if (stableJson(current.root) !== stableJson(root)) {
|
||||
layouts = { ...layouts, [tab.entityId]: { ...current, root } }
|
||||
}
|
||||
}
|
||||
}
|
||||
paneTreeKeys = waiting
|
||||
const patch = terminalLayoutsPatch(state, layouts)
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
const showPaneTrees = (state: AppState): void => {
|
||||
const shownTrees = terminalLayoutsWithFramePaneTrees(
|
||||
state.terminalLayoutsByTabId,
|
||||
cache,
|
||||
paneTreeKeys,
|
||||
framesDrawWorkspace(state)
|
||||
)
|
||||
paneTreeKeys = shownTrees.waiting
|
||||
show(terminalLayoutsPatch(state, shownTrees.layouts))
|
||||
}
|
||||
|
||||
// Only panes' bindings changed: the pane trees take them, unless only a rebuild shows them so.
|
||||
const showFrameBindings = (keys: string[]): void => {
|
||||
// Only panes' bindings changed: the pane trees take them, unless only a build shows them so.
|
||||
const showFrameBindings = (rebound: ReadonlyMap<string, ReadonlySet<string>>): string[] => {
|
||||
const state = store.getState()
|
||||
let layouts = state.terminalLayoutsByTabId
|
||||
const unshown = keys.filter((key) => {
|
||||
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!)
|
||||
layouts = next ?? layouts
|
||||
return next === null
|
||||
})
|
||||
const patch = terminalLayoutsPatch(state, layouts)
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
if (unshown.length > 0) {
|
||||
rebuild(store.getState(), unshown)
|
||||
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)
|
||||
}
|
||||
}
|
||||
show(terminalLayoutsPatch(state, layouts))
|
||||
return unshown
|
||||
}
|
||||
|
||||
const flush = (): void => {
|
||||
flushQueued = false
|
||||
if (!live) {
|
||||
pending.clear()
|
||||
return
|
||||
}
|
||||
const state = store.getState()
|
||||
const drawn = framesDrawWorkspace(state)
|
||||
const nextShown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
const structural: string[] = []
|
||||
const rebound: string[] = []
|
||||
const rowFieldsTaken = new Map<string, Partial<TerminalTab>>()
|
||||
const holds: LayoutFrameBuildHolds = {
|
||||
...FRAME_BUILD_HOLDS,
|
||||
newRowFields: (tabId) => {
|
||||
const fields = takeTabRowFields(tabId)
|
||||
if (fields) {
|
||||
rowFieldsTaken.set(tabId, fields)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
}
|
||||
const changed: string[] = []
|
||||
const fullBuilt: string[] = []
|
||||
const workspaceBuilt: string[] = []
|
||||
const rebound = new Map<string, ReadonlySet<string>>()
|
||||
const titleUpdates: TerminalTabTitleUpdate[] = []
|
||||
for (const [key, layout] of Object.entries(cache)) {
|
||||
const previous = shown[key]
|
||||
if (layout === previous || !drawn(key)) {
|
||||
if (previous) {
|
||||
nextShown[key] = previous
|
||||
const changes = pending.get(key)
|
||||
if (!drawn(key)) {
|
||||
// Frames the window did not draw: its next draw takes the full build.
|
||||
if (changes) {
|
||||
shown.delete(key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Against the store, not `shown`: a held frame leaves `shown` behind the pane trees drawn since.
|
||||
paneTreeKeys.add(key)
|
||||
const sameStructure = previous && structureJson(previous) === structureJson(layout)
|
||||
if (
|
||||
sameStructure ||
|
||||
(previous && unboundStructureJson(previous) === unboundStructureJson(layout))
|
||||
) {
|
||||
if (shown.has(key) && !changes) {
|
||||
continue
|
||||
}
|
||||
changed.push(key)
|
||||
if (!shown.has(key)) {
|
||||
fullBuilt.push(key)
|
||||
} else if (changes?.rebuild) {
|
||||
workspaceBuilt.push(key)
|
||||
} else if (changes) {
|
||||
titleUpdates.push(
|
||||
...liveTitleUpdates(
|
||||
previous,
|
||||
changes.titled,
|
||||
layout,
|
||||
(tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId
|
||||
)
|
||||
)
|
||||
if (!sameStructure) {
|
||||
rebound.push(key)
|
||||
if (changes.rebound.size > 0) {
|
||||
rebound.set(key, changes.rebound)
|
||||
}
|
||||
} else {
|
||||
structural.push(key)
|
||||
}
|
||||
nextShown[key] = layout
|
||||
}
|
||||
pending.clear()
|
||||
// A workspace main stopped publishing keeps what the window shows; main has nothing to say.
|
||||
shown = nextShown
|
||||
if (structural.length > 0) {
|
||||
rebuild(state, structural)
|
||||
for (const key of shown) {
|
||||
if (!(key in cache)) {
|
||||
shown.delete(key)
|
||||
}
|
||||
}
|
||||
if (rebound.length > 0) {
|
||||
showFrameBindings(rebound)
|
||||
if (fullBuilt.length > 0) {
|
||||
show(fullLayoutFrameBuildPatch(state, cache, fullBuilt, holds))
|
||||
}
|
||||
if (rebound.size > 0) {
|
||||
workspaceBuilt.push(...showFrameBindings(rebound))
|
||||
}
|
||||
if (workspaceBuilt.length > 0) {
|
||||
show(workspaceLayoutFrameBuildPatch(store.getState(), cache, workspaceBuilt, holds))
|
||||
}
|
||||
if (titleUpdates.length > 0) {
|
||||
store.getState().updateTabTitles(titleUpdates)
|
||||
}
|
||||
// A closed stream's cache is frozen: the window draws its own answers then.
|
||||
if (streamOpen && paneTreeKeys.size > 0) {
|
||||
showPaneTrees(store.getState(), framesDrawWorkspace(store.getState()))
|
||||
for (const key of [...fullBuilt, ...workspaceBuilt]) {
|
||||
shown.add(key)
|
||||
// Against the store, not the frame: a held tab's tree may lag the pane trees drawn since.
|
||||
paneTreeKeys.add(key)
|
||||
}
|
||||
// A closed stream's cache is frozen: the window draws its own answers then.
|
||||
if (!streamOpen) {
|
||||
return
|
||||
}
|
||||
if (paneTreeKeys.size > 0) {
|
||||
showPaneTrees(store.getState())
|
||||
}
|
||||
layoutFrameApplyObserver()?.({
|
||||
before: state,
|
||||
after: store.getState(),
|
||||
cache,
|
||||
changed,
|
||||
fullBuilt,
|
||||
workspaceBuilt,
|
||||
rowFieldsTaken
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleFlush = (): void => {
|
||||
@@ -227,17 +245,25 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
}
|
||||
|
||||
// A released tab shows main's copy: its workspace builds from the cache again.
|
||||
const rebuildWorkspaces = (keys: Iterable<string>): void => {
|
||||
for (const key of keys) {
|
||||
if (shown.has(key)) {
|
||||
pendingWorkspaceChanges(pending, key).rebuild = true
|
||||
}
|
||||
}
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
beginLayoutFramePositions()
|
||||
const stream = subscribeRuntimeLayout(
|
||||
{ kind: 'local' },
|
||||
{},
|
||||
(frame) => {
|
||||
const before = cache
|
||||
cache = applyLayoutFrame(cache, frame)
|
||||
noteFrame(before, frame)
|
||||
if (frame.type === 'snapshot') {
|
||||
// A reopened stream's snapshot redraws every workspace: the window drew alone meanwhile.
|
||||
if (snapshotReceived) {
|
||||
shown = {}
|
||||
}
|
||||
snapshotReceived = true
|
||||
live = started
|
||||
}
|
||||
@@ -260,15 +286,10 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
// checks the store rather than assume the frame shows.
|
||||
(seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq))
|
||||
)
|
||||
// A released tab shows main's copy: its workspaces draw from the cache again.
|
||||
const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => {
|
||||
if (!streamOpen) {
|
||||
return
|
||||
if (streamOpen) {
|
||||
rebuildWorkspaces(workspaces)
|
||||
}
|
||||
for (const key of workspaces) {
|
||||
delete shown[key]
|
||||
}
|
||||
scheduleFlush()
|
||||
})
|
||||
// Against the store: the window may have drawn over main's tree for this tab since.
|
||||
const stopReleaseListener = onTabPaneTreeRedraw((tabId) => {
|
||||
@@ -276,19 +297,18 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
if (!streamOpen) {
|
||||
return
|
||||
}
|
||||
for (const [key, layout] of Object.entries(cache)) {
|
||||
if (layout.tabs.some((tab) => tab.entityId === tabId)) {
|
||||
paneTreeKeys.add(key)
|
||||
}
|
||||
}
|
||||
scheduleFlush()
|
||||
rebuildWorkspaces(
|
||||
Object.entries(cache).flatMap(([key, layout]) =>
|
||||
layout.tabs.some((tab) => tab.entityId === tabId) ? [key] : []
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// A workspace only main held (a CLI-started tab) shows on the window's first visit, so the
|
||||
// activation finds main's tab rather than seeding a second one.
|
||||
const stopHeldWorkspacesListener = onWorkspaceStartsDrawing(
|
||||
store,
|
||||
() => Object.keys(cache).filter((key) => !(key in shown)),
|
||||
() => Object.keys(cache).filter((key) => !shown.has(key)),
|
||||
() => streamOpen && scheduleFlush()
|
||||
)
|
||||
|
||||
@@ -298,7 +318,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
// Before its snapshot, the snapshot starts it; a stream that ends first leaves restore's.
|
||||
live = snapshotReceived
|
||||
// A re-run restore rebuilt the slices, so every workspace is shown again.
|
||||
shown = {}
|
||||
shown.clear()
|
||||
flush()
|
||||
},
|
||||
close: () => {
|
||||
@@ -320,7 +340,10 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
}
|
||||
|
||||
const HELD_DRAWN_TABS = { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld }
|
||||
const FRAME_BUILD_HOLDS: Omit<LayoutFrameBuildHolds, 'newRowFields'> = {
|
||||
keepsTerminalLayout: isTabPaneTreeHeld,
|
||||
held: { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld }
|
||||
}
|
||||
|
||||
let startupFrames: WorkspaceLayoutFrameSync | null = null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Tab properties in a layout-owner build: main's pin, color or rename, drawn from its frame, must
|
||||
// look as the window's own action does today, and must restart the same.
|
||||
|
||||
import { 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 { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
|
||||
@@ -24,6 +24,7 @@ import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import type { AppState } from '../store/types'
|
||||
import { layoutApplyTestTransport } from './layout-apply-test-transport.test-fixture'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
@@ -56,6 +57,15 @@ createStoreSessionMockApi()
|
||||
|
||||
type Store = ReturnType<typeof createTestStore>
|
||||
|
||||
let check: ReturnType<typeof installLayoutFrameApplyCheck>
|
||||
beforeEach(() => {
|
||||
check = installLayoutFrameApplyCheck()
|
||||
})
|
||||
afterEach(() => {
|
||||
check.uninstall()
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// This window's view and content facts for some workspaces, read from the store's records the way
|
||||
// the Loader reads them back from the window's save, so a workspace's frame slices build without
|
||||
// serializing the window's session (design phase E2a). The save's own writers trim what it keeps
|
||||
// (editor selection, groups, browser and scrollback state), so those run here too, over these
|
||||
// workspaces' records only.
|
||||
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import {
|
||||
BROWSER_TAB_LAYOUT_FIELDS,
|
||||
BROWSER_TAB_WORKSPACE_FIELDS,
|
||||
type DesktopLayoutView,
|
||||
type LayoutContentFacts
|
||||
} from '../../../shared/workspace-layout/workspace-layout-beside'
|
||||
import {
|
||||
childRecord,
|
||||
omitStoredFields,
|
||||
pickStoredFields
|
||||
} from '../../../shared/workspace-layout/stored-record-fields'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers'
|
||||
import {
|
||||
buildPersistedBrowserPagesByWorkspace,
|
||||
buildPersistedBrowserTabsByWorktree
|
||||
} from '@/lib/workspace-session-browser-tabs'
|
||||
import { buildEditorSessionData } from '@/lib/workspace-session'
|
||||
import { buildPersistedUnifiedTabSessionData } from '@/lib/workspace-session-unified-tabs'
|
||||
import type { AppState } from './types'
|
||||
|
||||
export type LayoutFrameStoreViewState = Pick<
|
||||
AppState,
|
||||
| 'activeRepoId'
|
||||
| 'activeWorktreeId'
|
||||
| 'activeTabId'
|
||||
| 'repos'
|
||||
| 'tabsByWorktree'
|
||||
| 'unifiedTabsByWorktree'
|
||||
| 'groupsByWorktree'
|
||||
| 'layoutByWorktree'
|
||||
| 'openFiles'
|
||||
| 'editorDrafts'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'localOnlyScrollbackByTabId'
|
||||
| 'browserTabsByWorktree'
|
||||
| 'browserPagesByWorkspace'
|
||||
| 'remoteBrowserPageHandlesByPageId'
|
||||
| 'activeTabIdByWorktree'
|
||||
| 'activeGroupIdByWorktree'
|
||||
| 'activeFileIdByWorktree'
|
||||
| 'activeBrowserTabIdByWorktree'
|
||||
| 'activeTabTypeByWorktree'
|
||||
| 'markdownFrontmatterVisible'
|
||||
>
|
||||
|
||||
function pickKeys<T>(map: Record<string, T>, keys: Iterable<string>): Record<string, T> {
|
||||
return Object.fromEntries([...keys].flatMap((key) => (key in map ? [[key, map[key]!]] : [])))
|
||||
}
|
||||
|
||||
function readPanes(
|
||||
tabId: string,
|
||||
layout: TerminalLayoutSnapshot,
|
||||
view: DesktopLayoutView,
|
||||
facts: LayoutContentFacts
|
||||
): void {
|
||||
view.panes[tabId] = { activeLeafId: layout.activeLeafId, expandedLeafId: layout.expandedLeafId }
|
||||
for (const leafId of new Set([
|
||||
...Object.keys(layout.buffersByLeafId ?? {}),
|
||||
...Object.keys(layout.scrollbackRefsByLeafId ?? {})
|
||||
])) {
|
||||
const buffer = layout.buffersByLeafId?.[leafId]
|
||||
const scrollbackRef = layout.scrollbackRefsByLeafId?.[leafId]
|
||||
facts.scrollback[leafId] = {
|
||||
...(buffer !== undefined ? { buffer } : {}),
|
||||
...(scrollbackRef !== undefined ? { scrollbackRef } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The view and facts the frame slices of `keys` need; other workspaces' are left out. */
|
||||
export function layoutViewAndFactsOfStore(
|
||||
state: LayoutFrameStoreViewState,
|
||||
keys: readonly string[]
|
||||
): { view: DesktopLayoutView; facts: LayoutContentFacts } {
|
||||
const keySet = new Set(keys)
|
||||
const editor = buildEditorSessionData(
|
||||
state.openFiles.filter((file) => keySet.has(file.worktreeId)),
|
||||
state.editorDrafts,
|
||||
state.markdownFrontmatterVisible,
|
||||
pickKeys(state.activeFileIdByWorktree, keys),
|
||||
pickKeys(state.activeTabTypeByWorktree, keys)
|
||||
)
|
||||
const unifiedTabsByWorktree = pickKeys(state.unifiedTabsByWorktree, keys)
|
||||
const groupsByWorktree = pickKeys(state.groupsByWorktree, keys)
|
||||
const layoutByWorktree = pickKeys(state.layoutByWorktree, keys)
|
||||
const activeGroupIdByWorktree = pickKeys(state.activeGroupIdByWorktree, keys)
|
||||
const tabBar = buildPersistedUnifiedTabSessionData({
|
||||
unifiedTabsByWorktree,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
})
|
||||
// The builders read only these workspaces' entries of the selection maps.
|
||||
const view: DesktopLayoutView = {
|
||||
activeRepoId: state.activeRepoId,
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
activeTabId: state.activeTabId,
|
||||
activeTabIdByWorktree: state.activeTabIdByWorktree,
|
||||
activeGroupIdByWorktree: tabBar.activeGroupIdByWorktree,
|
||||
activeFileIdByWorktree: editor.activeFileIdByWorktree,
|
||||
activeBrowserTabIdByWorktree: state.activeBrowserTabIdByWorktree,
|
||||
activeTabTypeByWorktree: editor.activeTabTypeByWorktree,
|
||||
markdownFrontmatterVisible: editor.markdownFrontmatterVisible,
|
||||
groups: {},
|
||||
lastFocusedAt: {},
|
||||
panes: {},
|
||||
editorDrafts: {}
|
||||
}
|
||||
const tabsByWorktree = pickKeys(state.tabsByWorktree, keys)
|
||||
const terminalTabIds = Object.values(tabsByWorktree).flatMap((rows) => rows.map((row) => row.id))
|
||||
const terminalLayoutsByTabId = pickKeys(state.terminalLayoutsByTabId, terminalTabIds)
|
||||
const localOnlyScrollbackByTabId = pickKeys(state.localOnlyScrollbackByTabId, terminalTabIds)
|
||||
// The save's scrollback trim reads only the rows and their pane trees.
|
||||
const terminals = pruneLocalTerminalScrollbackBuffers(
|
||||
{ ...view, tabsByWorktree, terminalLayoutsByTabId, localOnlyScrollbackByTabId },
|
||||
state.repos
|
||||
)
|
||||
const browserTabs = buildPersistedBrowserTabsByWorktree(
|
||||
pickKeys(state.browserTabsByWorktree, keys)
|
||||
)
|
||||
const browserTabIds = Object.values(browserTabs ?? {}).flatMap((tabs) =>
|
||||
tabs.map((tab) => tab.id)
|
||||
)
|
||||
const facts: LayoutContentFacts = {
|
||||
localOnlyScrollbackByTabId: terminals.localOnlyScrollbackByTabId,
|
||||
browserPagesByWorkspace: buildPersistedBrowserPagesByWorkspace(
|
||||
pickKeys(state.browserPagesByWorkspace, browserTabIds),
|
||||
state.remoteBrowserPageHandlesByPageId
|
||||
),
|
||||
tabLabels: {},
|
||||
terminalRows: {},
|
||||
scrollback: {},
|
||||
browserTabs: {}
|
||||
}
|
||||
for (const key of keys) {
|
||||
const entries = tabBar.unifiedTabs?.[key] ?? []
|
||||
const terminalEntries = new Map(
|
||||
entries.flatMap((entry) =>
|
||||
entry.contentType === 'terminal' ? [[entry.entityId, entry]] : []
|
||||
)
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (entry.contentType !== 'terminal') {
|
||||
childRecord(facts.tabLabels, key)[entry.id] = entry.label
|
||||
}
|
||||
if (entry.lastFocusedAt !== undefined) {
|
||||
childRecord(view.lastFocusedAt, key)[entry.id] = entry.lastFocusedAt
|
||||
}
|
||||
}
|
||||
// The Loader keeps a repeated group id's first selection.
|
||||
for (const group of tabBar.tabGroups?.[key] ?? []) {
|
||||
childRecord(view.groups, key)[group.id] ??= {
|
||||
activeTabId: group.activeTabId,
|
||||
...pickStoredFields(group, ['recentTabIds'])
|
||||
}
|
||||
}
|
||||
for (const row of tabsByWorktree[key] ?? []) {
|
||||
facts.terminalRows[row.id] = {
|
||||
title: terminalEntries.get(row.id)?.label ?? row.title,
|
||||
...pickStoredFields(row, ['generation'])
|
||||
}
|
||||
const layout = terminals.terminalLayoutsByTabId[row.id]
|
||||
if (layout) {
|
||||
readPanes(row.id, layout, view, facts)
|
||||
}
|
||||
}
|
||||
for (const tab of browserTabs?.[key] ?? []) {
|
||||
childRecord(facts.browserTabs, key)[tab.id] = omitStoredFields(tab, [
|
||||
...BROWSER_TAB_LAYOUT_FIELDS,
|
||||
...BROWSER_TAB_WORKSPACE_FIELDS
|
||||
])
|
||||
}
|
||||
}
|
||||
return { view, facts }
|
||||
}
|
||||
@@ -177,9 +177,10 @@ function installRestartHarness(
|
||||
pendingWrite: null,
|
||||
quitFlushStarted: false,
|
||||
writeGeneration: 0,
|
||||
lastDurableWriteGeneration: 0
|
||||
lastDurableWriteGeneration: 0,
|
||||
ptyBindingGenerations: new Map<string, number>()
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation, the per-pane binding generations and the session partitions from these fakes.
|
||||
const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters<
|
||||
typeof PtyBindingPersistenceOperations
|
||||
>
|
||||
@@ -264,7 +265,7 @@ describe('account restart through renderer connection and host spawn', () => {
|
||||
registerWithFakes(mainWindow, host.runtime, host.store)
|
||||
rendererState = createInitialStoreState(() => rendererState)
|
||||
rendererState.tabsByWorktree[worktreeId][0].ptyId = 'pty-old'
|
||||
rendererState.ptyIdsByTabId[tabId] = ['pty-old']
|
||||
rendererState.ptyIdsByTabId = { ...rendererState.ptyIdsByTabId, [tabId]: ['pty-old'] }
|
||||
rendererState.sleepingAgentSessionsByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
tabId,
|
||||
@@ -276,7 +277,12 @@ describe('account restart through renderer connection and host spawn', () => {
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
rendererState.terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'pty-old'
|
||||
const layouts = rendererState.terminalLayoutsByTabId ?? {}
|
||||
const layout = layouts[tabId]!
|
||||
rendererState.terminalLayoutsByTabId = {
|
||||
...layouts,
|
||||
[tabId]: { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: 'pty-old' } }
|
||||
}
|
||||
await installTerminalTestGlobals()
|
||||
installIpcPtyWindow(window, {})
|
||||
window.api.pty.claimViewport = vi.fn()
|
||||
@@ -310,7 +316,7 @@ describe('account restart through renderer connection and host spawn', () => {
|
||||
paneTransportsRef: { current: transports },
|
||||
clearTabPtyId: vi.fn(() => {
|
||||
rendererState.tabsByWorktree[worktreeId][0].ptyId = null
|
||||
rendererState.ptyIdsByTabId[tabId] = []
|
||||
rendererState.ptyIdsByTabId = { ...rendererState.ptyIdsByTabId, [tabId]: [] }
|
||||
})
|
||||
})
|
||||
rendererState.pendingPaneAccountRestarts = { 'pty-old': 'codex' }
|
||||
|
||||
Reference in New Issue
Block a user