From 36ab59d640a0ef098a34373a40fe2b873a2963aa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:41:05 -0700 Subject: [PATCH] Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122) Co-authored-by: Orca --- .../components/terminal-pane/TerminalPane.tsx | 5 +- .../terminal-webgl-paste-recovery.test.ts | 34 +- .../terminal-webgl-paste-recovery.ts | 17 +- .../use-terminal-pane-context-menu.ts | 5 +- .../use-terminal-pane-global-effects.test.ts | 24 ++ .../use-terminal-pane-global-effects.ts | 11 +- .../pane-manager-registry.test.ts | 44 ++ .../lib/pane-manager/pane-manager-registry.ts | 28 ++ .../src/lib/pane-manager/pane-manager.ts | 5 + ...ating-workspace-shared-glyph-atlas.spec.ts | 403 ++++++++++++++++++ 10 files changed, 551 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-registry.ts create mode 100644 tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8d674c699..2d6b8dff3 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1255,10 +1255,7 @@ export default function TerminalPane({ pasteText: (text, options) => { pasteTerminalText(pane.terminal, text, options) if (options?.forceBracketedPaste) { - const manager = managerRef.current - if (manager) { - scheduleImagePasteWebglAtlasRecovery(manager) - } + scheduleImagePasteWebglAtlasRecovery() } }, onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts index ed5472efd..61b93ed1e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts @@ -1,8 +1,24 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { + registerLivePaneManager, + unregisterLivePaneManager +} from '@/lib/pane-manager/pane-manager-registry' import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' describe('terminal image paste WebGL recovery', () => { + const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] + + function registerManager(): { resetWebglTextureAtlases: Mock<() => void> } { + const manager = { resetWebglTextureAtlases: vi.fn<() => void>() } + registerLivePaneManager(manager) + registeredManagers.push(manager) + return manager + } + afterEach(() => { + for (const manager of registeredManagers.splice(0)) { + unregisterLivePaneManager(manager) + } vi.useRealTimers() vi.unstubAllGlobals() }) @@ -17,13 +33,17 @@ describe('terminal image paste WebGL recovery', () => { return rafCallbacks.length }) ) - const manager = { resetWebglTextureAtlases: vi.fn() } + // Why: resets go through the live-manager registry so every terminal + // sharing the glyph atlas rebuilds, not just the pasted-into pane. + const manager = registerManager() + const otherManager = registerManager() - scheduleImagePasteWebglAtlasRecovery(manager) + scheduleImagePasteWebglAtlasRecovery() expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() rafCallbacks[0]?.(0) expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(otherManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(120) expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) @@ -34,9 +54,9 @@ describe('terminal image paste WebGL recovery', () => { it('falls back to a timeout when animation frames are unavailable', () => { vi.useFakeTimers() vi.stubGlobal('requestAnimationFrame', undefined) - const manager = { resetWebglTextureAtlases: vi.fn() } + const manager = registerManager() - scheduleImagePasteWebglAtlasRecovery(manager) + scheduleImagePasteWebglAtlasRecovery() expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() vi.advanceTimersByTime(0) @@ -57,8 +77,10 @@ describe('terminal image paste WebGL recovery', () => { throw new Error('pane disposed') }) } + registerLivePaneManager(manager) + registeredManagers.push(manager) - expect(() => scheduleImagePasteWebglAtlasRecovery(manager)).not.toThrow() + expect(() => scheduleImagePasteWebglAtlasRecovery()).not.toThrow() expect(() => vi.runAllTimers()).not.toThrow() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts index 8f0827398..8975913ae 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts @@ -1,6 +1,4 @@ -type TerminalWebglRecoveryManager = { - resetWebglTextureAtlases: () => void -} +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' const IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS = [120, 500] @@ -12,20 +10,23 @@ function scheduleNextFrame(callback: () => void): void { globalThis.setTimeout(callback, 0) } -function resetAtlas(manager: TerminalWebglRecoveryManager): void { +function resetAtlases(): void { try { - manager.resetWebglTextureAtlases() + // Why: the glyph atlas is shared across same-config terminals, so the + // recovery reset must rebuild every live terminal's render model — a + // single-manager reset would garble the others. + resetAllTerminalWebglAtlases() } catch { /* ignore - terminal pane may have unmounted after paste */ } } -export function scheduleImagePasteWebglAtlasRecovery(manager: TerminalWebglRecoveryManager): void { +export function scheduleImagePasteWebglAtlasRecovery(): void { // Why: Claude Code redraws its image chip immediately after bracketed paste, // and xterm WebGL atlas corruption can appear after that redraw without a // context-loss event. A few cheap resets cover the post-paste paint window. - scheduleNextFrame(() => resetAtlas(manager)) + scheduleNextFrame(() => resetAtlases()) for (const delayMs of IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS) { - globalThis.setTimeout(() => resetAtlas(manager), delayMs) + globalThis.setTimeout(() => resetAtlases(), delayMs) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index c529392cc..9e5938f9e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -171,10 +171,7 @@ export function useTerminalPaneContextMenu({ pasteText: (text, options) => { pasteTerminalText(pane.terminal, text, options) if (options?.forceBracketedPaste) { - const manager = managerRef.current - if (manager) { - scheduleImagePasteWebglAtlasRecovery(manager) - } + scheduleImagePasteWebglAtlasRecovery() } }, onImagePasteError: (error) => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 245165b5b..355010875 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -2,6 +2,10 @@ import type * as ReactModule from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal' +import { + registerLivePaneManager, + unregisterLivePaneManager +} from '@/lib/pane-manager/pane-manager-registry' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' const mocks = vi.hoisted(() => ({ @@ -135,6 +139,16 @@ function useMountForFileDrop( } describe('useTerminalPaneGlobalEffects', () => { + // Why: the live-manager registry is module-global; unregister in afterEach + // so a failed assertion cannot leak fake managers into later tests. + const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] + + function registerManagerForReset(manager: T): T { + registerLivePaneManager(manager) + registeredManagers.push(manager) + return manager + } + beforeEach(() => { resetHookRefs() vi.clearAllMocks() @@ -154,6 +168,9 @@ describe('useTerminalPaneGlobalEffects', () => { }) afterEach(() => { + for (const manager of registeredManagers.splice(0)) { + unregisterLivePaneManager(manager) + } delete (globalThis as unknown as { window?: unknown }).window delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver }) @@ -189,6 +206,10 @@ describe('useTerminalPaneGlobalEffects', () => { }) mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus')) + // Why: the resume path resets atlases through the live-manager registry + // (shared glyph atlas), so the fake manager must be registered to observe + // its reset in the ordering assertion. + registerManagerForReset(manager) const isActiveRef = { current: false } const isVisibleRef = { current: false } beginHookRender() @@ -328,6 +349,9 @@ describe('useTerminalPaneGlobalEffects', () => { getActivePane: vi.fn(() => null) } + // Why: focus recovery resets every registered manager (shared glyph + // atlas), so the fake manager observes the reset through the registry. + registerManagerForReset(manager) beginHookRender() useTerminalPaneGlobalEffects({ tabId: 'tab-1', diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 902004d5a..7b6b435b6 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -7,6 +7,7 @@ import { type PasteTerminalTextDetail } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' @@ -118,7 +119,9 @@ export function useTerminalPaneGlobalEffects({ restoreScrollStateAfterLayout(pane.terminal, position) } } - manager.resetWebglTextureAtlases() + // Why: this clear wipes the glyph atlas shared with other same-config + // terminals; the global reset rebuilds their render models too. + resetAllTerminalWebglAtlases() }) wasVisibleRef.current = true applyPendingFollowOutputRequests() @@ -143,11 +146,13 @@ export function useTerminalPaneGlobalEffects({ const onFocus = (): void => { // Why: WebGL atlas corruption does not always raise context loss; window // focus regain is a low-cost recovery point for agent TUI glyph damage. - managerRef.current?.resetWebglTextureAtlases() + // Reset globally — a per-manager reset clears the shared glyph atlas + // under every other visible same-config terminal and garbles it. + resetAllTerminalWebglAtlases() } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) - }, [isActive, isVisible, managerRef]) + }, [isActive, isVisible]) useEffect(() => { const manager = managerRef.current diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts new file mode 100644 index 000000000..ed5888727 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { + registerLivePaneManager, + resetAllTerminalWebglAtlases, + unregisterLivePaneManager +} from './pane-manager-registry' + +describe('pane manager registry', () => { + // Why: the registry is module-global; unregister in afterEach so a failed + // assertion cannot leak fake managers into later tests. + const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] + + function registerManager(): { resetWebglTextureAtlases: Mock<() => void> } { + const manager = { resetWebglTextureAtlases: vi.fn<() => void>() } + registerLivePaneManager(manager) + registeredManagers.push(manager) + return manager + } + + afterEach(() => { + for (const manager of registeredManagers.splice(0)) { + unregisterLivePaneManager(manager) + } + }) + + it('resets atlases on every registered manager', () => { + const first = registerManager() + const second = registerManager() + + resetAllTerminalWebglAtlases() + + expect(first.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(second.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + + it('stops resetting managers after they unregister', () => { + const manager = registerManager() + unregisterLivePaneManager(manager) + + resetAllTerminalWebglAtlases() + + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts new file mode 100644 index 000000000..da33aceb5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -0,0 +1,28 @@ +type AtlasResettablePaneManager = { + resetWebglTextureAtlases(): void +} + +const liveManagers = new Set() + +export function registerLivePaneManager(manager: AtlasResettablePaneManager): void { + liveManagers.add(manager) +} + +export function unregisterLivePaneManager(manager: AtlasResettablePaneManager): void { + liveManagers.delete(manager) +} + +/** + * Resets the WebGL glyph atlases of every live pane manager, not just one. + * + * Why: @xterm/addon-webgl keeps a module-global atlas cache, so terminals with + * identical font configs share one glyph texture atlas. Clearing it through a + * single manager invalidates the cached glyph coordinates of every other + * sharing terminal without rebuilding their render models, which paints them + * as garbled glyphs. Recovery resets must therefore rebuild all terminals. + */ +export function resetAllTerminalWebglAtlases(): void { + for (const manager of liveManagers) { + manager.resetWebglTextureAtlases() + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index fc5aab656..53bd10f44 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -36,6 +36,7 @@ import { suspendPaneRendering } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' import { PaneIdentityRegistry } from './pane-identity-registry' import { closeManagedPane, splitManagedPane } from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' @@ -62,6 +63,9 @@ export class PaneManager { this.root = root this.options = options this.renderingSuspended = options.initialRenderingSuspended === true + // Why: atlas recovery must reach every live manager — see + // resetAllTerminalWebglAtlases for the shared-atlas rationale. + registerLivePaneManager(this) } createInitialPane(opts?: { focus?: boolean; leafId?: string }): ManagedPane { @@ -289,6 +293,7 @@ export class PaneManager { destroy(): void { this.destroyed = true + unregisterLivePaneManager(this) cancelActivePaneDrag(this.dragState) this.cancelPendingPaneReparentFrames() for (const pane of this.panes.values()) { diff --git a/tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts b/tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts new file mode 100644 index 000000000..dc2ff2336 --- /dev/null +++ b/tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts @@ -0,0 +1,403 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { sendToTerminal, waitForActivePanePtyId } from './helpers/terminal' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// e2e specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' + +// Why: the floating panel toggles via this window event +// (src/renderer/src/lib/floating-terminal.ts); dispatching it exercises the +// same code path as the status bar button and the keyboard shortcut. +const TOGGLE_EVENT = 'orca-toggle-floating-terminal' + +// Why: a silent foreground command blocks the shell so no prompt framework +// (e.g. async p10k segments) repaints while screenshots are compared. +const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r' + +// Why: distinct glyph populations per terminal. After a shared-atlas clear the +// pages refill in first-use order, so terminals with different content put +// different glyphs at the coordinates a stale render model still points to. +const WORKSPACE_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~' +const FLOATING_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' + +async function dumpFloatingDiagnostics(page: Page, label: string): Promise { + const probe = await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree?.[worktreeId] ?? [] + return tabs.map((tab) => ({ + tabId: tab.id, + diagnostics: window.__paneManagers?.get(tab.id)?.getRenderingDiagnostics?.() ?? null + })) + }, FLOATING_WORKTREE_ID) + console.log(`[shared-atlas] ${label}: ${JSON.stringify(probe)}`) +} + +async function setSharedAtlasSettings(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + const state = store?.getState() + if (!store || !state?.settings) { + throw new Error('Store unavailable') + } + store.setState({ + settings: { + ...state.settings, + floatingTerminalEnabled: true, + terminalGpuAcceleration: 'on' + } + }) + }) +} + +async function ensureFloatingTabs(page: Page, count: number): Promise { + const tabIds = await page.evaluate( + ({ worktreeId, wanted }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + while ((store.getState().tabsByWorktree[worktreeId] ?? []).length < wanted) { + store.getState().createTab(worktreeId, undefined, undefined, { activate: false }) + } + const tabs = store.getState().tabsByWorktree[worktreeId] ?? [] + store.getState().activateTab(tabs[0].id) + return tabs.slice(0, wanted).map((tab) => tab.id) + }, + { worktreeId: FLOATING_WORKTREE_ID, wanted: count } + ) + // Why: the toggle event listener closes over floatingTerminalEnabled; wait + // for the (lazy) panel to mount so React has committed the enabled state + // before the toggle event is dispatched, otherwise the event is dropped. + await page.waitForFunction( + (panelSelector) => Boolean(document.querySelector(panelSelector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + return tabIds +} + +async function activateFloatingTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__store?.getState().activateTab(id) + }, tabId) +} + +async function toggleFloatingPanel(page: Page, open: boolean): Promise { + await page.evaluate((eventName) => { + window.dispatchEvent(new Event(eventName)) + }, TOGGLE_EVENT) + await (open + ? expect(page.locator(PANEL_SELECTOR)).toBeVisible() + : expect(page.locator(PANEL_SELECTOR)).toBeHidden()) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + // Why: a pane that mounted before the GPU setting landed needs the manager + // call too — mirrors forceWebgl in terminal-image-paste-webgl-recovery.spec. + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, tabId) + // Why: getPanes()/getActivePane() return a public projection without + // webglAddon; getRenderingDiagnostics() is the supported way to observe + // whether WebGL is attached. + return page + .waitForFunction( + (id) => { + const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((diagnostic) => diagnostic.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() + const ptyId = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId) + if (!ptyId) { + throw new Error(`Pane for tab ${tabId} has no PTY binding`) + } + return ptyId +} + +async function writeStaticContent( + page: Page, + tabId: string, + marker: string, + glyphRow: string +): Promise { + await page.evaluate( + async ({ id, content }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`Pane unavailable for tab ${id}`) + } + await new Promise((resolve) => pane.terminal.write(content, resolve)) + }, + { + id: tabId, + // Why: clear screen + scrollback and hide the cursor so screenshots are + // time-invariant, then render dense mixed glyphs so the shared WebGL + // atlas region this terminal depends on is populated. Default-colored + // ASCII only: a small glyph population avoids atlas page merges, whose + // one-shot clear-model flag would let a stale renderer accidentally + // self-heal and mask the corruption this spec reproduces. + content: `\x1b[2J\x1b[3J\x1b[H\x1b[?25l${Array.from( + { length: 14 }, + (_, row) => `${marker} row ${row} | ${glyphRow} |\r\n` + ).join('')}` + } + ) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function refreshTerminalOnTab(page: Page, tabId: string): Promise { + // Why: stands in for the steady output stream of a real agent session. The + // workspace shell is blocked, so without a repaint trigger the stale-model + // corruption would stay latent and the comparison would prove nothing. + await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + for (const pane of manager?.getPanes?.() ?? []) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } + }, tabId) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +/** + * True when both tabs' WebGL renderers draw from the same glyph texture atlas. + * @xterm/addon-webgl keeps a module-global atlas cache keyed by font config, + * so terminals with identical settings share pages — the precondition for the + * cross-terminal corruption this spec reproduces. + */ +async function tabsShareGlyphAtlas(page: Page, tabIdA: string, tabIdB: string): Promise { + return page.evaluate( + ({ a, b }) => { + const atlasCanvasOf = (tabId: string): HTMLCanvasElement | null => { + const manager = window.__paneManagers?.get(tabId) + // Why: the public pane projection omits webglAddon; reach the internal + // pane map (runtime-visible) to compare addon.textureAtlas identity. + const internalPanes = ( + manager as unknown as + | { panes?: Map } + | undefined + )?.panes + const pane = internalPanes ? [...internalPanes.values()][0] : undefined + return pane?.webglAddon?.textureAtlas ?? null + } + const atlasA = atlasCanvasOf(a) + return Boolean(atlasA) && atlasA === atlasCanvasOf(b) + }, + { a: tabIdA, b: tabIdB } + ) +} + +async function resetAtlasOnTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.() + }, tabId) +} + +function workspaceScreenLocator(page: Page, ptyId: string): ReturnType { + return page.locator(`[data-pty-id="${ptyId}"] .xterm-screen`).first() +} + +async function screenshotWorkspaceTerminal(page: Page, ptyId: string): Promise { + const screen = workspaceScreenLocator(page, ptyId) + await expect(screen).toBeVisible() + return screen.screenshot({ animations: 'disabled' }) +} + +async function captureStableWorkspaceShot(page: Page, ptyId: string): Promise { + // Why: two consecutive identical captures prove the surface is byte-stable + // before screenshot-equality comparisons begin. + let previous = await screenshotWorkspaceTerminal(page, ptyId) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screenshotWorkspaceTerminal(page, ptyId) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error('Workspace terminal surface did not stabilize for a screenshot') +} + +async function settleAtlasActivity(page: Page): Promise { + // Why: atlas warm-up re-rasterization runs in idle callbacks and scheduled + // recovery resets fire up to 500ms after their trigger; wait past both. + await page.waitForTimeout(800) +} + +type SharedAtlasScenario = { + workspaceTabId: string + workspacePtyId: string + floatingTabIds: string[] + baseline: Buffer +} + +/** + * Stage: a visible workspace terminal with stable static content, plus two + * floating workspace terminal tabs whose WebGL renderers share its glyph + * atlas. Returns a workspace baseline screenshot taken with the panel closed + * (the panel can overlap the workspace terminal region). + */ +async function setUpSharedAtlasScenario(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await setSharedAtlasSettings(page) + + const workspaceTabId = await page.evaluate(() => { + const state = window.__store?.getState() + return state?.activeTabId ?? null + }) + if (!workspaceTabId) { + return null + } + const workspacePtyId = await waitForActivePanePtyId(page) + if (!(await waitForWebglOnTab(page, workspaceTabId))) { + console.log('[shared-atlas] workspace terminal never attached WebGL') + return null + } + await sendToTerminal(page, workspacePtyId, SILENT_FOREGROUND_COMMAND) + // Why: give the shell a beat to echo the command and start blocking before + // the screen is cleared; later captures verify stability explicitly. + await page.waitForTimeout(1_000) + await writeStaticContent(page, workspaceTabId, 'WORKSPACE', WORKSPACE_GLYPH_ROW) + + const floatingTabIds = await ensureFloatingTabs(page, 2) + await toggleFloatingPanel(page, true) + if (!(await waitForWebglOnTab(page, floatingTabIds[0]))) { + await dumpFloatingDiagnostics(page, 'active floating tab never attached WebGL') + return null + } + for (const tabId of floatingTabIds) { + const ptyId = await waitForPanePtyIdOnTab(page, tabId) + await sendToTerminal(page, ptyId, SILENT_FOREGROUND_COMMAND) + } + await page.waitForTimeout(1_000) + // Why: the hidden second tab accepts writes too — its buffer paints on + // resume, refilling the cleared shared atlas with a different glyph layout. + for (const tabId of floatingTabIds) { + await writeStaticContent(page, tabId, 'FLOATING', FLOATING_GLYPH_ROW) + } + + if (!(await tabsShareGlyphAtlas(page, workspaceTabId, floatingTabIds[0]))) { + console.log('[shared-atlas] workspace and floating terminals do not share an atlas') + return null + } + + // Why: glyphs rasterized during startup can predate web font readiness; a + // clean rebuild here makes the baseline byte-identical to any later + // re-rasterization, so equality is a sound "intact" oracle. + await resetAtlasOnTab(page, workspaceTabId) + await settleAtlasActivity(page) + + // Why: the panel overlay can cover the workspace terminal region, so all + // workspace screenshots are taken with the panel closed. Closing only + // suspends the floating renderer; it never mutates the shared atlas. + await toggleFloatingPanel(page, false) + // Why: closing the panel can refit the workspace terminal; rebuild once more + // so the baseline model/atlas state matches the post-trigger capture path. + await resetAtlasOnTab(page, workspaceTabId) + const baseline = await captureStableWorkspaceShot(page, workspacePtyId) + + return { workspaceTabId, workspacePtyId, floatingTabIds, baseline } +} + +async function captureWorkspaceAfterTrigger( + page: Page, + scenario: SharedAtlasScenario +): Promise { + await settleAtlasActivity(page) + // Why: a real agent session repaints continuously; the blocked test shell + // does not, so force the equivalent full repaint before comparing. + await refreshTerminalOnTab(page, scenario.workspaceTabId) + return captureStableWorkspaceShot(page, scenario.workspacePtyId) +} + +test.describe('floating workspace shared glyph atlas @headful', () => { + test('switching floating workspace tabs keeps workspace terminal glyphs intact', async ({ + orcaPage + }, testInfo) => { + // Why: xterm WebGL terminals with identical font configs share one glyph + // texture atlas. The floating tab switch resumes a hidden renderer, whose + // atlas reset clears those shared pages; unless every sharing terminal + // rebuilds its render model too, the visible workspace terminal keeps + // stale glyph coordinates and paints garbage (the bug this guards). + const scenario = await setUpSharedAtlasScenario(orcaPage) + test.skip(!scenario, 'WebGL inactive or terminals do not share a glyph atlas') + const { baseline, floatingTabIds } = scenario! + + await toggleFloatingPanel(orcaPage, true) + await activateFloatingTab(orcaPage, floatingTabIds[1]) + // Why: the switched-to tab attaching WebGL proves the suspend/resume + // (and with it the atlas reset trigger) actually ran. + expect( + await waitForWebglOnTab(orcaPage, floatingTabIds[1]), + 'switched-to floating tab should resume WebGL' + ).toBe(true) + await settleAtlasActivity(orcaPage) + await toggleFloatingPanel(orcaPage, false) + + const afterSwitch = await captureWorkspaceAfterTrigger(orcaPage, scenario!) + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + await testInfo.attach('after-tab-switch', { body: afterSwitch, contentType: 'image/png' }) + console.log(`[shared-atlas] tabSwitchIntact=${afterSwitch.equals(baseline)}`) + + expect( + afterSwitch.equals(baseline), + 'workspace terminal must render identically after floating tab switching' + ).toBe(true) + }) + + test('reopening the floating workspace keeps workspace terminal glyphs intact', async ({ + orcaPage + }, testInfo) => { + // Why: reopening the panel resumes its terminal, whose atlas reset clears + // the shared pages just like a tab switch — the other user flow that + // garbled visible workspace terminals before resets went global. + const scenario = await setUpSharedAtlasScenario(orcaPage) + test.skip(!scenario, 'WebGL inactive or terminals do not share a glyph atlas') + const { baseline } = scenario! + + await toggleFloatingPanel(orcaPage, true) + await settleAtlasActivity(orcaPage) + await toggleFloatingPanel(orcaPage, false) + + const afterReopen = await captureWorkspaceAfterTrigger(orcaPage, scenario!) + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + await testInfo.attach('after-reopen', { body: afterReopen, contentType: 'image/png' }) + console.log(`[shared-atlas] reopenIntact=${afterReopen.equals(baseline)}`) + + expect( + afterReopen.equals(baseline), + 'workspace terminal must render identically after a floating panel reopen' + ).toBe(true) + }) +})