diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index 73ab8c9b46c..24cc2331f38 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -32,7 +32,10 @@ function flushAnimationFrames(timestamp = 16): void { } } -function createPane(): ManagedPaneInternal { +function createPane( + proposeDimensions: () => { cols: number; rows: number } = () => ({ cols: 80, rows: 24 }), + options: { rect?: { width: number; height: number } } = {} +): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never return { id: 1, @@ -43,7 +46,9 @@ function createPane(): ManagedPaneInternal { rows: 24 } as never, container: { dataset: {} } as never, - xtermContainer: {} as never, + xtermContainer: { + getBoundingClientRect: () => options.rect ?? ({ width: 800, height: 600 } as DOMRect) + } as never, linkTooltip: {} as never, terminalGpuAcceleration: 'auto', gpuRenderingEnabled: true, @@ -52,7 +57,7 @@ function createPane(): ManagedPaneInternal { hasComplexScriptOutput: false, fitAddon: { fit: vi.fn(), - proposeDimensions: vi.fn(() => ({ cols: 80, rows: 24 })) + proposeDimensions: vi.fn(proposeDimensions) } as never, fitResizeObserver: null, pendingObservedFitRafId: null, @@ -101,7 +106,7 @@ describe('attachPaneFitResizeObserver', () => { vi.restoreAllMocks() }) - it('coalesces repeated observer callbacks into a single fit per frame', () => { + it('coalesces repeated observer callbacks and fits once the grid is stable', () => { const pane = createPane() attachPaneFitResizeObserver(pane) @@ -116,6 +121,57 @@ describe('attachPaneFitResizeObserver', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + it('waits through a transient grid wobble before fitting', () => { + const proposed = [ + { cols: 80, rows: 24 }, + { cols: 81, rows: 24 }, + { cols: 81, rows: 24 } + ] + const pane = createPane(() => proposed.shift() ?? { cols: 81, rows: 24 }) + + attachPaneFitResizeObserver(pane) + mockResizeObservers[0]?.trigger() + + flushAnimationFrames() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + flushAnimationFrames() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('skips observer fits while the terminal has no visible geometry', () => { + const pane = createPane(() => ({ cols: 80, rows: 24 }), { + rect: { width: 0, height: 0 } + }) + + attachPaneFitResizeObserver(pane) + mockResizeObservers[0]?.trigger() + flushAnimationFrames() + + expect(requestAnimationFrame).not.toHaveBeenCalled() + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('throttles an endlessly unstable grid instead of fitting every frame', () => { + let cols = 80 + const pane = createPane(() => { + cols = cols === 80 ? 81 : 80 + return { cols, rows: 24 } + }) + + attachPaneFitResizeObserver(pane) + mockResizeObservers[0]?.trigger() + + for (let i = 0; i < 10; i += 1) { + flushAnimationFrames() + } + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.pendingObservedFitRafId).toBeNull() + }) + it('disconnects the observer and cancels any queued fit', () => { const pane = createPane() diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index da4a9b65eb8..de047249311 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -1,6 +1,34 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { safeFit } from './pane-tree-ops' +type ProposedDimensions = { + cols: number + rows: number +} + +const MAX_STABILITY_FRAMES = 8 + +function getProposedDimensions(pane: ManagedPaneInternal): ProposedDimensions | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function dimensionsEqual(a: ProposedDimensions | null, b: ProposedDimensions | null): boolean { + return a?.cols === b?.cols && a?.rows === b?.rows +} + +function terminalDimensionsEqual(pane: ManagedPaneInternal, dims: ProposedDimensions): boolean { + return pane.terminal.cols === dims.cols && pane.terminal.rows === dims.rows +} + +function hasVisibleFitGeometry(pane: ManagedPaneInternal): boolean { + const rect = pane.xtermContainer.getBoundingClientRect?.() + return !rect || (rect.width > 0 && rect.height > 0) +} + export function attachPaneFitResizeObserver(pane: ManagedPaneInternal): void { detachPaneFitResizeObserver(pane) @@ -12,12 +40,54 @@ export function attachPaneFitResizeObserver(pane: ManagedPaneInternal): void { if (pane.pendingObservedFitRafId !== null) { return } + if (!hasVisibleFitGeometry(pane)) { + return + } // Why: keep xterm fit work off the divider pointermove hot path and let // the browser coalesce drag-driven size changes the same way Superset does. - pane.pendingObservedFitRafId = requestAnimationFrame(() => { - pane.pendingObservedFitRafId = null - safeFit(pane) - }) + // + // Windows can report a short-lived one-column anchor/scrollbar wobble when + // the right sidebar is open. Requiring a stable proposed grid before fitting + // prevents Codex from receiving a rapid SIGWINCH loop and visibly vibrating. + let previous = getProposedDimensions(pane) + let frameCount = 0 + const waitForStableGrid = (): void => { + pane.pendingObservedFitRafId = requestAnimationFrame(() => { + if (!hasVisibleFitGeometry(pane)) { + pane.pendingObservedFitRafId = null + return + } + const next = getProposedDimensions(pane) + frameCount += 1 + + if (!next) { + pane.pendingObservedFitRafId = null + safeFit(pane) + return + } + + if (terminalDimensionsEqual(pane, next)) { + pane.pendingObservedFitRafId = null + return + } + + if (dimensionsEqual(previous, next)) { + pane.pendingObservedFitRafId = null + safeFit(pane) + return + } + + previous = next + if (frameCount >= MAX_STABILITY_FRAMES) { + pane.pendingObservedFitRafId = null + safeFit(pane) + return + } + + waitForStableGrid() + }) + } + waitForStableGrid() }) observer.observe(pane.xtermContainer)