mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Only the tabs the window just changed wait for main's answer; the window-wide layout hold goes (dark, layout-owner build only) (#30384)
* chore(layout): the drift check's ignore comment names every main-only kind (dark, layout-owner build only) * fix(layout): a quit stage retried after a failed one still sends the resume records (dark, layout-owner build only) * feat(layout): the window's local layout stream reopens after a loss, and its snapshot redraws every workspace (dark, layout-owner build only) * fix(layout): a command whose target main's copy lacks is a logged refusal, not the window's own change (dark, layout-owner build only) * feat(layout): frames keep each tab the window drew, closed or moved as drawn until main answers; the global frame hold goes (dark, layout-owner build only) A refused draw-first terminal create is closed (its terminal stopped) at the refusal. The activation create no longer draws main's tab itself while the stream is down: the reopened stream's snapshot does. * fix(layout): a tab move or drag-out the frame already drew is not placed again by the window (dark, layout-owner build only) Also holds the group sizes the window drew until main saves them. * test(layout): with frames flowing, a pin promotes a preview tab once and tab-change bookkeeping runs once (dark, layout-owner build only) Also states that the held-tab overlay builds frame slices only. * test(layout): read the rename's title without a cast * chore(layout): the drift check's header lists the same ignored records as its inline comment
This commit is contained in:
@@ -117,5 +117,6 @@
|
||||
5 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts
|
||||
7 src/renderer/src/store/workspace-layout-frame-held-tabs.ts
|
||||
4 src/renderer/src/store/workspace-layout-frame-slices.ts
|
||||
12 src/renderer/src/store/workspace-layout-frame-store-patch.ts
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// its own layout over a window save, so a save whose layout differs from main's model shows a
|
||||
// change main never got as a command. Report-only, and only when its log is set (evidence runs);
|
||||
// it never alters, fails or delays the write. Records main keeps through its own writes (pane
|
||||
// bindings, default-tab marks, closed-tab records, terminal row owners) are not compared: a save
|
||||
// that has not caught up with them asks main for nothing. Deleted with the old path (phase F).
|
||||
// bindings, resume records, default-tab marks, closed-tab records, terminal row owners,
|
||||
// client-hosted browser pages, the topology revision) are not compared: a save that has not
|
||||
// caught up with them asks main for nothing. Deleted with the old path (phase F).
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness'
|
||||
import { beginLayoutFramePositions, endLayoutFramePositions } from '@/runtime/layout-frame-position'
|
||||
import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers'
|
||||
import {
|
||||
dropTabThroughLayoutOwner,
|
||||
@@ -139,6 +140,21 @@ describe('dropTabThroughLayoutOwner', () => {
|
||||
expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right)
|
||||
})
|
||||
|
||||
it('leaves placing a lone tab’s group to main’s frame while frames flow', () => {
|
||||
const { left, right } = seeded()
|
||||
const layout = store().getState().layoutByWorktree[WT]
|
||||
beginLayoutFramePositions()
|
||||
try {
|
||||
dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'c', groupId: left, direction: 'left' })
|
||||
lastHandlers().apply({ groupId: right })
|
||||
} finally {
|
||||
endLayoutFramePositions()
|
||||
}
|
||||
|
||||
expect(store().getState().layoutByWorktree[WT]).toBe(layout)
|
||||
expect(store().getState().activeGroupIdByWorktree[WT]).toBe(right)
|
||||
})
|
||||
|
||||
it('leaves a drop the window would skip to today’s path, sending nothing', () => {
|
||||
const { left, right } = seeded()
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// A layout-owner build's tab moves and group sizes. A move: main places the tab first, then the
|
||||
// window shows main's answer. Main's frame usually shows the move before main answers; while this
|
||||
// window owes main a save the frame waits, so the window then makes the same move itself, with
|
||||
// main's new group id. Group sizes: drawn while dragging, main saves them when the drag ends.
|
||||
// window shows main's answer. Main's frame shows the move before main answers; only when no frame
|
||||
// is coming does the window make the same move itself, with main's new group id. Group sizes:
|
||||
// drawn while dragging, main saves them when the drag ends.
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import type { TabSplitDirection } from '@/store/slices/tabs'
|
||||
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
|
||||
import { layoutFramesFlowing } from '@/runtime/layout-frame-position'
|
||||
import type { WindowLayoutCommand } from '@/runtime/layout-apply-send'
|
||||
import { moveGroupLeafBeside } from '../../../../shared/workspace-layout/tab-group-layout-tree'
|
||||
import { stableJson } from '../../../../shared/workspace-layout/workspace-layout-load-report'
|
||||
@@ -134,7 +135,10 @@ function showMainMove(worktreeId: string, drop: TabDrop, mainGroupId: string | u
|
||||
moveAsToday(worktreeId, drop)
|
||||
return
|
||||
}
|
||||
placeGroupBeside(worktreeId, groupId, drop.groupId, drop.direction)
|
||||
// Main's frame placed the group; the window places it only when no frame is coming.
|
||||
if (!layoutFramesFlowing()) {
|
||||
placeGroupBeside(worktreeId, groupId, drop.groupId, drop.direction)
|
||||
}
|
||||
}
|
||||
state.activateTab(tabId, { worktreeId, preservePreview: true, recordFocus: false })
|
||||
useAppStore.getState().focusGroup(worktreeId, groupId)
|
||||
@@ -170,12 +174,11 @@ export function setGroupRatioThroughLayoutOwner(
|
||||
state.setTabGroupSplitRatio(worktreeId, nodePath, ratio)
|
||||
const groupLayout = useAppStore.getState().layoutByWorktree[worktreeId]
|
||||
if (groupLayout && groupLayout !== state.layoutByWorktree[worktreeId]) {
|
||||
// Why keep on every answer: the window's own save still writes these sizes in this phase.
|
||||
const keep = (): void => {}
|
||||
sendLayoutCommandToOwner(
|
||||
worktreeId,
|
||||
{ type: 'setGroupRatios', groupLayout },
|
||||
{ apply: keep, asToday: keep }
|
||||
{ apply: keep, asToday: keep, drawn: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ export function equalizeTerminalPanes(
|
||||
then(manager)
|
||||
}
|
||||
},
|
||||
asToday: equalize,
|
||||
unapplied: () => {}
|
||||
asToday: equalize
|
||||
}
|
||||
)
|
||||
) {
|
||||
|
||||
+6
-16
@@ -55,7 +55,6 @@ describe('layoutOwnerPaneGestures', () => {
|
||||
{
|
||||
apply: expect.any(Function),
|
||||
asToday: expect.any(Function),
|
||||
unapplied: expect.any(Function),
|
||||
refused: expect.any(Function)
|
||||
}
|
||||
)
|
||||
@@ -74,22 +73,13 @@ describe('layoutOwnerPaneGestures', () => {
|
||||
expect(move).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['refused', 'unapplied'] as const)(
|
||||
'keeps the window’s shape when main answers %s, as main’s tree did not change',
|
||||
(outcome) => {
|
||||
const move = vi.fn()
|
||||
gestures().takePaneDrop?.({ sourcePaneId: 2, targetPaneId: 1, zone: 'left' }, move)
|
||||
const handlers = mocks.send.mock.calls[0]?.[2]
|
||||
it('keeps the window’s shape when main refuses, as main’s tree did not change', () => {
|
||||
const move = vi.fn()
|
||||
gestures().takePaneDrop?.({ sourcePaneId: 2, targetPaneId: 1, zone: 'left' }, move)
|
||||
mocks.send.mock.calls[0]?.[2]?.refused?.('same_pane')
|
||||
|
||||
if (outcome === 'refused') {
|
||||
handlers?.refused?.('same_pane')
|
||||
} else {
|
||||
handlers?.unapplied?.()
|
||||
}
|
||||
|
||||
expect(move).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
expect(move).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds divider ratios until every move in flight has settled, even one that threw', () => {
|
||||
const view = gestures()
|
||||
|
||||
@@ -63,7 +63,6 @@ export function layoutOwnerPaneGestures(pane: {
|
||||
apply: () => settle(true),
|
||||
asToday: () => settle(true),
|
||||
// Main's tree did not change, and frames draw main's tree: the window keeps its shape.
|
||||
unapplied: () => settle(false),
|
||||
refused: () => settle(false)
|
||||
}
|
||||
)
|
||||
|
||||
+7
-15
@@ -308,22 +308,14 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['unapplied', 'refused'] as const)(
|
||||
'in a layout-owner build, forgets the pane when main answers %s',
|
||||
(outcome) => {
|
||||
splitInLayoutOwnerBuild(makeManager(vi.fn()))
|
||||
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
|
||||
it('in a layout-owner build, forgets the pane when main refuses', () => {
|
||||
splitInLayoutOwnerBuild(makeManager(vi.fn()))
|
||||
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
|
||||
handlers?.refused?.('pane_not_found')
|
||||
|
||||
if (outcome === 'refused') {
|
||||
handlers?.refused?.('pane_not_found')
|
||||
} else {
|
||||
handlers?.unapplied?.()
|
||||
}
|
||||
|
||||
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
|
||||
}
|
||||
)
|
||||
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
|
||||
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
|
||||
})
|
||||
|
||||
it('in a layout-owner build, splits here when main answers as today', () => {
|
||||
const splitPane = vi.fn(() => ({ id: 2 }))
|
||||
|
||||
@@ -65,7 +65,6 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void {
|
||||
forget()
|
||||
splitLocally(args, () => cwd)
|
||||
},
|
||||
unapplied: forget,
|
||||
refused: forget
|
||||
}
|
||||
)
|
||||
|
||||
+25
@@ -195,6 +195,31 @@ describe('a drag-out whose new tab main’s frame drew first', () => {
|
||||
expect(state.activeTabIdByWorktree[WT]).toBe(MOVED)
|
||||
})
|
||||
|
||||
it('leaves the tab where main’s frame placed it, asked for an index', async () => {
|
||||
const store = storeWithFrameDrawnMove()
|
||||
mocks.send.mockImplementation((_worktreeId, _command, handlers) => {
|
||||
handlers.apply({ tabId: MOVED, ptyId: 'pty-right' })
|
||||
return true
|
||||
})
|
||||
|
||||
await detachTerminalPaneToTab({
|
||||
getStore: () => store.getState(),
|
||||
manager: {
|
||||
getPanes: () => [{ id: 1 }, { id: 2 }],
|
||||
getLeafId: (paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1),
|
||||
detachPaneForExternalMove: () => true
|
||||
},
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE,
|
||||
targetGroupId: GROUP,
|
||||
targetIndex: 0,
|
||||
worktreeId: WT
|
||||
})
|
||||
|
||||
expect(store.getState().groupsByWorktree[WT]![0]!.tabOrder).toEqual([SOURCE, MOVED])
|
||||
})
|
||||
|
||||
it('opens a dragged-out chat pane’s tab in chat view, as a window-owned build does', async () => {
|
||||
const store = storeWithChatSplit()
|
||||
mocks.send.mockImplementation((_worktreeId, _command, handlers) => {
|
||||
|
||||
@@ -235,14 +235,17 @@ function applyMove(
|
||||
recordInteraction: true
|
||||
})
|
||||
const afterCreateStore = args.getStore()
|
||||
moveCreatedTabToIndex({
|
||||
groupId: args.targetGroupId,
|
||||
store: afterCreateStore,
|
||||
tabId: tab.id,
|
||||
targetIndex: args.targetIndex,
|
||||
worktreeId,
|
||||
mainPlaced: mainMoved
|
||||
})
|
||||
// Main's frame drew the tab where main placed it; the window places it only when it drew it.
|
||||
if (!frameDrawn) {
|
||||
moveCreatedTabToIndex({
|
||||
groupId: args.targetGroupId,
|
||||
store: afterCreateStore,
|
||||
tabId: tab.id,
|
||||
targetIndex: args.targetIndex,
|
||||
worktreeId,
|
||||
mainPlaced: mainMoved
|
||||
})
|
||||
}
|
||||
if (split) {
|
||||
afterCreateStore.setTabLayout(sourceTabId, split.sourceLayout)
|
||||
}
|
||||
|
||||
@@ -252,11 +252,8 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
|
||||
// Switch point: main decides whether the workspace gets its first tab.
|
||||
if (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN &&
|
||||
createInitialTerminalThroughLayoutOwner(useAppStore.getState, activeWorktreeId, (ids) =>
|
||||
createTab(activeWorktreeId, undefined, undefined, {
|
||||
pendingActivationSpawn: true,
|
||||
...(ids ? { id: ids.tabId, initialLeafId: ids.leafId } : {})
|
||||
})
|
||||
createInitialTerminalThroughLayoutOwner(useAppStore.getState, activeWorktreeId, () =>
|
||||
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
|
||||
)
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { createSessionWriteSubscriber } from './session-write-subscriber'
|
||||
import { isWindowSessionWriteBusy } from './window-session-write-activity'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
describe('session write activity in a layout-owner build', () => {
|
||||
let initial: AppState
|
||||
let dispose: (() => void) | undefined
|
||||
beforeEach(() => {
|
||||
initial = useAppStore.getState()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
dispose?.()
|
||||
dispose = undefined
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initial, true)
|
||||
})
|
||||
|
||||
it('is busy from a change until main acknowledges its write', async () => {
|
||||
let acknowledge!: () => void
|
||||
const persist = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
acknowledge = resolve
|
||||
})
|
||||
)
|
||||
dispose = createSessionWriteSubscriber({ store: useAppStore, persist })
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
// The first write after the gate opens.
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
acknowledge()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
|
||||
useAppStore.setState({ activeTabId: 'tab-2' })
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(persist).toHaveBeenCalledTimes(2)
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
acknowledge()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
})
|
||||
|
||||
it('is busy before the window may save, and after a failed write until it resends', async () => {
|
||||
const persist = vi.fn(() => Promise.reject(new Error('main unavailable')))
|
||||
dispose = createSessionWriteSubscriber({
|
||||
store: useAppStore,
|
||||
persist,
|
||||
onPersistError: () => {}
|
||||
})
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
})
|
||||
|
||||
it('is idle once disposed', () => {
|
||||
dispose = createSessionWriteSubscriber({ store: useAppStore, persist: vi.fn() })
|
||||
useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true })
|
||||
expect(isWindowSessionWriteBusy()).toBe(true)
|
||||
dispose()
|
||||
dispose = undefined
|
||||
expect(isWindowSessionWriteBusy()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,6 @@ import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-st
|
||||
import { SESSION_RELEVANT_FIELDS, shouldPersistWorkspaceSession } from './workspace-session'
|
||||
import { buildWorkspaceSessionPatch } from './workspace-session-patch'
|
||||
import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { reportWindowSessionWriteActivity } from './window-session-write-activity'
|
||||
|
||||
type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number]
|
||||
type TabsByWorktree = AppState['tabsByWorktree']
|
||||
@@ -126,21 +124,9 @@ export function createSessionWriteSubscriber({
|
||||
const pendingChangedFields = new Set<SessionRelevantField>()
|
||||
const terminalTabsProjection = createTerminalSessionTabsProjection()
|
||||
const unifiedTabsProjection = createUnifiedSessionTabsProjection()
|
||||
// Switch point (each report below, inline so a window-owned build keeps none of it): layout
|
||||
// frames wait while this window owes main a write, including before it may save at all.
|
||||
const owesWrite = (): boolean =>
|
||||
!disposed &&
|
||||
(timer !== null ||
|
||||
writing ||
|
||||
pendingChangedFields.size > 0 ||
|
||||
!shouldPersistWorkspaceSession(store.getState()))
|
||||
|
||||
const flushPendingWrite = (): void => {
|
||||
timer = null
|
||||
if (disposed || writing || pendingChangedFields.size === 0) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: rebuild from the freshest store state rather than the snapshot
|
||||
@@ -161,9 +147,6 @@ export function createSessionWriteSubscriber({
|
||||
pendingChangedFields.clear()
|
||||
const patch = buildWorkspaceSessionPatch(fresh, changed)
|
||||
if (Object.keys(patch).length === 0) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
writing = true
|
||||
@@ -181,10 +164,6 @@ export function createSessionWriteSubscriber({
|
||||
} else if (pendingChangedFields.size > 0) {
|
||||
armFlushTimer()
|
||||
}
|
||||
// A failed write stays owed, so structure frames wait for the next store wake's resend.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = persist({ patch })
|
||||
@@ -209,9 +188,6 @@ export function createSessionWriteSubscriber({
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(flushPendingWrite, debounceMs)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,9 +218,6 @@ export function createSessionWriteSubscriber({
|
||||
|
||||
const evaluateSessionState = (state: AppState): void => {
|
||||
if (!shouldPersistWorkspaceSession(state)) {
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: this fires on every store write and almost none of them touch a session field. Scan
|
||||
@@ -307,8 +280,5 @@ export function createSessionWriteSubscriber({
|
||||
clearTimeout(timer)
|
||||
}
|
||||
pendingChangedFields.clear()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
reportWindowSessionWriteActivity(owesWrite())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Whether this window owes main a session write: one is scheduled or not yet acknowledged. Fed by
|
||||
// the session write subscriber in a layout-owner build only; layout frames wait while it is busy,
|
||||
// so a frame never replaces a change main has not received yet.
|
||||
|
||||
let busy = false
|
||||
let idleListener: (() => void) | null = null
|
||||
|
||||
export function reportWindowSessionWriteActivity(nextBusy: boolean): void {
|
||||
if (busy === nextBusy) {
|
||||
return
|
||||
}
|
||||
busy = nextBusy
|
||||
if (!busy) {
|
||||
idleListener?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function isWindowSessionWriteBusy(): boolean {
|
||||
return busy
|
||||
}
|
||||
|
||||
/** The one listener: the window's layout frame sync. */
|
||||
export function onWindowSessionWriteIdle(listener: () => void): () => void {
|
||||
idleListener = listener
|
||||
return () => {
|
||||
if (idleListener === listener) {
|
||||
idleListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-
|
||||
import { provideLayoutFrameTerminalPanes } from '../runtime/layout-frame-terminal-panes'
|
||||
import { openWorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync'
|
||||
import type { WorkspaceLayoutFrameSync } from '../runtime/workspace-layout-frame-sync'
|
||||
import { reportWindowSessionWriteActivity } from './window-session-write-activity'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { adoptLiveWorkspacePtySurfaces } from './worktree-agent-live-surface-adoption'
|
||||
@@ -93,7 +92,6 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
let sync: WorkspaceLayoutFrameSync | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
reportWindowSessionWriteActivity(false)
|
||||
sync?.close()
|
||||
sync = null
|
||||
provideLayoutFrameTerminalPanes({
|
||||
@@ -102,15 +100,13 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('makes no tab or pane of its own while frames are held; the frame then draws the bound tab', async () => {
|
||||
it('makes no tab or pane of its own before frames draw; the frame then draws the bound tab', async () => {
|
||||
const { window: windowSession, main } = sessions()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
sync.start()
|
||||
// The window owes main a save, so main's tree waits.
|
||||
reportWindowSessionWriteActivity(true)
|
||||
// Main's snapshot is in, but the window has not started drawing from frames yet.
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
@@ -128,7 +124,7 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
expect(setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other'])
|
||||
|
||||
reportWindowSessionWriteActivity(false)
|
||||
sync.start()
|
||||
await flushMicrotasks()
|
||||
const layout = store.getState().terminalLayoutsByTabId['tab-split']
|
||||
expect(layout?.root).toMatchObject({
|
||||
@@ -157,8 +153,6 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
|
||||
|
||||
@@ -24,13 +24,17 @@ vi.mock('@/runtime/layout-frame-position', () => ({
|
||||
}))
|
||||
vi.mock('@/runtime/layout-owner-pane-intents', () => ({ holdTabPaneTree: () => () => {} }))
|
||||
|
||||
import { sendLayoutApply } from './layout-apply-send'
|
||||
import { recordLayoutApply, sendLayoutApply } from './layout-apply-send'
|
||||
import { isDrawnGroupLayoutHeld, isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state.
|
||||
const NO_STATE = {} as never
|
||||
|
||||
const RENAME = { type: 'renamePane', tabId: 'tab-1', leafId: 'leaf-1', title: 'Pane' } as const
|
||||
|
||||
async function send(reply: LayoutApplyReply | Error) {
|
||||
mocks.sent.reply = reply
|
||||
const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn(), unapplied: vi.fn() }
|
||||
const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state.
|
||||
sendLayoutApply({} as never, 'wt-1', RENAME, handlers)
|
||||
await vi.waitFor(() => {
|
||||
@@ -53,13 +57,17 @@ describe('sendLayoutApply', () => {
|
||||
it.each([
|
||||
{ reply: { ok: false, code: 'other_partition' } as const, code: 'other_partition' },
|
||||
{ reply: { ok: false, code: 'host_not_owned' } as const, code: 'host_not_owned' },
|
||||
{ reply: new Error('disk refused'), code: 'call_failed' }
|
||||
{ reply: new Error('disk refused'), code: 'call_failed' },
|
||||
// Main's copy lacking the target is a refusal too: a frame drew what the window shows.
|
||||
{ reply: { ok: false, code: 'tab_not_found' } as const, code: 'tab_not_found' },
|
||||
{ reply: { ok: false, code: 'pane_not_found' } as const, code: 'pane_not_found' },
|
||||
{ reply: { ok: false, code: 'workspace_not_found' } as const, code: 'workspace_not_found' },
|
||||
{ reply: { ok: false, code: 'group_not_found' } as const, code: 'group_not_found' }
|
||||
])('logs a $code refusal and draws nothing of its own', async ({ reply, code }) => {
|
||||
const handlers = await send(reply)
|
||||
|
||||
expect(handlers.refused).toHaveBeenCalledWith(code)
|
||||
expect(handlers.asToday).not.toHaveBeenCalled()
|
||||
expect(handlers.unapplied).not.toHaveBeenCalled()
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`[layout-owner] renamePane refused`),
|
||||
...(reply instanceof Error ? ['disk refused'] : [code])
|
||||
@@ -92,4 +100,47 @@ describe('sendLayoutApply', () => {
|
||||
const applied = await send({ ok: true, result: { tabId: 'tab-1' } })
|
||||
expect(applied.apply).toHaveBeenCalledWith({ tabId: 'tab-1' })
|
||||
})
|
||||
|
||||
it('holds the tabs a drawn change names, in each workspace, until its answer is in', async () => {
|
||||
mocks.sent.reply = { ok: true, result: {} }
|
||||
recordLayoutApply(NO_STATE, 'wt-1', {
|
||||
type: 'moveTabToWorkspace',
|
||||
toWorkspace: 'wt-2',
|
||||
tabs: [{ tabId: 'tab-1', newTabId: 'tab-1b', newEntityId: 'file-1b' }],
|
||||
groupId: 'group-2'
|
||||
})
|
||||
expect(isDrawnTabHeld('wt-1', 'tab-1')).toBe(true)
|
||||
expect(isDrawnTabHeld('wt-2', 'tab-1b')).toBe(true)
|
||||
expect(isDrawnTabHeld('wt-2', 'file-1b')).toBe(true)
|
||||
expect(isDrawnTabHeld('wt-2', 'tab-1')).toBe(false)
|
||||
await vi.waitFor(() => expect(isDrawnTabHeld('wt-1', 'tab-1')).toBe(false))
|
||||
expect(isDrawnTabHeld('wt-2', 'tab-1b')).toBe(false)
|
||||
})
|
||||
|
||||
it('holds group sizes the window set, and nothing for a change main draws first', async () => {
|
||||
mocks.sent.reply = { ok: true, result: {} }
|
||||
const keep = vi.fn()
|
||||
sendLayoutApply(
|
||||
NO_STATE,
|
||||
'wt-1',
|
||||
{ type: 'setGroupRatios', groupLayout: { type: 'leaf', groupId: 'g' } },
|
||||
{
|
||||
apply: keep,
|
||||
asToday: keep,
|
||||
drawn: true
|
||||
}
|
||||
)
|
||||
sendLayoutApply(
|
||||
NO_STATE,
|
||||
'wt-1',
|
||||
{ type: 'moveTab', tabId: 'tab-9', toGroupId: 'g', index: 0 },
|
||||
{
|
||||
apply: keep,
|
||||
asToday: keep
|
||||
}
|
||||
)
|
||||
expect(isDrawnGroupLayoutHeld('wt-1')).toBe(true)
|
||||
expect(isDrawnTabHeld('wt-1', 'tab-9')).toBe(false)
|
||||
await vi.waitFor(() => expect(isDrawnGroupLayoutHeld('wt-1')).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { afterLayoutFrame } from '@/runtime/layout-frame-position'
|
||||
import { holdTabPaneTree } from '@/runtime/layout-owner-pane-intents'
|
||||
import { holdDrawnTabs, type DrawnTabHold } from '@/runtime/layout-owner-drawn-tab-holds'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import type { TabGroupLayoutNode } from '../../../shared/tab-types'
|
||||
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
|
||||
@@ -13,6 +14,7 @@ import type { PaneSide } from '../../../shared/workspace-layout/terminal-pane-tr
|
||||
import type { LayoutEditorFile } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import type { EditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type'
|
||||
import type {
|
||||
CommandOf,
|
||||
EditorFileRename,
|
||||
GroupSide,
|
||||
LayoutApplyReply,
|
||||
@@ -132,6 +134,7 @@ export type WindowLayoutCommand =
|
||||
/** Drops the panes' resume records without starting them. */
|
||||
| { type: 'wake'; tabId: string; paneKeys: string[]; recordOnly: true }
|
||||
| { type: 'setDefaultTabsApplied' }
|
||||
| (Omit<CommandOf<'createTerminalTab'>, 'workspace'> & { tabId: string; leafId: string })
|
||||
|
||||
/** `call_failed`: the call threw, and a thrown write is rolled back. */
|
||||
type WindowRefusalCode = Extract<LayoutApplyReply, { ok: false }>['code'] | 'call_failed'
|
||||
@@ -140,10 +143,8 @@ export type LayoutApplyHandlers = {
|
||||
apply: (result: LayoutCommandResult) => void
|
||||
asToday: () => void
|
||||
refused?: (code: WindowRefusalCode) => void
|
||||
/** Main's copy lacks the target; `asToday` when absent. A pane split or move
|
||||
* passes one: main keeps its pane trees over the window's saves, so that change would not last.
|
||||
* A close keeps `asToday`: a pane main lacks has nothing to keep it. */
|
||||
unapplied?: () => void
|
||||
/** The window already drew the change: frames keep its tabs as drawn until the answer is in. */
|
||||
drawn?: true
|
||||
}
|
||||
|
||||
/** Commands that change one tab's panes: frames wait to redraw that tab until the answer is in. */
|
||||
@@ -158,13 +159,70 @@ const PANE_COMMANDS: ReadonlySet<WindowLayoutCommand['type']> = new Set([
|
||||
'movePaneToNewTab'
|
||||
])
|
||||
|
||||
/** The tabs, by workspace, that a command the window already drew changes. */
|
||||
function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnTabHold[] {
|
||||
switch (command.type) {
|
||||
case 'closeTabs':
|
||||
return [{ workspace: worktreeId, ids: command.tabIds }]
|
||||
case 'openBrowserTab':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId, command.browserTabId] }]
|
||||
case 'openEditorTab':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId, command.fileId] }]
|
||||
case 'splitGroup':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId], groupLayout: true }]
|
||||
case 'moveTab':
|
||||
case 'renameTab':
|
||||
case 'setTabProps':
|
||||
case 'promotePreviewTab':
|
||||
case 'openAgentSessionTab':
|
||||
case 'openSimulatorTab':
|
||||
case 'createTerminalTab':
|
||||
return [{ workspace: worktreeId, ids: [command.tabId] }]
|
||||
case 'openBrowserPage':
|
||||
case 'closeBrowserPage':
|
||||
case 'replaceBrowserPage':
|
||||
case 'setBrowserTabProfile':
|
||||
return [{ workspace: worktreeId, ids: [command.browserTabId] }]
|
||||
case 'renameEditorFiles':
|
||||
return [
|
||||
{
|
||||
workspace: worktreeId,
|
||||
ids: command.renames.flatMap((rename) => [rename.fileId, rename.newFileId])
|
||||
}
|
||||
]
|
||||
case 'moveTabToWorkspace':
|
||||
return [
|
||||
{ workspace: worktreeId, ids: command.tabs.map((tab) => tab.tabId) },
|
||||
{
|
||||
workspace: command.toWorkspace,
|
||||
ids: command.tabs.flatMap((tab) => [tab.newTabId, tab.newEntityId])
|
||||
}
|
||||
]
|
||||
case 'setGroupRatios':
|
||||
return [{ workspace: worktreeId, ids: [], groupLayout: true }]
|
||||
// Pane commands hold their tab's pane tree; records are not drawn from frames.
|
||||
case 'splitPane':
|
||||
case 'closePane':
|
||||
case 'renamePane':
|
||||
case 'equalizePanes':
|
||||
case 'movePane':
|
||||
case 'setPaneRatios':
|
||||
case 'setChatPane':
|
||||
case 'movePaneToNewTab':
|
||||
case 'sleep':
|
||||
case 'wake':
|
||||
case 'setDefaultTabsApplied':
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout command in a layout-owner build: main applies it first, then, once the window has the
|
||||
* frame with main's change, `apply` shows main's result. `asToday` runs instead only when a paired
|
||||
* server may hold the tab (#18202), or main's copy lacks the workspace, tab, pane or group. Any
|
||||
* other refusal, and a call that threw, is logged and `refused`: main's frame stays what is drawn,
|
||||
* since a window save no longer carries the change. False when a paired server owns the
|
||||
* workspace's layout; the caller acts as today.
|
||||
* server may hold the tab (#18202). Any other refusal (main's copy lacking the target too), and a
|
||||
* call that threw, is logged and `refused`: main's frame stays what is drawn, since a window save
|
||||
* no longer carries the change. False when a paired server owns the workspace's layout; the
|
||||
* caller acts as today.
|
||||
*/
|
||||
export function sendLayoutApply(
|
||||
state: Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
|
||||
@@ -180,12 +238,17 @@ export function sendLayoutApply(
|
||||
}
|
||||
const hold =
|
||||
PANE_COMMANDS.has(command.type) && 'tabId' in command ? holdTabPaneTree(command.tabId) : null
|
||||
const tabHolds = handlers.drawn ? drawnTabHolds(worktreeId, command) : []
|
||||
const tabHold = tabHolds.length > 0 ? holdDrawnTabs(tabHolds) : null
|
||||
// Why a task later: a handler that resolves a promise (drag-out) acts in its awaiting caller,
|
||||
// microtasks after the handler returns.
|
||||
const release = (): void => {
|
||||
if (hold) {
|
||||
setTimeout(hold, 0)
|
||||
}
|
||||
if (tabHold) {
|
||||
setTimeout(tabHold, 0)
|
||||
}
|
||||
}
|
||||
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
|
||||
command: { ...command, workspace: worktreeId }
|
||||
@@ -228,10 +291,6 @@ function handleReply(
|
||||
handlers.apply(reply.result)
|
||||
} else if (reply.code === 'host_not_owned' && mayBePairedServers(state, worktreeId)) {
|
||||
handlers.asToday()
|
||||
} else if (mainLacksTarget(reply.code)) {
|
||||
// Why: main's copy can lag the window's: a tab the window drew before main's create answered.
|
||||
console.warn(`[layout-owner] ${command.type} found no target:`, reply.code)
|
||||
unapplied(handlers)
|
||||
} else {
|
||||
console.warn(`[layout-owner] ${command.type} refused:`, reply.code)
|
||||
handlers.refused?.(reply.code)
|
||||
@@ -248,32 +307,19 @@ function mayBePairedServers(
|
||||
)
|
||||
}
|
||||
|
||||
function unapplied(handlers: LayoutApplyHandlers): void {
|
||||
if (handlers.unapplied) {
|
||||
handlers.unapplied()
|
||||
} else {
|
||||
handlers.asToday()
|
||||
}
|
||||
}
|
||||
|
||||
const keepWindowCopy = (): void => {}
|
||||
|
||||
/** Records a change the window already made; any answer leaves the window's copy as it is. */
|
||||
/** Records a change the window already made: drawn as the window made it until main answers. */
|
||||
export function recordLayoutApply(
|
||||
state: Parameters<typeof sendLayoutApply>[0],
|
||||
worktreeId: string,
|
||||
command: WindowLayoutCommand
|
||||
): void {
|
||||
sendLayoutApply(state, worktreeId, command, { apply: keepWindowCopy, asToday: keepWindowCopy })
|
||||
}
|
||||
|
||||
function mainLacksTarget(code: WindowRefusalCode): boolean {
|
||||
return (
|
||||
code === 'workspace_not_found' ||
|
||||
code === 'tab_not_found' ||
|
||||
code === 'pane_not_found' ||
|
||||
code === 'group_not_found'
|
||||
)
|
||||
sendLayoutApply(state, worktreeId, command, {
|
||||
apply: keepWindowCopy,
|
||||
asToday: keepWindowCopy,
|
||||
drawn: true
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): unknown {
|
||||
|
||||
@@ -105,36 +105,28 @@ describe('sendLayoutCommandToOwner', () => {
|
||||
'main lacks the workspace',
|
||||
() => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' })
|
||||
]
|
||||
])('acts as today when %s', async (_case, arrange) => {
|
||||
])('refuses, logging it and drawing nothing, when %s', async (_case, arrange) => {
|
||||
arrange()
|
||||
const on = handlers()
|
||||
|
||||
sendLayoutCommandToOwner('worktree-1', SPLIT, on)
|
||||
|
||||
await vi.waitFor(() => expect(on.asToday).toHaveBeenCalledOnce())
|
||||
expect(on.refused).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledOnce())
|
||||
expect(on.asToday).not.toHaveBeenCalled()
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[layout-owner] splitPane refused:',
|
||||
expect.stringMatching(/_not_found$/)
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses, drawing nothing, when the call fails', async () => {
|
||||
mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))
|
||||
const on = { ...handlers(), unapplied: vi.fn() }
|
||||
const on = handlers()
|
||||
|
||||
sendLayoutCommandToOwner('worktree-1', SPLIT, on)
|
||||
|
||||
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('call_failed'))
|
||||
expect(on.asToday).not.toHaveBeenCalled()
|
||||
expect(on.unapplied).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws nothing for a pane command passing `unapplied` when main lacks the pane', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' })
|
||||
const on = { ...handlers(), unapplied: vi.fn() }
|
||||
|
||||
sendLayoutCommandToOwner('worktree-1', SPLIT, on)
|
||||
|
||||
await vi.waitFor(() => expect(on.unapplied).toHaveBeenCalledOnce())
|
||||
expect(on.asToday).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(isTabPaneTreeHeld('tab-1')).toBe(false))
|
||||
})
|
||||
|
||||
it('keeps the tab’s drawn tree from frames until its answer, and what awaits it, has run', async () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Tabs this window drew, closed or moved before main answered, in a layout-owner build: frames
|
||||
// keep each as the window shows it until main's answer is in, then redraw its workspaces, so a
|
||||
// frame sent before main had the change neither drops a just-drawn tab nor brings back a closed one.
|
||||
|
||||
/** A workspace's held tabs, by tab-bar or entity id; `groupLayout`: the group sizes the window set. */
|
||||
export type DrawnTabHold = { workspace: string; ids: readonly string[]; groupLayout?: true }
|
||||
|
||||
const held = new Map<string, Map<string, number>>()
|
||||
const redrawListeners = new Set<(workspaces: readonly string[]) => void>()
|
||||
|
||||
/** The tree key of a workspace's group sizes, never a tab id. */
|
||||
const GROUP_LAYOUT = '\0group-layout'
|
||||
|
||||
function count(workspace: string, id: string, by: 1 | -1): void {
|
||||
const ids = held.get(workspace) ?? new Map<string, number>()
|
||||
const next = (ids.get(id) ?? 0) + by
|
||||
if (next > 0) {
|
||||
ids.set(id, next)
|
||||
} else {
|
||||
ids.delete(id)
|
||||
}
|
||||
if (ids.size > 0) {
|
||||
held.set(workspace, ids)
|
||||
} else {
|
||||
held.delete(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function keysOf(hold: DrawnTabHold): string[] {
|
||||
return hold.groupLayout ? [...hold.ids, GROUP_LAYOUT] : [...hold.ids]
|
||||
}
|
||||
|
||||
/** Keeps these as drawn until the release, which redraws their workspaces; a second release is a no-op. */
|
||||
export function holdDrawnTabs(holds: readonly DrawnTabHold[]): () => void {
|
||||
for (const hold of holds) {
|
||||
for (const id of keysOf(hold)) {
|
||||
count(hold.workspace, id, 1)
|
||||
}
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
for (const hold of holds) {
|
||||
for (const id of keysOf(hold)) {
|
||||
count(hold.workspace, id, -1)
|
||||
}
|
||||
}
|
||||
const workspaces = [...new Set(holds.map((hold) => hold.workspace))]
|
||||
for (const listener of redrawListeners) {
|
||||
listener(workspaces)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isDrawnTabHeld(workspace: string, id: string): boolean {
|
||||
return held.get(workspace)?.has(id) ?? false
|
||||
}
|
||||
|
||||
export function isDrawnGroupLayoutHeld(workspace: string): boolean {
|
||||
return isDrawnTabHeld(workspace, GROUP_LAYOUT)
|
||||
}
|
||||
|
||||
export function holdsDrawnTabs(workspace: string): boolean {
|
||||
return held.has(workspace)
|
||||
}
|
||||
|
||||
export function onDrawnTabsRedraw(listener: (workspaces: readonly string[]) => void): () => void {
|
||||
redrawListeners.add(listener)
|
||||
return () => redrawListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function _resetDrawnTabHoldsForTests(): void {
|
||||
held.clear()
|
||||
redrawListeners.clear()
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { buildWorkspaceSessionPayload } from '../lib/workspace-session'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import { recordOpenedEditorTabInLayoutOwner } from '../store/slices/editor/tabs/editor-tab-layout-owner'
|
||||
import { recordOpenedAgentSessionTabInLayoutOwner } from './agent-session-tab-layout-owner'
|
||||
import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner'
|
||||
import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props'
|
||||
import { endLayoutFramePositions } from './layout-frame-position'
|
||||
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
|
||||
|
||||
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() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
type Call = {
|
||||
command: { type: string; tabId?: string; title?: string }
|
||||
answer: (r: LayoutApplyReply) => void
|
||||
fail: (error: Error) => void
|
||||
}
|
||||
const effects = vi.hoisted(() => ({
|
||||
graphSync: vi.fn(),
|
||||
mirrorViewMode: vi.fn(),
|
||||
chatToggled: vi.fn()
|
||||
}))
|
||||
vi.mock('@/runtime/sync-runtime-graph', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
scheduleRuntimeGraphSync: effects.graphSync
|
||||
}))
|
||||
vi.mock('@/store/slices/tabs/tabs-host-mirroring', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
mirrorTabViewModeToHost: effects.mirrorViewMode
|
||||
}))
|
||||
vi.mock('@/lib/native-chat-telemetry', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
emitNativeChatToggled: effects.chatToggled
|
||||
}))
|
||||
|
||||
const rpc = vi.hoisted((): { calls: Call[] } => ({ calls: [] }))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
callRuntimeRpc: (_target: unknown, _method: string, params: { command: { type: string } }) =>
|
||||
new Promise<LayoutApplyReply>((answer, fail) =>
|
||||
rpc.calls.push({ command: params.command, answer, fail })
|
||||
)
|
||||
}))
|
||||
|
||||
type StreamDouble = {
|
||||
onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null
|
||||
onPosition: ((seq: number) => void) | null
|
||||
}
|
||||
const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onPosition: null }))
|
||||
vi.mock('./runtime-layout-client', () => ({
|
||||
subscribeRuntimeLayout: (
|
||||
_target: unknown,
|
||||
_params: unknown,
|
||||
onFrame: (frame: WorkspaceLayoutChangeFrame) => void,
|
||||
_onStatus: unknown,
|
||||
onPosition: (seq: number) => void
|
||||
) => {
|
||||
stream.onFrame = onFrame
|
||||
stream.onPosition = onPosition
|
||||
return { close: () => {} }
|
||||
}
|
||||
}))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout {
|
||||
const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext())
|
||||
return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local')
|
||||
}
|
||||
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
let seq = 0
|
||||
/** 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.onPosition!(seq)
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const store = createTestStore()
|
||||
const session = localDesktopSession()
|
||||
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 })
|
||||
stream.onPosition!(seq)
|
||||
sync.start()
|
||||
await settle()
|
||||
/** Main's answer to the window's command, with the frame holding the window's change. */
|
||||
const answer = async (
|
||||
index: number,
|
||||
reply: LayoutApplyReply = okReply,
|
||||
/** Main's change the window has not drawn: a change main applies first. */
|
||||
mainChange: (layout: PublishedWorkspaceLayout) => void = () => {}
|
||||
): Promise<void> => {
|
||||
const frame = publishedOf(buildWorkspaceSessionPayload(store.getState()))
|
||||
mainChange(frame)
|
||||
seq += 1
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: frame, seq })
|
||||
stream.onPosition!(seq)
|
||||
rpc.calls[index]!.answer({ ...reply, frameSeq: seq })
|
||||
await settle()
|
||||
await settle()
|
||||
}
|
||||
return { store, sync, before, answer }
|
||||
}
|
||||
|
||||
const okReply: LayoutApplyReply = { ok: true, result: {} }
|
||||
|
||||
const tabBar = (store: ReturnType<typeof createTestStore>) =>
|
||||
store.getState().unifiedTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)
|
||||
const groupOrder = (store: ReturnType<typeof createTestStore>, groupId: string) =>
|
||||
store.getState().groupsByWorktree[GIT_KEY]!.find((group) => group.id === groupId)?.tabOrder
|
||||
|
||||
/** Draws a tab as the window's open does, before its recorder tells main. */
|
||||
function drawTab(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
tab: Partial<Tab> & { id: string }
|
||||
): Tab {
|
||||
const drawn: Tab = {
|
||||
entityId: tab.id,
|
||||
groupId: 'group-b',
|
||||
worktreeId: GIT_KEY,
|
||||
contentType: 'simulator',
|
||||
label: tab.id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 9,
|
||||
createdAt: 1_700_000_900_000,
|
||||
...tab
|
||||
}
|
||||
const state = store.getState()
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
...state.unifiedTabsByWorktree,
|
||||
[GIT_KEY]: [...state.unifiedTabsByWorktree[GIT_KEY]!, drawn]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
...state.groupsByWorktree,
|
||||
[GIT_KEY]: state.groupsByWorktree[GIT_KEY]!.map((group) =>
|
||||
group.id === drawn.groupId ? { ...group, tabOrder: [...group.tabOrder, drawn.id] } : group
|
||||
)
|
||||
}
|
||||
})
|
||||
return drawn
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rpc.calls = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
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 () => {
|
||||
const { store, sync, before, answer } = await boot()
|
||||
const tab = store.getState().createTab(GIT_KEY)
|
||||
expect(rpc.calls[0]!.command).toMatchObject({ type: 'createTerminalTab', tabId: tab.id })
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(tab.id)
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).toContain(tab.id)
|
||||
expect(store.getState().terminalLayoutsByTabId[tab.id]).toBeDefined()
|
||||
|
||||
await answer(0)
|
||||
expect(tabBar(store)).toContain(tab.id)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a just-closed terminal tab closed', async () => {
|
||||
const { store, sync, before, answer } = await boot()
|
||||
store.getState().closeTab('tab-unbound')
|
||||
const close = rpc.calls.findIndex((call) => call.command.type === 'closeTabs')
|
||||
expect(close).toBeGreaterThanOrEqual(0)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).not.toContain('tab-unbound')
|
||||
expect(groupOrder(store, 'group-b')).not.toContain('tab-unbound')
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).not.toContain(
|
||||
'tab-unbound'
|
||||
)
|
||||
|
||||
await answer(close)
|
||||
expect(tabBar(store)).not.toContain('tab-unbound')
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'editor',
|
||||
(store: ReturnType<typeof createTestStore>) => {
|
||||
const file = {
|
||||
...store.getState().openFiles[0]!,
|
||||
id: '/Users/dev/orca/src/new.ts',
|
||||
filePath: '/Users/dev/orca/src/new.ts',
|
||||
relativePath: 'src/new.ts'
|
||||
}
|
||||
store.setState({ openFiles: [...store.getState().openFiles, file] })
|
||||
const tab = drawTab(store, {
|
||||
id: 'editor:new',
|
||||
entityId: file.id,
|
||||
contentType: 'editor'
|
||||
})
|
||||
recordOpenedEditorTabInLayoutOwner(store.getState(), tab)
|
||||
return tab
|
||||
}
|
||||
],
|
||||
[
|
||||
'chat',
|
||||
(store: ReturnType<typeof createTestStore>) => {
|
||||
const tab = drawTab(store, {
|
||||
id: 'agent-session-new',
|
||||
contentType: 'agent-session',
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab)
|
||||
return tab
|
||||
}
|
||||
],
|
||||
[
|
||||
'simulator',
|
||||
(store: ReturnType<typeof createTestStore>) => {
|
||||
const tab = drawTab(store, { id: 'simulator-new' })
|
||||
recordOpenedSimulatorTabInLayoutOwner(store.getState(), tab)
|
||||
return tab
|
||||
}
|
||||
]
|
||||
])('keeps a just-opened %s tab', async (_kind, open) => {
|
||||
const { store, sync, before } = await boot()
|
||||
const tab = open(store)
|
||||
expect(rpc.calls.length).toBeGreaterThan(0)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(tab.id)
|
||||
expect(groupOrder(store, 'group-b')).toContain(tab.id)
|
||||
if (tab.contentType === 'editor') {
|
||||
expect(store.getState().openFiles.map((file) => file.id)).toContain(tab.entityId)
|
||||
}
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a just-opened browser tab and its page', async () => {
|
||||
const { store, sync, before } = await boot()
|
||||
const created = store.getState().createBrowserTab(GIT_KEY, 'https://example.com/', {
|
||||
title: 'Example'
|
||||
})
|
||||
const entry = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[GIT_KEY]!.find((tab) => tab.entityId === created.id)!
|
||||
expect(rpc.calls.some((call) => call.command.type === 'openBrowserTab')).toBe(true)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(entry.id)
|
||||
expect(store.getState().browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain(
|
||||
created.id
|
||||
)
|
||||
expect(store.getState().browserPagesByWorkspace[created.id]?.length).toBeGreaterThan(0)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('keeps a reorder the window made, and main’s order for the rest', async () => {
|
||||
const { store, sync, before, answer } = await boot()
|
||||
const order = groupOrder(store, 'group-a')!
|
||||
const reordered = order.toReversed()
|
||||
store.getState().reorderUnifiedTabs('group-a', reordered)
|
||||
const moves = rpc.calls.filter((call) => call.command.type === 'moveTab')
|
||||
expect(moves.length).toBeGreaterThan(0)
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(groupOrder(store, 'group-a')).toEqual(reordered)
|
||||
|
||||
for (let index = 0; index < rpc.calls.length; index += 1) {
|
||||
await answer(index)
|
||||
}
|
||||
expect(groupOrder(store, 'group-a')).toEqual(reordered)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['refuses it', (call: Call) => call.answer({ ok: false, code: 'wsl_move_in_progress' })],
|
||||
['throws', (call: Call) => call.fail(new Error('layout_store_unavailable'))]
|
||||
])('removes a just-created terminal tab, stopping it, when main %s', async (_case, refuse) => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { store, sync, before } = await boot()
|
||||
const tab = store.getState().createTab(GIT_KEY)
|
||||
const closeTab = vi.spyOn(store.getState(), 'closeTab')
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(tabBar(store)).toContain(tab.id)
|
||||
|
||||
refuse(rpc.calls[0]!)
|
||||
await settle()
|
||||
await settle()
|
||||
expect(closeTab).toHaveBeenCalledWith(tab.id, { reason: 'cleanup' })
|
||||
expect(tabBar(store)).not.toContain(tab.id)
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((row) => row.id)).not.toContain(tab.id)
|
||||
expect(store.getState().terminalLayoutsByTabId[tab.id]).toBeUndefined()
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a change main applies first, with frames flowing', () => {
|
||||
const callsOf = (type: string) => rpc.calls.filter((call) => call.command.type === type)
|
||||
|
||||
it('pins a preview tab and promotes it once; a frame from before does not undo it', async () => {
|
||||
const { store, sync, before, answer } = await boot()
|
||||
const file = {
|
||||
...store.getState().openFiles[0]!,
|
||||
id: '/Users/dev/orca/src/peek.ts',
|
||||
filePath: '/Users/dev/orca/src/peek.ts',
|
||||
relativePath: 'src/peek.ts',
|
||||
isPreview: true
|
||||
}
|
||||
store.setState({ openFiles: [...store.getState().openFiles, file] })
|
||||
const tab = drawTab(store, {
|
||||
id: 'editor:peek',
|
||||
entityId: file.id,
|
||||
contentType: 'editor',
|
||||
isPreview: true
|
||||
})
|
||||
recordOpenedEditorTabInLayoutOwner(store.getState(), tab)
|
||||
await answer(0)
|
||||
const entry = () =>
|
||||
store.getState().unifiedTabsByWorktree[GIT_KEY]!.find((item) => item.id === tab.id)!
|
||||
|
||||
withLayoutOwnerTabProps(
|
||||
{ togglePinned: (item) => store.getState().pinFile(file.id, item.unifiedTabId) },
|
||||
() => GIT_KEY
|
||||
).togglePinned({ unifiedTabId: tab.id, isPinned: false })
|
||||
const pin = rpc.calls.length - 1
|
||||
expect(rpc.calls[pin]!.command).toMatchObject({ type: 'setTabProps', isPinned: true })
|
||||
await answer(pin, okReply, (layout) => {
|
||||
layout.tabs.find((item) => item.id === tab.id)!.isPinned = true
|
||||
})
|
||||
expect(callsOf('promotePreviewTab')).toHaveLength(1)
|
||||
expect(entry()).toMatchObject({ isPinned: true })
|
||||
expect(entry().isPreview).toBeFalsy()
|
||||
|
||||
sendStaleFrame(before)
|
||||
await settle()
|
||||
expect(entry().isPreview).toBeFalsy()
|
||||
await answer(rpc.calls.indexOf(callsOf('promotePreviewTab')[0]!))
|
||||
expect(entry()).toMatchObject({ isPinned: true })
|
||||
expect(entry().isPreview).toBeFalsy()
|
||||
expect(callsOf('promotePreviewTab')).toHaveLength(1)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('runs a view toggle’s bookkeeping once, after main’s frame shows the toggle', async () => {
|
||||
const { store, sync, answer } = await boot()
|
||||
store.getState().toggleTabViewMode('tab-agent')
|
||||
const toggle = rpc.calls.length - 1
|
||||
expect(rpc.calls[toggle]!.command).toMatchObject({ type: 'setTabProps', viewMode: 'terminal' })
|
||||
|
||||
await answer(toggle, okReply, (layout) => {
|
||||
layout.tabs.find((item) => item.id === 'tab-agent')!.viewMode = 'terminal'
|
||||
})
|
||||
const agent = () =>
|
||||
store.getState().unifiedTabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-agent')!
|
||||
expect(agent().viewMode).toBe('terminal')
|
||||
expect(effects.mirrorViewMode).toHaveBeenCalledTimes(1)
|
||||
expect(effects.chatToggled).toHaveBeenCalledTimes(1)
|
||||
expect(effects.chatToggled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: 'chat', to: 'terminal' })
|
||||
)
|
||||
|
||||
// The answered send is settled: the next toggle flips from what is shown, back to chat.
|
||||
store.getState().toggleTabViewMode('tab-agent')
|
||||
expect(rpc.calls.at(-1)!.command).toMatchObject({ type: 'setTabProps', viewMode: 'chat' })
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('settles a generated title once main’s frame shows it, so the next one is sent', async () => {
|
||||
const { store, sync, answer } = await boot()
|
||||
store.setState({ settings: { ...store.getState().settings!, tabAutoGenerateTitle: true } })
|
||||
const leafId = store.getState().terminalLayoutsByTabId['tab-unbound']!.activeLeafId!
|
||||
const paneKey = makePaneKey('tab-unbound', leafId)
|
||||
store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Refactor the auth middleware')
|
||||
const renames = () => callsOf('renameTab')
|
||||
expect(renames()).toHaveLength(1)
|
||||
const title = renames()[0]!.command.title!
|
||||
// Unanswered: a later prompt for the same tab waits for the first.
|
||||
store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Fix the flaky login test')
|
||||
expect(renames()).toHaveLength(1)
|
||||
|
||||
await answer(rpc.calls.indexOf(renames()[0]!), okReply, (layout) => {
|
||||
layout.tabs.find((item) => item.id === 'tab-unbound')!.generatedTitle = title
|
||||
})
|
||||
const row = store.getState().tabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-unbound')!
|
||||
expect(row.generatedTitle).toBe(title)
|
||||
store.getState().setGeneratedTabTitleFromAgentPrompt(paneKey, 'Fix the flaky login test', {
|
||||
replaceExistingGeneratedTitle: true
|
||||
})
|
||||
expect(renames()).toHaveLength(2)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('runs a rename’s graph sync once and shows main’s title', async () => {
|
||||
const { store, sync, answer } = await boot()
|
||||
store.getState().setTabCustomTitle('tab-unbound', 'Build')
|
||||
const rename = rpc.calls.length - 1
|
||||
expect(effects.graphSync).not.toHaveBeenCalled()
|
||||
|
||||
await answer(rename, okReply, (layout) => {
|
||||
layout.tabs.find((item) => item.id === 'tab-unbound')!.customTitle = 'Build'
|
||||
})
|
||||
const row = store.getState().tabsByWorktree[GIT_KEY]!.find((item) => item.id === 'tab-unbound')!
|
||||
expect(row.customTitle).toBe('Build')
|
||||
expect(effects.graphSync).toHaveBeenCalledTimes(1)
|
||||
sync.close()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, 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'
|
||||
@@ -17,7 +17,6 @@ import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layou
|
||||
import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids'
|
||||
import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture'
|
||||
import { reportWindowSessionWriteActivity } from '../lib/window-session-write-activity'
|
||||
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'
|
||||
@@ -125,10 +124,6 @@ const FRAME_DIFFERENCE_FIXTURES = new Set([
|
||||
'session from before the tab bar, with an open file'
|
||||
])
|
||||
|
||||
beforeEach(() => {
|
||||
reportWindowSessionWriteActivity(false)
|
||||
})
|
||||
|
||||
afterEach(() => endLayoutFramePositions())
|
||||
|
||||
describe('the window drawn from layout frames', () => {
|
||||
@@ -225,17 +220,6 @@ describe('the window drawn from layout frames', () => {
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual(['renamed'])
|
||||
|
||||
// A held frame still releases its reply: the window's own save carries what it changed.
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const held = structuredClone(renamed)
|
||||
held.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'held'
|
||||
afterLayoutFrame(3, () => seen.push(shellTitle()))
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: held, seq: 3 })
|
||||
stream.onPosition!(3)
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual(['renamed', 'renamed'])
|
||||
reportWindowSessionWriteActivity(false)
|
||||
|
||||
const released = vi.fn()
|
||||
afterLayoutFrame(9, released)
|
||||
stream.onStatus!({ state: 'lost' })
|
||||
@@ -249,27 +233,9 @@ describe('the window drawn from layout frames', () => {
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('holds a structure frame while the window owes main a write, then shows it', async () => {
|
||||
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())
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
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 })
|
||||
await flushMicrotasks()
|
||||
const shellTitle = () =>
|
||||
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle
|
||||
expect(shellTitle()).not.toBe('renamed')
|
||||
reportWindowSessionWriteActivity(false)
|
||||
await flushMicrotasks()
|
||||
expect(shellTitle()).toBe('renamed')
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows main’s pane tree at once even while a write is owed, except a tab the window’s own pane command holds', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root
|
||||
const withRatio = (ratio: number) => {
|
||||
const next = structuredClone(layouts[GIT_KEY]!)
|
||||
@@ -293,16 +259,14 @@ describe('the window drawn from layout frames', () => {
|
||||
release()
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toMatchObject({ ratio: 0.7 })
|
||||
reportWindowSessionWriteActivity(false)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows main’s pane tree going back to one the window last showed before an owed write', async () => {
|
||||
it('shows main’s pane tree going back to one the window showed before', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
const shellRoot = () => store.getState().terminalLayoutsByTabId['tab-shell']!.root
|
||||
const original = shellRoot()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const changed = structuredClone(layouts[GIT_KEY]!)
|
||||
const root = changed.tabs.find((tab) => tab.id === 'tab-shell')!.terminal!.root!
|
||||
if (root.type === 'split') {
|
||||
@@ -312,11 +276,10 @@ describe('the window drawn from layout frames', () => {
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toMatchObject({ ratio: 0.15 })
|
||||
|
||||
// Same structure as the frame shown before the write: its pane tree still shows.
|
||||
// Same structure as the frame shown before: its pane tree still shows.
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) })
|
||||
await flushMicrotasks()
|
||||
expect(shellRoot()).toEqual(original)
|
||||
reportWindowSessionWriteActivity(false)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
@@ -324,8 +287,7 @@ describe('the window drawn from layout frames', () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
await flushMicrotasks()
|
||||
// A frame queued behind an owed write when the stream ends.
|
||||
reportWindowSessionWriteActivity(true)
|
||||
// A frame still queued when the stream ends.
|
||||
stream.onFrame!({ type: 'workspace', key: GIT_KEY, layout: structuredClone(layouts[GIT_KEY]!) })
|
||||
stream.onStatus!({ state: 'lost' })
|
||||
const drawn = { type: 'leaf' as const, leafId: 'drawn-by-the-window' }
|
||||
@@ -340,11 +302,6 @@ describe('the window drawn from layout frames', () => {
|
||||
await flushMicrotasks()
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.root).toEqual(drawn)
|
||||
|
||||
// Nor does a later flush, such as an owed write ending.
|
||||
reportWindowSessionWriteActivity(true)
|
||||
reportWindowSessionWriteActivity(false)
|
||||
await flushMicrotasks()
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.root).toEqual(drawn)
|
||||
sync.close()
|
||||
})
|
||||
|
||||
@@ -378,10 +335,9 @@ describe('the window drawn from layout frames', () => {
|
||||
sync.close()
|
||||
})
|
||||
|
||||
it('shows a live title change of the focused pane at once, even while a write is owed', async () => {
|
||||
it('shows a live title change of the focused pane without rebuilding structure', async () => {
|
||||
const { store, sync, layouts } = await bootWithFrames('local', localDesktopSession())
|
||||
sync.start()
|
||||
reportWindowSessionWriteActivity(true)
|
||||
const titled = structuredClone(layouts[GIT_KEY]!)
|
||||
const shell = titled.tabs.find((tab) => tab.id === 'tab-shell')!
|
||||
const focused = store.getState().terminalLayoutsByTabId['tab-shell']!.activeLeafId
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
// it writes that workspace to a partition this machine owns (a paired server's stay the mirror's)
|
||||
// and already holds it (startup restore keeps only workspaces the catalog knows).
|
||||
//
|
||||
// Frames wait while this window owes main a session write: until main has the window's own change,
|
||||
// a frame would put back what the window just changed. Main sends a write's frames before it
|
||||
// acknowledges the write, so the cache is current by the time the window is idle again.
|
||||
// A frame that changes only live terminal titles never waits: it touches no structure.
|
||||
// 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.
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -20,10 +18,6 @@ import {
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { pairedServerMirrors } from './paired-server-workspace'
|
||||
import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split'
|
||||
import {
|
||||
isWindowSessionWriteBusy,
|
||||
onWindowSessionWriteIdle
|
||||
} from '@/lib/window-session-write-activity'
|
||||
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'
|
||||
@@ -44,6 +38,11 @@ import {
|
||||
onTabPaneTreeRedraw,
|
||||
takeTabRowFields
|
||||
} from './layout-owner-pane-intents'
|
||||
import {
|
||||
isDrawnGroupLayoutHeld,
|
||||
isDrawnTabHeld,
|
||||
onDrawnTabsRedraw
|
||||
} from './layout-owner-drawn-tab-holds'
|
||||
import { subscribeRuntimeLayout } from './runtime-layout-client'
|
||||
|
||||
type FrameSyncStore = {
|
||||
@@ -154,15 +153,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
built,
|
||||
keys,
|
||||
isTabPaneTreeHeld,
|
||||
takeTabRowFields
|
||||
takeTabRowFields,
|
||||
HELD_DRAWN_TABS
|
||||
)
|
||||
if (Object.keys(patch).length > 0) {
|
||||
store.setState(patch)
|
||||
}
|
||||
}
|
||||
|
||||
// Main keeps its own pane trees over the window's saves, so they show at once, even while the
|
||||
// window owes main a write; only a tab the window's own pane command holds waits.
|
||||
// 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>()
|
||||
@@ -198,7 +197,6 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
const state = store.getState()
|
||||
const drawn = framesDrawWorkspace(state)
|
||||
const busy = isWindowSessionWriteBusy()
|
||||
const nextShown: Record<string, PublishedWorkspaceLayout> = {}
|
||||
const structural: string[] = []
|
||||
const titleUpdates: TerminalTabTitleUpdate[] = []
|
||||
@@ -221,10 +219,6 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
)
|
||||
)
|
||||
nextShown[key] = layout
|
||||
} else if (busy) {
|
||||
if (previous) {
|
||||
nextShown[key] = previous
|
||||
}
|
||||
} else {
|
||||
structural.push(key)
|
||||
nextShown[key] = layout
|
||||
@@ -247,7 +241,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
const scheduleFlush = (): void => {
|
||||
if (live && !flushQueued) {
|
||||
flushQueued = true
|
||||
// Coalesces the frames of one task; also lets a write that is about to start report busy.
|
||||
// Coalesces the frames of one task.
|
||||
queueMicrotask(flush)
|
||||
}
|
||||
}
|
||||
@@ -281,11 +275,20 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
endLayoutFramePositions()
|
||||
}
|
||||
},
|
||||
// After the frame's flush. A busy write may still hold the frame's tabs (never its pane
|
||||
// trees), so a waiting reply checks the store rather than assume the frame shows.
|
||||
// After the frame's flush. A held tab may still show as the window drew it, so a waiting reply
|
||||
// checks the store rather than assume the frame shows.
|
||||
(seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq))
|
||||
)
|
||||
const stopIdleListener = onWindowSessionWriteIdle(scheduleFlush)
|
||||
// A released tab shows main's copy: its workspaces draw from the cache again.
|
||||
const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => {
|
||||
if (!streamOpen) {
|
||||
return
|
||||
}
|
||||
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) => {
|
||||
// A closed stream's cache is frozen: the window draws its own answers then.
|
||||
@@ -311,7 +314,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
},
|
||||
close: () => {
|
||||
live = false
|
||||
stopIdleListener()
|
||||
stopHeldTabsListener()
|
||||
stopReleaseListener()
|
||||
stream.close()
|
||||
},
|
||||
@@ -333,6 +336,8 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
|
||||
}
|
||||
}
|
||||
|
||||
const HELD_DRAWN_TABS = { isTabHeld: isDrawnTabHeld, isGroupLayoutHeld: isDrawnGroupLayoutHeld }
|
||||
|
||||
let startupFrames: WorkspaceLayoutFrameSync | null = null
|
||||
|
||||
/** After restore: the window's one layout stream shows the frames' workspaces from now on. */
|
||||
|
||||
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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'
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
bootWindowFromPartitions,
|
||||
projectStartupWindowView
|
||||
} from '../app-shell/startup-window-view.test-fixture'
|
||||
import { reportWindowSessionWriteActivity } from '../lib/window-session-write-activity'
|
||||
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
|
||||
import { createTestStore } from '../store/slices/store-test-helpers'
|
||||
import type { AppState } from '../store/types'
|
||||
@@ -220,10 +219,6 @@ const CASES: Case[] = [
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
reportWindowSessionWriteActivity(false)
|
||||
})
|
||||
|
||||
describe('tab properties through main', () => {
|
||||
it.each(CASES.map((entry) => [entry.name, entry] as const))(
|
||||
'%s: main’s frame shows what the window’s own action shows, and restarts the same',
|
||||
|
||||
@@ -106,6 +106,8 @@ function applySent(): LayoutCommand[] {
|
||||
}
|
||||
|
||||
async function showMainFrame(): Promise<void> {
|
||||
// Main has answered the window's commands, so no tab of theirs is still held as drawn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const sync = openWorkspaceLayoutFrameSync(useAppStore)
|
||||
stream.onFrame!({
|
||||
type: 'snapshot',
|
||||
|
||||
@@ -102,8 +102,8 @@ describe('a terminal tab’s own changes in a layout-owner build', () => {
|
||||
})
|
||||
|
||||
it('leaves the window’s color as it is when main refuses', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'invalid_params' })
|
||||
const { store, tabId, tabBarId } = storeWithTab()
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'invalid_params' })
|
||||
|
||||
store.getState().setTabColor(tabId, '#f00')
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { commitTerminalSurfaceClose } from './terminal-surface-close-intent'
|
||||
|
||||
/**
|
||||
* A layout-owner build's record of a terminal tab this window closed: main records it through
|
||||
* `layout.apply`, or today's close intent does when main cannot. False when a paired server owns
|
||||
* the workspace's layout; the caller records it as today.
|
||||
* `layout.apply`, or today's close intent does for a twin a paired server may hold. False when a
|
||||
* paired server owns the workspace's layout; the caller records it as today.
|
||||
*/
|
||||
export function recordTerminalTabCloseThroughLayoutOwner(
|
||||
state: WorktreeRuntimeOwnerState & Pick<AppState, 'unifiedTabsByWorktree'>,
|
||||
@@ -26,7 +26,8 @@ export function recordTerminalTabCloseThroughLayoutOwner(
|
||||
{ type: 'closeTabs', tabIds: [entryId], recordClose: reason },
|
||||
{
|
||||
apply: keep,
|
||||
asToday: () => commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId }, reason)
|
||||
asToday: () => commitTerminalSurfaceClose(worktreeId, { kind: 'tab', tabId }, reason),
|
||||
drawn: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,20 +12,26 @@ import {
|
||||
takeTabRowFields
|
||||
} from '@/runtime/layout-owner-pane-intents'
|
||||
import { createTestStore, makeTab } from '../slices/store-test-helpers'
|
||||
import { createInitialTerminalThroughLayoutOwner } from './terminal-tab-create-through-layout-owner'
|
||||
import {
|
||||
createInitialTerminalThroughLayoutOwner,
|
||||
recordCreatedTerminalTabInLayoutOwner
|
||||
} from './terminal-tab-create-through-layout-owner'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const held: {
|
||||
pairedServer: string | null
|
||||
twin: boolean
|
||||
reply: ((value: LayoutApplyReply) => void) | null
|
||||
fail: ((error: Error) => void) | null
|
||||
} = { pairedServer: null, reply: null, fail: null }
|
||||
} = { pairedServer: null, twin: false, reply: null, fail: null }
|
||||
return Object.assign(held, { callRuntimeRpc: vi.fn() })
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
vi.mock('@/lib/resolve-owner', () => ({
|
||||
runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer
|
||||
runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer,
|
||||
runtimeEnvironmentIdForWorkspaceOwner: () =>
|
||||
mocks.twin ? { kind: 'ambiguous' } : { kind: 'resolved', environmentId: null }
|
||||
}))
|
||||
|
||||
const WORKTREE = 'repo1::/path/wt'
|
||||
@@ -52,6 +58,7 @@ beforeEach(() => {
|
||||
_resetLayoutOwnerPaneIntentsForTests()
|
||||
endLayoutFramePositions()
|
||||
mocks.pairedServer = null
|
||||
mocks.twin = false
|
||||
mocks.callRuntimeRpc.mockReset()
|
||||
mocks.callRuntimeRpc.mockImplementation(
|
||||
() =>
|
||||
@@ -81,17 +88,17 @@ describe('createInitialTerminalThroughLayoutOwner', () => {
|
||||
expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true })
|
||||
})
|
||||
|
||||
it('draws main’s tab itself, under main’s ids, when no frame is coming', async () => {
|
||||
it('draws nothing itself when no frame is coming: the reopened stream’s snapshot brings it', async () => {
|
||||
const { create, command } = start()
|
||||
|
||||
mocks.reply?.({ ok: true, result: { tabId: command.tabId, leafId: command.leafId } })
|
||||
await settle()
|
||||
|
||||
expect(create).toHaveBeenCalledWith({ tabId: command.tabId, leafId: command.leafId })
|
||||
expect(takeTabRowFields(command.tabId)).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true })
|
||||
})
|
||||
|
||||
it('waits for a stream not yet in, and draws main’s tab itself if it ends first', async () => {
|
||||
it('draws nothing itself when the stream ends before main’s frame', async () => {
|
||||
_resetLayoutFramePositionForTests()
|
||||
beginLayoutFramePositions()
|
||||
const { create, command } = start()
|
||||
@@ -102,10 +109,10 @@ describe('createInitialTerminalThroughLayoutOwner', () => {
|
||||
frameSeq: 1
|
||||
})
|
||||
await settle()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
|
||||
endLayoutFramePositions()
|
||||
expect(create).toHaveBeenCalledWith({ tabId: command.tabId, leafId: command.leafId })
|
||||
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(takeTabRowFields(command.tabId)).toEqual({ pendingActivationSpawn: true })
|
||||
})
|
||||
|
||||
it('draws nothing itself when a frame showed the tab before the stream ended', async () => {
|
||||
@@ -174,3 +181,29 @@ describe('createInitialTerminalThroughLayoutOwner', () => {
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordCreatedTerminalTabInLayoutOwner', () => {
|
||||
const command = {
|
||||
type: 'createTerminalTab' as const,
|
||||
workspace: WORKTREE,
|
||||
tabId: 'tab-drawn',
|
||||
leafId: 'leaf-drawn'
|
||||
}
|
||||
|
||||
it.each([
|
||||
[false, 1],
|
||||
[true, 0]
|
||||
])(
|
||||
'on host_not_owned (twin: %s), closes the drawn tab unless a paired server may hold it',
|
||||
async (twin, closes) => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.twin = twin
|
||||
const state = stateWith(['tab-drawn'])
|
||||
const closeTab = vi.fn()
|
||||
recordCreatedTerminalTabInLayoutOwner({ ...state, closeTab }, command)
|
||||
mocks.reply?.({ ok: false, code: 'host_not_owned' })
|
||||
await settle()
|
||||
expect(closeTab).toHaveBeenCalledTimes(closes)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// window's save of it changes nothing in main.
|
||||
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { afterLayoutFrame, layoutFramesFlowing } from '@/runtime/layout-frame-position'
|
||||
import { afterLayoutFrame } from '@/runtime/layout-frame-position'
|
||||
import { expectTabRowFields } from '@/runtime/layout-owner-pane-intents'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import { sendLayoutApply } from '@/runtime/layout-apply-send'
|
||||
import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { layoutFrameTerminalTab } from '@/runtime/layout-frame-terminal-panes'
|
||||
@@ -86,49 +87,41 @@ export function terminalTabCreateCommand(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a tab the window just drew to main. Nothing to undo on any answer: main's frame is what
|
||||
* stays drawn, since the window's session save no longer carries the tab. A refusal is logged.
|
||||
* Sends a tab the window just drew to main; frames keep it as drawn until main answers. A refusal
|
||||
* is logged and the window closes the tab, stopping its terminal, since main's frame will not
|
||||
* have it. A twin that main may route to a paired server keeps it: the window's save carries the
|
||||
* server's partition as it is.
|
||||
*/
|
||||
export function recordCreatedTerminalTabInLayoutOwner(
|
||||
state: AppState,
|
||||
command: CommandOf<'createTerminalTab'> & { tabId: string; leafId: string }
|
||||
): void {
|
||||
// Why: a paired server mirrors this workspace's tabs; main does not own its layout. A tab main
|
||||
// already holds is its own.
|
||||
if (
|
||||
// Why: as `sendLayoutApply`, a twin stays on main's path: it edits this window's layout.
|
||||
runtimeEnvironmentIdUnlessTwin(state, command.workspace) !== null ||
|
||||
layoutFrameTerminalTab(command.workspace, command.tabId)
|
||||
) {
|
||||
// Why: a tab main already holds is its own.
|
||||
if (layoutFrameTerminalTab(command.workspace, command.tabId)) {
|
||||
return
|
||||
}
|
||||
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', { command }).then(
|
||||
(reply) => {
|
||||
if (!reply.ok) {
|
||||
console.warn('[layout-owner] createTerminalTab refused:', reply.code)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.warn(
|
||||
'[layout-owner] createTerminalTab refused: call_failed',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
}
|
||||
)
|
||||
const { workspace, ...rest } = command
|
||||
const keep = (): void => {}
|
||||
sendLayoutApply(state, workspace, rest, {
|
||||
apply: keep,
|
||||
asToday: keep,
|
||||
refused: () => state.closeTab(command.tabId, { reason: 'cleanup' }),
|
||||
drawn: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace's first terminal on activation (F23). Main creates it only into a workspace its copy
|
||||
* holds no tab of, so this window and the runtime asking at once still make one tab. Main's frame
|
||||
* draws it, with the window's own row fields; the window draws main's tab itself, under main's
|
||||
* ids, only when no frame is coming (the stream is down). False when a paired server owns the
|
||||
* workspace's layout. `create` with no ids opens the terminal as today: main holds no partition
|
||||
* for the workspace.
|
||||
* draws it, with the window's own row fields; while the stream is down, the reopened stream's
|
||||
* snapshot does. False when a paired server owns the workspace's layout. `create` opens the
|
||||
* terminal as today: main does not own the workspace (a twin it routes to a paired
|
||||
* server, whose partition the window's save carries), or the call threw and was rolled back.
|
||||
*/
|
||||
export function createInitialTerminalThroughLayoutOwner(
|
||||
getState: () => AppState,
|
||||
worktreeId: string,
|
||||
create: (ids?: { tabId: string; leafId: string }) => void
|
||||
create: () => void
|
||||
): boolean {
|
||||
if (runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) !== null) {
|
||||
return false
|
||||
@@ -157,14 +150,8 @@ export function createInitialTerminalThroughLayoutOwner(
|
||||
(reply) =>
|
||||
// After the frame with main's tab: the stream may end first, and the window draws it then.
|
||||
afterLayoutFrame(reply.frameSeq, () => {
|
||||
// Main's frame draws it, a reopened stream's snapshot too.
|
||||
if (reply.ok && reply.result.tabId === tabId) {
|
||||
// Why the store check: a frame may have drawn it before the stream ended.
|
||||
if (!layoutFramesFlowing()) {
|
||||
release()
|
||||
if (!holdsTerminalTab(getState(), tabId)) {
|
||||
create({ tabId, leafId })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// No tab id: main's copy already shows a tab here, which its frames bring.
|
||||
@@ -196,7 +183,3 @@ export function createInitialTerminalThroughLayoutOwner(
|
||||
|
||||
/** The window's own mark on a first terminal: its spawn does not count as workspace activity. */
|
||||
const ACTIVATION_ROW_FIELDS = { pendingActivationSpawn: true } as const
|
||||
|
||||
function holdsTerminalTab(state: AppState, tabId: string): boolean {
|
||||
return Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === tabId))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Builds frame slices only and never calls set: the store patch applies what it returns.
|
||||
// Slices rebuilt from a frame, with each tab the window's own command still holds put back as the
|
||||
// store shows it (present, gone, or where the window placed it), and the rest as main has it. A
|
||||
// group's order keeps main's for the tabs nothing holds.
|
||||
|
||||
import type { Tab, TabGroup } from '../../../shared/tab-types'
|
||||
import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type'
|
||||
import type { LayoutFrameSlices } from './workspace-layout-frame-slices'
|
||||
import type { LayoutFrameStoreState } from './workspace-layout-frame-store-patch'
|
||||
|
||||
export type HeldDrawnTabs = {
|
||||
/** By tab-bar or entity id. */
|
||||
isTabHeld: (workspace: string, id: string) => boolean
|
||||
isGroupLayoutHeld: (workspace: string) => boolean
|
||||
}
|
||||
|
||||
export const NO_HELD_TABS: HeldDrawnTabs = {
|
||||
isTabHeld: () => false,
|
||||
isGroupLayoutHeld: () => false
|
||||
}
|
||||
|
||||
/** `built`'s records with the held ones as `existing` has them: kept, dropped or added. */
|
||||
function asDrawn<T>(
|
||||
existing: readonly T[] | undefined,
|
||||
built: T[] | undefined,
|
||||
heldIds: ReadonlySet<string>,
|
||||
idOf: (record: T) => string
|
||||
): T[] | undefined {
|
||||
const drawn = new Map(
|
||||
(existing ?? []).filter((record) => heldIds.has(idOf(record))).map((r) => [idOf(r), r])
|
||||
)
|
||||
if (drawn.size === 0 && !(built ?? []).some((record) => heldIds.has(idOf(record)))) {
|
||||
return built
|
||||
}
|
||||
const builtIds = new Set((built ?? []).map(idOf))
|
||||
const records = [
|
||||
...(built ?? []).flatMap((record) => {
|
||||
const id = idOf(record)
|
||||
return heldIds.has(id) ? (drawn.has(id) ? [drawn.get(id)!] : []) : [record]
|
||||
}),
|
||||
...[...drawn.values()].filter((record) => !builtIds.has(idOf(record)))
|
||||
]
|
||||
return records.length > 0 || built ? records : undefined
|
||||
}
|
||||
|
||||
/** Main's order without the held tabs, each held tab put back after its neighbour in the store. */
|
||||
function heldOrder(
|
||||
built: TabGroup | undefined,
|
||||
existing: TabGroup | undefined,
|
||||
heldIds: ReadonlySet<string>
|
||||
): string[] {
|
||||
const order = (built?.tabOrder ?? []).filter((id) => !heldIds.has(id))
|
||||
const drawnOrder = existing?.tabOrder ?? []
|
||||
drawnOrder.forEach((id, index) => {
|
||||
if (!heldIds.has(id)) {
|
||||
return
|
||||
}
|
||||
const before = drawnOrder
|
||||
.slice(0, index)
|
||||
.toReversed()
|
||||
.find((previous) => order.includes(previous))
|
||||
order.splice(before === undefined ? 0 : order.indexOf(before) + 1, 0, id)
|
||||
})
|
||||
return order
|
||||
}
|
||||
|
||||
function heldGroups(
|
||||
existing: readonly TabGroup[],
|
||||
built: readonly TabGroup[],
|
||||
heldIds: ReadonlySet<string>
|
||||
): TabGroup[] {
|
||||
const touches = (group: TabGroup | undefined): boolean =>
|
||||
group?.tabOrder.some((id) => heldIds.has(id)) ?? false
|
||||
const existingById = new Map(existing.map((group) => [group.id, group]))
|
||||
const builtIds = new Set(built.map((group) => group.id))
|
||||
const reconcile = (main: TabGroup | undefined, drawn: TabGroup | undefined): TabGroup[] => {
|
||||
if (!touches(main) && !touches(drawn)) {
|
||||
return main ? [main] : []
|
||||
}
|
||||
const tabOrder = heldOrder(main, drawn, heldIds)
|
||||
// A group only main still has, left with no tab: the window closed or moved its last one.
|
||||
if (!drawn && tabOrder.length === 0) {
|
||||
return []
|
||||
}
|
||||
const base = (drawn ?? main)!
|
||||
const activeTabId =
|
||||
[drawn?.activeTabId, main?.activeTabId].find((id) => id && tabOrder.includes(id)) ??
|
||||
tabOrder[0] ??
|
||||
null
|
||||
return [{ ...base, tabOrder, activeTabId }]
|
||||
}
|
||||
return [
|
||||
...built.flatMap((group) => reconcile(group, existingById.get(group.id))),
|
||||
...existing
|
||||
.filter((group) => !builtIds.has(group.id) && touches(group))
|
||||
.flatMap((group) => reconcile(undefined, group))
|
||||
]
|
||||
}
|
||||
|
||||
function entityIds(tabs: readonly Tab[], kind: (tab: Tab) => boolean): Set<string> {
|
||||
return new Set(tabs.filter(kind).map((tab) => tab.entityId))
|
||||
}
|
||||
|
||||
/** `built` for `keys`, with every tab `held` names as `state` draws it. */
|
||||
export function withHeldTabsAsDrawn(
|
||||
state: LayoutFrameStoreState,
|
||||
built: LayoutFrameSlices,
|
||||
keys: readonly string[],
|
||||
held: HeldDrawnTabs
|
||||
): LayoutFrameSlices {
|
||||
let next = built
|
||||
for (const key of keys) {
|
||||
const isHeld = (tab: Tab): boolean =>
|
||||
held.isTabHeld(key, tab.id) || held.isTabHeld(key, tab.entityId)
|
||||
const heldTabs = [
|
||||
...(state.unifiedTabsByWorktree[key] ?? []),
|
||||
...(built.unifiedTabsByWorktree[key] ?? [])
|
||||
].filter(isHeld)
|
||||
const layoutHeld = held.isGroupLayoutHeld(key)
|
||||
if (heldTabs.length === 0 && !layoutHeld) {
|
||||
continue
|
||||
}
|
||||
const heldIds = new Set(heldTabs.map((tab) => tab.id))
|
||||
const groups = heldGroups(
|
||||
state.groupsByWorktree[key] ?? [],
|
||||
next.groupsByWorktree[key] ?? [],
|
||||
heldIds
|
||||
)
|
||||
const groupIds = (list: readonly TabGroup[]): string =>
|
||||
list
|
||||
.map((group) => group.id)
|
||||
.sort()
|
||||
.join('\0')
|
||||
const layout =
|
||||
layoutHeld || groupIds(groups) !== groupIds(next.groupsByWorktree[key] ?? [])
|
||||
? state.layoutByWorktree[key]
|
||||
: next.layoutByWorktree[key]
|
||||
const terminals = entityIds(heldTabs, (tab) => tab.contentType === 'terminal')
|
||||
const browsers = entityIds(heldTabs, (tab) => tab.contentType === 'browser')
|
||||
const files = entityIds(heldTabs, (tab) => isEditorTabContentType(tab.contentType))
|
||||
|
||||
const terminalLayouts = { ...next.terminalLayoutsByTabId }
|
||||
for (const id of terminals) {
|
||||
if (id in state.terminalLayoutsByTabId) {
|
||||
terminalLayouts[id] = state.terminalLayoutsByTabId[id]!
|
||||
} else {
|
||||
delete terminalLayouts[id]
|
||||
}
|
||||
}
|
||||
// A browser tab or file the window closed brings back none of its pages or record.
|
||||
const drawnBrowsers = new Set((state.browserTabsByWorktree[key] ?? []).map((tab) => tab.id))
|
||||
const closedBrowsers = [...browsers].filter((id) => !drawnBrowsers.has(id))
|
||||
const closedPages = new Set(
|
||||
closedBrowsers.flatMap((id) => (next.browserPagesByWorkspace[id] ?? []).map((p) => p.id))
|
||||
)
|
||||
const drawnFiles = new Set(
|
||||
state.openFiles.filter((file) => file.worktreeId === key).map((file) => file.id)
|
||||
)
|
||||
next = {
|
||||
...next,
|
||||
unifiedTabsByWorktree: withKey(
|
||||
next.unifiedTabsByWorktree,
|
||||
key,
|
||||
asDrawn(state.unifiedTabsByWorktree[key], next.unifiedTabsByWorktree[key], heldIds, idOf)
|
||||
),
|
||||
groupsByWorktree: withKey(next.groupsByWorktree, key, groups),
|
||||
layoutByWorktree: withKey(next.layoutByWorktree, key, layout),
|
||||
tabsByWorktree: withKey(
|
||||
next.tabsByWorktree,
|
||||
key,
|
||||
asDrawn(state.tabsByWorktree[key], next.tabsByWorktree[key], terminals, idOf)
|
||||
),
|
||||
terminalLayoutsByTabId: terminalLayouts,
|
||||
browserTabsByWorktree: withKey(
|
||||
next.browserTabsByWorktree,
|
||||
key,
|
||||
asDrawn(state.browserTabsByWorktree[key], next.browserTabsByWorktree[key], browsers, idOf)
|
||||
),
|
||||
browserPagesByWorkspace: Object.fromEntries(
|
||||
Object.entries(next.browserPagesByWorkspace).filter(([id]) => !closedBrowsers.includes(id))
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: Object.fromEntries(
|
||||
Object.entries(next.remoteBrowserPageHandlesByPageId).filter(([id]) => !closedPages.has(id))
|
||||
),
|
||||
openFiles: next.openFiles.filter(
|
||||
(file) => file.worktreeId !== key || !files.has(file.id) || drawnFiles.has(file.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const idOf = (record: { id: string }): string => record.id
|
||||
|
||||
function withKey<T>(map: Record<string, T>, key: string, value: T | undefined): Record<string, T> {
|
||||
if (value === undefined) {
|
||||
const { [key]: _removed, ...rest } = map
|
||||
return rest
|
||||
}
|
||||
return { ...map, [key]: value }
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type'
|
||||
import type { AppState } from './types'
|
||||
import type { LayoutFrameSlices } from './workspace-layout-frame-slices'
|
||||
import {
|
||||
NO_HELD_TABS,
|
||||
withHeldTabsAsDrawn,
|
||||
type HeldDrawnTabs
|
||||
} from './workspace-layout-frame-held-tabs'
|
||||
|
||||
/** Row fields this window owns: what its mounted panes are doing, and which terminal each row
|
||||
* binds (main publishes the saved binding; the live one stays the window's until main owns it). */
|
||||
@@ -126,13 +131,16 @@ function setKey<T>(
|
||||
*/
|
||||
export function buildLayoutFrameStorePatch(
|
||||
state: LayoutFrameStoreState,
|
||||
built: LayoutFrameSlices,
|
||||
frameSlices: LayoutFrameSlices,
|
||||
keys: readonly string[],
|
||||
/** A tab whose pane layout stays as the store has it: the window's own pane command holds it. */
|
||||
keepsTerminalLayout: (tabId: string) => boolean = () => false,
|
||||
/** This window's own fields for a row the frame creates. */
|
||||
newRowFields: (tabId: string) => Partial<TerminalTab> | null = () => null
|
||||
newRowFields: (tabId: string) => Partial<TerminalTab> | null = () => null,
|
||||
/** Tabs the window drew, closed or moved before main answered: they stay as the store has them. */
|
||||
held: HeldDrawnTabs = NO_HELD_TABS
|
||||
): Partial<LayoutFrameStoreState> {
|
||||
const built = withHeldTabsAsDrawn(state, frameSlices, keys, held)
|
||||
const patch: Partial<LayoutFrameStoreState> = {}
|
||||
const keyed = (slice: KeyedSlice, value: (key: string) => unknown): void => {
|
||||
let next: Record<string, unknown> = state[slice]
|
||||
|
||||
Reference in New Issue
Block a user