mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
perf(renderer): bail out of identity-equal terminal layout and cache-timer writes
setCacheTimerStartedAt and setTabLayout spread a fresh object and returned it unconditionally, so every redundant call published a new AppState and ran every zustand subscriber's selector across all mounted panes. Both have a real redundant cadence: parked-terminal-byte-watcher writes a null cache timer on each agent working/exit/stale-title transition, and TerminalPane re-persists an identical layout on pane-title churn. Extract the existing terminalLayoutEqual comparator out of web-session-tabs-sync into a shared module and use it to gate the layout write, and dedupe the remote-runtime layout IPC against the last snapshot pushed per tab.
This commit is contained in:
@@ -136,8 +136,7 @@ import {
|
||||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import {
|
||||
clearWebRuntimeTerminalBuffer,
|
||||
closeWebRuntimeTerminal,
|
||||
updateWebRuntimePaneLayout
|
||||
closeWebRuntimeTerminal
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import {
|
||||
armPrimarySelectionNativePasteSuppression,
|
||||
@@ -158,6 +157,10 @@ import {
|
||||
planTerminalLiveLayoutInsertions
|
||||
} from './terminal-live-layout-reconciliation'
|
||||
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
|
||||
import {
|
||||
createRemotePaneLayoutPusher,
|
||||
type RemotePaneLayoutPusher
|
||||
} from './remote-pane-layout-push'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
@@ -512,6 +515,8 @@ function TerminalPane(
|
||||
paneTitlesRef.current = paneTitles
|
||||
const removedTitleLeafIdsRef = useRef<Set<string>>(new Set())
|
||||
const clearedScrollbackLeafIdsRef = useRef<Set<string>>(new Set())
|
||||
const remotePaneLayoutPusherRef = useRef<RemotePaneLayoutPusher | null>(null)
|
||||
remotePaneLayoutPusherRef.current ??= createRemotePaneLayoutPusher()
|
||||
const [paneTitleOverlayRects, setPaneTitleOverlayRects] = useState<
|
||||
Record<number, PaneTitleOverlayRect>
|
||||
>({})
|
||||
@@ -1057,19 +1062,21 @@ function TerminalPane(
|
||||
(ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId)
|
||||
)
|
||||
if (hasRemotePane) {
|
||||
void updateWebRuntimePaneLayout({
|
||||
worktreeId,
|
||||
tabId,
|
||||
root: layout.root,
|
||||
expandedLeafId: layout.expandedLeafId,
|
||||
...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {})
|
||||
})
|
||||
remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, layout })
|
||||
}
|
||||
for (const leafId of currentLeafIds) {
|
||||
clearedScrollbackLeafIds.delete(leafId)
|
||||
}
|
||||
}, [tabId, setTabLayout, worktreeId])
|
||||
|
||||
// Why: the dedupe cache mirrors what one host holds for one tab, so drop it when either changes or the pane unmounts.
|
||||
useEffect(() => {
|
||||
const pusher = remotePaneLayoutPusherRef.current
|
||||
return () => {
|
||||
pusher?.reset()
|
||||
}
|
||||
}, [tabId, worktreeId])
|
||||
|
||||
const clearPaneScrollback = useCallback(
|
||||
(pane: ManagedPane): void => {
|
||||
clearedScrollbackLeafIdsRef.current.add(pane.leafId)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Perf regression: layout persists fire on pane-title churn, and every one of
|
||||
* them used to push a remote-runtime IPC round trip regardless of whether the
|
||||
* host-visible layout had moved. Counting invocations at the mocked IPC boundary
|
||||
* pins the before/after: 100 persists with an unchanged layout cost 100 pushes
|
||||
* before the dedupe and 1 after.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
|
||||
const updateWebRuntimePaneLayout = vi.fn()
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
updateWebRuntimePaneLayout: (...args: unknown[]) => updateWebRuntimePaneLayout(...args)
|
||||
}))
|
||||
|
||||
const { createRemotePaneLayoutPusher } = await import('./remote-pane-layout-push')
|
||||
|
||||
const PERSISTS = 100
|
||||
|
||||
function makeLayout(overrides: Partial<TerminalLayoutSnapshot> = {}): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
},
|
||||
activeLeafId: 'leaf-a',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-a': 'remote:pty-a', 'leaf-b': 'remote:pty-b' },
|
||||
titlesByLeafId: { 'leaf-a': 'build' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('createRemotePaneLayoutPusher', () => {
|
||||
beforeEach(() => {
|
||||
updateWebRuntimePaneLayout.mockClear()
|
||||
})
|
||||
|
||||
it('pushes once across 100 persists of an unchanged layout', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
for (let i = 0; i < PERSISTS; i += 1) {
|
||||
// Fresh object each time: persistLayoutSnapshot re-serializes on every call.
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
}
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends the same payload the un-deduped path sent', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
const layout = makeLayout()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout })
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
root: layout.root,
|
||||
expandedLeafId: layout.expandedLeafId,
|
||||
titlesByLeafId: layout.titlesByLeafId
|
||||
})
|
||||
})
|
||||
|
||||
it('omits titlesByLeafId when the layout carries no titles', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
pusher.push({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
layout: makeLayout({ titlesByLeafId: undefined })
|
||||
})
|
||||
expect(updateWebRuntimePaneLayout.mock.calls[0][0]).not.toHaveProperty('titlesByLeafId')
|
||||
})
|
||||
|
||||
it('pushes again for every host-visible change', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
pusher.push({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
layout: makeLayout({ expandedLeafId: 'leaf-a' })
|
||||
})
|
||||
pusher.push({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
layout: makeLayout({
|
||||
expandedLeafId: 'leaf-a',
|
||||
titlesByLeafId: { 'leaf-a': 'test' }
|
||||
})
|
||||
})
|
||||
pusher.push({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
layout: makeLayout({
|
||||
expandedLeafId: 'leaf-a',
|
||||
titlesByLeafId: { 'leaf-a': 'test' },
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('pushes again when a remote pane swaps its pty', () => {
|
||||
// ptyIdsByLeafId is not in the payload, but a swap means a different host session.
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
pusher.push({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
layout: makeLayout({ ptyIdsByLeafId: { 'leaf-a': 'remote:pty-c', 'leaf-b': 'remote:pty-b' } })
|
||||
})
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not carry a cached layout across tabs', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-2', layout: makeLayout() })
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('re-pushes after reset so a remount re-establishes host geometry', () => {
|
||||
const pusher = createRemotePaneLayoutPusher()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
pusher.reset()
|
||||
pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
|
||||
expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
|
||||
import { updateWebRuntimePaneLayout } from '@/runtime/web-runtime-session'
|
||||
|
||||
export type RemotePaneLayoutPusher = {
|
||||
push: (input: { worktreeId: string; tabId: string; layout: TerminalLayoutSnapshot }) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Pane geometry is host-authoritative for remote tabs, so persists must push it — but
|
||||
* persists also fire on pane-title churn, which leaves the host-visible layout untouched.
|
||||
* Dedupe against the last push so unchanged layouts cost no remote round trip.
|
||||
*/
|
||||
export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher {
|
||||
let lastPushed: { tabId: string; snapshot: TerminalLayoutSnapshot } | null = null
|
||||
return {
|
||||
push: ({ worktreeId, tabId, layout }) => {
|
||||
if (lastPushed?.tabId === tabId && terminalLayoutEqual(lastPushed.snapshot, layout)) {
|
||||
return
|
||||
}
|
||||
lastPushed = { tabId, snapshot: layout }
|
||||
void updateWebRuntimePaneLayout({
|
||||
worktreeId,
|
||||
tabId,
|
||||
root: layout.root,
|
||||
expandedLeafId: layout.expandedLeafId,
|
||||
...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {})
|
||||
})
|
||||
},
|
||||
reset: () => {
|
||||
lastPushed = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types'
|
||||
|
||||
function sameStringRecord(
|
||||
a: Readonly<Record<string, string>> | undefined,
|
||||
b: Readonly<Record<string, string>> | undefined
|
||||
): boolean {
|
||||
const left = a ?? {}
|
||||
const right = b ?? {}
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
return (
|
||||
leftKeys.length === rightKeys.length &&
|
||||
leftKeys.every(
|
||||
(key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function terminalLayoutNodeEqual(
|
||||
a: TerminalPaneLayoutNode | null | undefined,
|
||||
b: TerminalPaneLayoutNode | null | undefined
|
||||
): boolean {
|
||||
if (!a || !b) {
|
||||
return !a && !b
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false
|
||||
}
|
||||
if (a.type === 'leaf') {
|
||||
return b.type === 'leaf' && a.leafId === b.leafId
|
||||
}
|
||||
return (
|
||||
b.type === 'split' &&
|
||||
a.direction === b.direction &&
|
||||
a.ratio === b.ratio &&
|
||||
terminalLayoutNodeEqual(a.first, b.first) &&
|
||||
terminalLayoutNodeEqual(a.second, b.second)
|
||||
)
|
||||
}
|
||||
|
||||
/** Structural equality over every persisted layout field; drives store/IPC write bailouts. */
|
||||
export function terminalLayoutEqual(
|
||||
a: TerminalLayoutSnapshot | undefined,
|
||||
b: TerminalLayoutSnapshot
|
||||
): boolean {
|
||||
return (
|
||||
terminalLayoutNodeEqual(a?.root, b.root) &&
|
||||
(a?.activeLeafId ?? null) === b.activeLeafId &&
|
||||
(a?.expandedLeafId ?? null) === b.expandedLeafId &&
|
||||
sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
|
||||
sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
|
||||
sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
|
||||
sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
|
||||
)
|
||||
}
|
||||
@@ -25,13 +25,13 @@ import type {
|
||||
TabGroup,
|
||||
TabGroupLayoutNode,
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalTab
|
||||
} from '../../../shared/types'
|
||||
import type { OpenFile } from '../store/slices/editor'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
|
||||
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
|
||||
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
|
||||
@@ -1424,59 +1424,6 @@ function isMirroredCommandCodeTurnBump(
|
||||
)
|
||||
}
|
||||
|
||||
function sameStringRecord(
|
||||
a: Readonly<Record<string, string>> | undefined,
|
||||
b: Readonly<Record<string, string>> | undefined
|
||||
): boolean {
|
||||
const left = a ?? {}
|
||||
const right = b ?? {}
|
||||
const leftKeys = Object.keys(left)
|
||||
const rightKeys = Object.keys(right)
|
||||
return (
|
||||
leftKeys.length === rightKeys.length &&
|
||||
leftKeys.every(
|
||||
(key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function terminalLayoutNodeEqual(
|
||||
a: TerminalPaneLayoutNode | null | undefined,
|
||||
b: TerminalPaneLayoutNode | null | undefined
|
||||
): boolean {
|
||||
if (!a || !b) {
|
||||
return !a && !b
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false
|
||||
}
|
||||
if (a.type === 'leaf') {
|
||||
return b.type === 'leaf' && a.leafId === b.leafId
|
||||
}
|
||||
return (
|
||||
b.type === 'split' &&
|
||||
a.direction === b.direction &&
|
||||
a.ratio === b.ratio &&
|
||||
terminalLayoutNodeEqual(a.first, b.first) &&
|
||||
terminalLayoutNodeEqual(a.second, b.second)
|
||||
)
|
||||
}
|
||||
|
||||
function terminalLayoutEqual(
|
||||
a: TerminalLayoutSnapshot | undefined,
|
||||
b: TerminalLayoutSnapshot
|
||||
): boolean {
|
||||
return (
|
||||
terminalLayoutNodeEqual(a?.root, b.root) &&
|
||||
(a?.activeLeafId ?? null) === b.activeLeafId &&
|
||||
(a?.expandedLeafId ?? null) === b.expandedLeafId &&
|
||||
sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
|
||||
sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
|
||||
sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
|
||||
sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] {
|
||||
if (!recent || recent.length === 0) {
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Perf regression: setCacheTimerStartedAt and setTabLayout must not publish
|
||||
* state when the write is a no-op.
|
||||
*
|
||||
* Both actions previously spread a fresh object and returned it unconditionally,
|
||||
* so every redundant call produced a new AppState object and woke EVERY zustand
|
||||
* subscriber — which, with per-pane selectors across all mounted panes, is an
|
||||
* O(panes) sweep per write. The cadence is real: parked-terminal-byte-watcher
|
||||
* writes a null cache timer on every agent working/exit/stale-title transition,
|
||||
* and TerminalPane re-persists its layout on pane-title churn.
|
||||
*
|
||||
* These tests count subscriber wakeups on a real store; the pre-fix numbers are
|
||||
* 1_000 (one per call), the post-fix numbers are 0.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
const REPEATS = 1_000
|
||||
|
||||
function makeLayout(overrides: Partial<TerminalLayoutSnapshot> = {}): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
},
|
||||
activeLeafId: 'leaf-a',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-b' },
|
||||
titlesByLeafId: { 'leaf-a': 'build' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('setCacheTimerStartedAt identity bailout', () => {
|
||||
it('wakes zero subscribers across 1,000 null-over-null writes', () => {
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-1:leaf-a'
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
for (let i = 0; i < REPEATS; i += 1) {
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
}
|
||||
unsubscribe()
|
||||
|
||||
expect(wakeups).toBe(0)
|
||||
expect(store.getState().cacheTimerByKey[paneKey]).toBeNull()
|
||||
})
|
||||
|
||||
it('wakes zero subscribers across 1,000 repeats of the same timestamp', () => {
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-1:leaf-a'
|
||||
store.getState().setCacheTimerStartedAt(paneKey, 1_700_000_000_000)
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
for (let i = 0; i < REPEATS; i += 1) {
|
||||
store.getState().setCacheTimerStartedAt(paneKey, 1_700_000_000_000)
|
||||
}
|
||||
unsubscribe()
|
||||
|
||||
expect(wakeups).toBe(0)
|
||||
})
|
||||
|
||||
it('still publishes when the timestamp actually changes', () => {
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-1:leaf-a'
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
store.getState().setCacheTimerStartedAt(paneKey, 123)
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
unsubscribe()
|
||||
|
||||
expect(wakeups).toBe(2)
|
||||
expect(store.getState().cacheTimerByKey[paneKey]).toBeNull()
|
||||
})
|
||||
|
||||
it('records the first write for a key even when the value is null', () => {
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-1:leaf-a'
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
unsubscribe()
|
||||
|
||||
expect(wakeups).toBe(1)
|
||||
expect(paneKey in store.getState().cacheTimerByKey).toBe(true)
|
||||
})
|
||||
|
||||
it('still clears a stale :seed sentinel when the pane value is unchanged', () => {
|
||||
// Without this carve-out the bailout would strand the sentinel and leave a phantom timer.
|
||||
const store = createTestStore()
|
||||
const paneKey = 'tab-1:leaf-a'
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
store.setState((s) => ({ cacheTimerByKey: { ...s.cacheTimerByKey, 'tab-1:seed': 42 } }))
|
||||
|
||||
store.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
|
||||
expect('tab-1:seed' in store.getState().cacheTimerByKey).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTabLayout identity bailout', () => {
|
||||
it('wakes zero subscribers across 1,000 structurally identical snapshots', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
for (let i = 0; i < REPEATS; i += 1) {
|
||||
// Fresh object each iteration: this is what persistLayoutSnapshot produces.
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
}
|
||||
unsubscribe()
|
||||
|
||||
expect(wakeups).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the stored snapshot reference stable when nothing changed', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
const first = store.getState().terminalLayoutsByTabId['tab-1']
|
||||
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-1']).toBe(first)
|
||||
})
|
||||
|
||||
it('publishes when any tracked field changes', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
|
||||
const mutations: Partial<TerminalLayoutSnapshot>[] = [
|
||||
{ activeLeafId: 'leaf-b' },
|
||||
{ expandedLeafId: 'leaf-a' },
|
||||
{ titlesByLeafId: { 'leaf-a': 'test' } },
|
||||
{ ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-c' } },
|
||||
{ buffersByLeafId: { 'leaf-a': 'scrollback' } },
|
||||
{ scrollbackRefsByLeafId: { 'leaf-a': 'ref-1' } },
|
||||
{
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
},
|
||||
{
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for (const mutation of mutations) {
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
store.getState().setTabLayout('tab-1', makeLayout(mutation))
|
||||
unsubscribe()
|
||||
expect(wakeups, `expected a publish for ${JSON.stringify(mutation)}`).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fire pane-ownership transfers for a bailed-out identical layout', () => {
|
||||
// A duplicate-pty layout normalizes to a transfer; replaying the already-normalized
|
||||
// snapshot must be inert, since normalization then finds nothing to move.
|
||||
const store = createTestStore()
|
||||
const duplicate = makeLayout({
|
||||
ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-a' }
|
||||
})
|
||||
store.getState().setTabLayout('tab-1', duplicate)
|
||||
const normalized = store.getState().terminalLayoutsByTabId['tab-1']
|
||||
expect(normalized.ptyIdsByLeafId).not.toEqual(duplicate.ptyIdsByLeafId)
|
||||
|
||||
store.getState().markTerminalPaneUnread('tab-1:leaf-a')
|
||||
const beforeUnread = { ...store.getState().unreadTerminalPanes }
|
||||
|
||||
for (let i = 0; i < REPEATS; i += 1) {
|
||||
store.getState().setTabLayout('tab-1', { ...normalized })
|
||||
}
|
||||
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-1']).toBe(normalized)
|
||||
expect(store.getState().unreadTerminalPanes).toEqual(beforeUnread)
|
||||
})
|
||||
|
||||
it('still deletes the layout on a clearing call, and bails when already absent', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setTabLayout('tab-1', makeLayout())
|
||||
|
||||
store.getState().setTabLayout('tab-1', null)
|
||||
expect('tab-1' in store.getState().terminalLayoutsByTabId).toBe(false)
|
||||
|
||||
let wakeups = 0
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
wakeups += 1
|
||||
})
|
||||
store.getState().setTabLayout('tab-1', null)
|
||||
unsubscribe()
|
||||
expect(wakeups).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,7 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity'
|
||||
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
|
||||
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
|
||||
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
import {
|
||||
@@ -1266,15 +1267,18 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
|
||||
setCacheTimerStartedAt: (key, ts) => {
|
||||
set((s) => {
|
||||
const next = { ...s.cacheTimerByKey, [key]: ts }
|
||||
// Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane.
|
||||
const colonIdx = key.indexOf(':')
|
||||
if (colonIdx !== -1) {
|
||||
const tabId = key.slice(0, colonIdx)
|
||||
const suffix = key.slice(colonIdx + 1)
|
||||
if (suffix !== 'seed') {
|
||||
delete next[`${tabId}:seed`]
|
||||
}
|
||||
const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1)
|
||||
const seedKey = colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null
|
||||
const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey
|
||||
// Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector.
|
||||
if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) {
|
||||
return s
|
||||
}
|
||||
const next = { ...s.cacheTimerByKey, [key]: ts }
|
||||
if (seedKey !== null) {
|
||||
delete next[seedKey]
|
||||
}
|
||||
return { cacheTimerByKey: next }
|
||||
})
|
||||
@@ -3649,20 +3653,27 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
setTabLayout: (tabId, layout) => {
|
||||
let ownershipTransfers: ReturnType<typeof resolveTerminalLayoutPtyOwnershipTransfers> = []
|
||||
set((s) => {
|
||||
const next = { ...s.terminalLayoutsByTabId }
|
||||
if (layout) {
|
||||
const normalized = normalizeTerminalLayoutPtyOwnership(layout)
|
||||
next[tabId] = normalized.snapshot
|
||||
if (normalized.changed) {
|
||||
ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(
|
||||
layout,
|
||||
normalized.snapshot
|
||||
)
|
||||
if (!layout) {
|
||||
if (!(tabId in s.terminalLayoutsByTabId)) {
|
||||
return s
|
||||
}
|
||||
} else {
|
||||
const next = { ...s.terminalLayoutsByTabId }
|
||||
delete next[tabId]
|
||||
return { terminalLayoutsByTabId: next }
|
||||
}
|
||||
const normalized = normalizeTerminalLayoutPtyOwnership(layout)
|
||||
// Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched.
|
||||
if (normalized.changed) {
|
||||
ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized.snapshot)
|
||||
}
|
||||
// Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep.
|
||||
const existing = s.terminalLayoutsByTabId[tabId]
|
||||
if (existing && terminalLayoutEqual(existing, normalized.snapshot)) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot }
|
||||
}
|
||||
return { terminalLayoutsByTabId: next }
|
||||
})
|
||||
transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user