From 5c8ac975e753edec98db8c0809ad37366714f62c Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Fri, 10 Jul 2026 08:47:08 -0700 Subject: [PATCH] fix(desktop): invalidate terminal overlay position on layout mutations Signed-off-by: Ho Lim --- .../terminal/persistent.test.tsx | 51 +++++++++++++++++++ .../app/right-sidebar/terminal/persistent.tsx | 14 +++++ 2 files changed, 65 insertions(+) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx index 04fb3b9cfa79..97b672752668 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -13,6 +13,7 @@ vi.mock('./workspace', () => ({ })) let resizeObserverCallback: ResizeObserverCallback | null = null +let mutationObserverCallback: MutationCallback | null = null let root: Root | null = null let container: HTMLDivElement | null = null let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null @@ -125,6 +126,7 @@ describe('PersistentTerminal rect tracking', () => { setVisibility(false) installWindowStateBridge() resizeObserverCallback = null + mutationObserverCallback = null vi.stubGlobal( 'ResizeObserver', class { @@ -137,6 +139,18 @@ describe('PersistentTerminal rect tracking', () => { unobserve = vi.fn() } as unknown as typeof ResizeObserver ) + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: MutationCallback) { + mutationObserverCallback = callback + } + + disconnect = vi.fn() + observe = vi.fn() + takeRecords = vi.fn(() => []) + } as unknown as typeof MutationObserver + ) }) afterEach(() => { @@ -184,6 +198,43 @@ describe('PersistentTerminal rect tracking', () => { expect(raf.pending()).toBe(0) }) + it('remeasures when layout moves the slot without resizing it', () => { + const raf = installRaf() + let currentRect = rect(10, 20, 200, 100) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect) + + render() + + act(() => { + raf.runNext() + }) + + const overlay = container!.lastElementChild as HTMLElement + expect(overlay.style.top).toBe('10px') + expect(overlay.style.left).toBe('20px') + expect(raf.pending()).toBe(0) + + currentRect = rect(32, 48, 200, 100) + act(() => { + mutationObserverCallback?.([], {} as MutationObserver) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + + act(() => { + raf.runNext() + }) + + expect(overlay.style.top).toBe('32px') + expect(overlay.style.left).toBe('48px') + + act(() => { + raf.runNext() + }) + + expect(raf.pending()).toBe(0) + }) + it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => { const raf = installRaf() vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 4d1421f0be28..4165449e959f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -153,11 +153,24 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP : new ResizeObserver(() => { scheduleMeasure() }) + const positionObserver = + typeof MutationObserver === 'undefined' + ? null + : new MutationObserver(() => { + scheduleMeasure() + }) if (measure()) { scheduleMeasure() } observer?.observe(slot) + for (let node: HTMLElement | null = slot; node; node = node.parentElement) { + positionObserver?.observe(node, { + attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'], + attributes: true, + childList: true + }) + } window.addEventListener('resize', scheduleMeasure) window.addEventListener('scroll', scheduleMeasure, true) pauseController = createRendererLoopPauseController(handleVisibilityChange) @@ -166,6 +179,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP stopped = true cancelFrame() observer?.disconnect() + positionObserver?.disconnect() window.removeEventListener('resize', scheduleMeasure) window.removeEventListener('scroll', scheduleMeasure, true) pauseController?.dispose()