fix(desktop): invalidate terminal overlay position on layout mutations

Signed-off-by: Ho Lim <subhoya@gmail.com>
This commit is contained in:
Ho Lim 2026-07-10 08:47:08 -07:00 committed by Brooklyn Nicholson
parent 7a5d534f5b
commit 5c8ac975e7
2 changed files with 65 additions and 0 deletions

View file

@ -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(<Harness />)
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))

View file

@ -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()