Recover floating workspace terminal WebGL atlas on reopen (#5069)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-09 23:19:07 -04:00 committed by GitHub
parent ad66692e7c
commit 1cbc40b2aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 656 additions and 20 deletions

View File

@ -2,6 +2,7 @@
* React/store environment directly so close and bootstrap behavior can be
* asserted without mounting the full Electron renderer. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RESET_TERMINAL_WEBGL_ATLAS_EVENT } from '@/constants/terminal'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
@ -593,6 +594,9 @@ describe('FloatingTerminalPanel close behavior', () => {
}
vi.stubGlobal('window', {
addEventListener: vi.fn(),
// Why: the open-transition effect dispatches the WebGL atlas recovery
// event; the stubbed window needs a sink for it.
dispatchEvent: vi.fn(),
api: {
app: {
getFloatingMarkdownDirectory: mocks.getFloatingMarkdownDirectory,
@ -952,6 +956,32 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab')
})
it('dispatches WebGL atlas recovery for the active terminal on reopen', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
await renderPanel(false)
runEffects()
vi.mocked(window.dispatchEvent).mockClear()
const countResetEvents = (): CustomEvent<{ tabId?: string }>[] =>
vi
.mocked(window.dispatchEvent)
.mock.calls.map(([event]) => event as CustomEvent<{ tabId?: string }>)
.filter((event) => event.type === RESET_TERMINAL_WEBGL_ATLAS_EVENT)
await renderPanel(true)
runEffects()
const resetEvents = countResetEvents()
expect(resetEvents).toHaveLength(1)
expect(resetEvents[0]?.detail?.tabId).toBe('tab-1')
// Why: re-renders while the panel stays open must not re-clear the atlas.
await renderPanel(true)
runEffects()
expect(countResetEvents()).toHaveLength(1)
})
it('routes titlebar Cmd+T to the floating workspace', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)

View File

@ -19,6 +19,10 @@ import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import TabBar from '@/components/tab-bar/TabBar'
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import {
RESET_TERMINAL_WEBGL_ATLAS_EVENT,
type ResetTerminalWebglAtlasDetail
} from '@/constants/terminal'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
@ -528,6 +532,31 @@ export function FloatingTerminalPanel({
focusTerminalTabSurface(activeTerminalId)
}, [activeTerminalId, open])
const wasOpenForAtlasRecoveryRef = useRef(false)
useEffect(() => {
if (!open) {
wasOpenForAtlasRecoveryRef.current = false
return
}
if (wasOpenForAtlasRecoveryRef.current) {
return
}
wasOpenForAtlasRecoveryRef.current = true
if (!activeTerminalId) {
return
}
// Why: closing the panel only hides it with CSS, so the active terminal
// keeps a live WebGL context while hidden and reopening never flips
// TerminalPane visibility. A glyph atlas corrupted while hidden (no
// context-loss event) would otherwise stay garbled until another
// recovery trigger such as window refocus.
window.dispatchEvent(
new CustomEvent<ResetTerminalWebglAtlasDetail>(RESET_TERMINAL_WEBGL_ATLAS_EVENT, {
detail: { tabId: activeTerminalId }
})
)
}, [activeTerminalId, open])
useEffect(() => {
if (!open || hasVisibleFloatingTabs) {
return
@ -653,7 +682,10 @@ export function FloatingTerminalPanel({
return
}
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, {
title: translate("auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17", "New Browser Tab"),
title: translate(
'auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17',
'New Browser Tab'
),
focusAddressBar: true,
targetGroupId: activeGroup?.id
})
@ -1386,7 +1418,11 @@ export function FloatingTerminalPanel({
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate("auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24", "Loading editor...")}</div>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24',
'Loading editor...'
)}
</div>
}
>
{/* Why: floating workspace markdown is scratch/local context,
@ -1416,16 +1452,25 @@ export function FloatingTerminalPanel({
) : null}
</div>
</div>
{showOrchestrationSetup && activeTabType === "terminal" ? (
{showOrchestrationSetup && activeTabType === 'terminal' ? (
<div
className="absolute right-4 bottom-4 z-10 w-[280px] rounded-md border border-border/60 bg-card/95 p-3 text-card-foreground shadow-xs"
data-floating-terminal-no-drag
>
<div className="space-y-2">
<div className="space-y-0.5">
<p className="text-sm font-medium">{translate("auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e", "Enable orchestration")}</p>
<p className="text-sm font-medium">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e',
'Enable orchestration'
)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate("auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b", "Set up the Orca CLI and agent skill so agents can coordinate through Orca.")}</p>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b',
'Set up the Orca CLI and agent skill so agents can coordinate through Orca.'
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
@ -1435,7 +1480,11 @@ export function FloatingTerminalPanel({
className="flex-1"
onClick={dismissOrchestrationSetup}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.adc281394d", "Dismiss")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.adc281394d',
'Dismiss'
)}
</Button>
<Button
type="button"
variant="default"
@ -1443,7 +1492,11 @@ export function FloatingTerminalPanel({
className="flex-1"
onClick={() => setOrchestrationDialogOpen(true)}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f", "Enable")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f',
'Enable'
)}
</Button>
</div>
</div>
</div>
@ -1470,11 +1523,23 @@ export function FloatingTerminalPanel({
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">{translate("auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a", "Unsaved Changes")}</DialogTitle>
<DialogTitle className="text-sm">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a',
'Unsaved Changes'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{saveDialogFile
? translate("auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52", "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", { value0: saveDialogFile.relativePath.split('/').pop() })
: translate("auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5", "This file has unsaved changes.")}
? translate(
'auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52',
'"{{value0}}" has unsaved changes. Do you want to save before closing?',
{ value0: saveDialogFile.relativePath.split('/').pop() }
)
: translate(
'auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5',
'This file has unsaved changes.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
@ -1484,16 +1549,28 @@ export function FloatingTerminalPanel({
size="sm"
onClick={handleFloatingSaveDialogCancel}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4", "Cancel")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4',
'Cancel'
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFloatingSaveDialogDiscard}
>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3", "Don't Save")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3',
"Don't Save"
)}
</Button>
<Button type="button" size="sm" onClick={handleFloatingSaveDialogSave}>
{translate("auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5", "Save")}</Button>
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5',
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -1541,7 +1618,12 @@ function FloatingTerminalEmptyState({
onClick={onNewTerminal}
>
<TerminalSquare className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9", "New Terminal")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9',
'New Terminal'
)}
</span>
<FloatingEmptyStateShortcut keys={newTerminalShortcutKeys} />
</Button>
<Button
@ -1551,7 +1633,12 @@ function FloatingTerminalEmptyState({
onClick={onNewMarkdown}
>
<FileText className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.629528690b", "New Markdown Note")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.629528690b',
'New Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={newMarkdownShortcutKeys} />
</Button>
<Button
@ -1561,7 +1648,12 @@ function FloatingTerminalEmptyState({
onClick={onOpenMarkdown}
>
<FileText className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5", "Open Markdown Note")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5',
'Open Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={openMarkdownShortcutKeys} />
</Button>
<Button
@ -1571,7 +1663,12 @@ function FloatingTerminalEmptyState({
onClick={onNewBrowser}
>
<Globe className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.8b07759314", "New Browser")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.8b07759314',
'New Browser'
)}
</span>
<FloatingEmptyStateShortcut keys={newBrowserShortcutKeys} />
</Button>
<Button
@ -1581,7 +1678,12 @@ function FloatingTerminalEmptyState({
onClick={onClose}
>
<Minus className="size-3.5 opacity-90" />
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b", "Minimize")}</span>
<span className="truncate text-left leading-none">
{translate(
'auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b',
'Minimize'
)}
</span>
<FloatingEmptyStateShortcut keys={closeShortcutKeys} />
</Button>
</div>

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: these hook tests share a mocked React lifecycle harness with global event cases. */
import type * as ReactModule from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { RESET_TERMINAL_WEBGL_ATLAS_EVENT, SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
const mocks = vi.hoisted(() => ({
@ -359,6 +359,42 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
})
it('clears WebGL texture atlases for a matching reset-atlas event', () => {
const { manager } = useMountForFileDrop()
const resetListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([eventName]) => eventName === RESET_TERMINAL_WEBGL_ATLAS_EVENT)
expect(resetListener).toBeDefined()
const listener = resetListener?.[1]
if (typeof listener !== 'function') {
throw new Error('expected reset-atlas listener')
}
manager.resetWebglTextureAtlases.mockClear()
listener(new CustomEvent(RESET_TERMINAL_WEBGL_ATLAS_EVENT, { detail: { tabId: 'tab-1' } }))
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
})
it('ignores reset-atlas events for another terminal tab', () => {
const { manager } = useMountForFileDrop()
const resetListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([eventName]) => eventName === RESET_TERMINAL_WEBGL_ATLAS_EVENT)
expect(resetListener).toBeDefined()
const listener = resetListener?.[1]
if (typeof listener !== 'function') {
throw new Error('expected reset-atlas listener')
}
manager.resetWebglTextureAtlases.mockClear()
listener(new CustomEvent(RESET_TERMINAL_WEBGL_ATLAS_EVENT, { detail: { tabId: 'tab-2' } }))
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
})
it('ignores terminal file drops for another terminal tab', () => {
const { onFileDrop } = useMountForFileDrop()

View File

@ -2,9 +2,11 @@ import { useEffect, useRef } from 'react'
import {
FOCUS_TERMINAL_PANE_EVENT,
PASTE_TERMINAL_TEXT_EVENT,
RESET_TERMINAL_WEBGL_ATLAS_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
type FocusTerminalPaneDetail,
type PasteTerminalTextDetail
type PasteTerminalTextDetail,
type ResetTerminalWebglAtlasDetail
} from '@/constants/terminal'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
@ -204,6 +206,21 @@ export function useTerminalPaneGlobalEffects({
return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
}, [tabId, managerRef, scheduleFollowOutputIfNeeded])
useEffect(() => {
const onResetAtlas = (event: Event): void => {
const detail = (event as CustomEvent<ResetTerminalWebglAtlasDetail | undefined>).detail
if (!detail?.tabId || detail.tabId !== tabId) {
return
}
// Why: WebGL atlas corruption can occur with no context-loss event while
// a CSS-hidden surface (floating workspace) holds a live context; the
// owning surface dispatches this when it becomes visible again.
managerRef.current?.resetWebglTextureAtlases()
}
window.addEventListener(RESET_TERMINAL_WEBGL_ATLAS_EVENT, onResetAtlas)
return () => window.removeEventListener(RESET_TERMINAL_WEBGL_ATLAS_EVENT, onResetAtlas)
}, [tabId, managerRef])
useEffect(() => {
const onPasteText = (event: Event): void => {
const detail = (event as CustomEvent<PasteTerminalTextDetail | undefined>).detail

View File

@ -7,6 +7,10 @@ export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane'
export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-terminal-pane-split'
export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane'
export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree'
// Why: surfaces that show/hide terminals with CSS only (floating workspace)
// never flip TerminalPane visibility, so they need an explicit trigger for
// WebGL glyph-atlas recovery when they become visible again.
export const RESET_TERMINAL_WEBGL_ATLAS_EVENT = 'orca-reset-terminal-webgl-atlas'
// Why: sidebar open/close is an instantaneous width change. If we wait for
// the ResizeObserver rAF (and the 150ms debounced global fit) to catch up,
@ -42,6 +46,10 @@ export type PasteTerminalTextDetail = {
text: string
}
export type ResetTerminalWebglAtlasDetail = {
tabId: string
}
export type SplitTerminalPaneDetail = {
tabId: string
paneRuntimeId: number

View File

@ -0,0 +1,443 @@
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { sendToTerminal } 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: matches the local-cast pattern used by terminal-image-paste-webgl-recovery.spec;
// a global Window augmentation would leak into every spec in the suite.
type RecoveryCounterWindow = typeof window & {
__floatingManagerResets?: number
__floatingRenderResumes?: number
}
async function enableFloatingWorkspaceWithWebgl(page: Page): Promise<void> {
await page.evaluate((worktreeId) => {
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'
}
})
const tabs = store.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.length === 0) {
const tab = store.getState().createTab(worktreeId, undefined, undefined, {
activate: false
})
store.getState().activateTab(tab.id)
}
}, FLOATING_WORKTREE_ID)
// 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 }
)
}
async function waitForFloatingPanePtyId(page: Page): Promise<string> {
await expect
.poll(
() =>
page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.container?.dataset?.ptyId ?? null
}, FLOATING_WORKTREE_ID),
{
timeout: 15_000,
message: 'Floating terminal pane did not receive a PTY binding'
}
)
.not.toBeNull()
const ptyId = await page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.container?.dataset?.ptyId ?? null
}, FLOATING_WORKTREE_ID)
if (!ptyId) {
throw new Error('Floating terminal pane has no PTY binding')
}
return ptyId
}
async function toggleFloatingPanel(page: Page, open: boolean): Promise<void> {
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 waitForFloatingWebglPane(page: Page): Promise<boolean> {
// 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
.waitForFunction(
(worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
return Boolean(manager?.getActivePane?.() ?? manager?.getPanes?.()[0])
},
FLOATING_WORKTREE_ID,
{ timeout: 15_000 }
)
.catch(() => undefined)
await page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
manager?.setTerminalGpuAcceleration?.('on')
}, FLOATING_WORKTREE_ID)
// Why: getPanes()/getActivePane() return a public projection without
// webglAddon; getRenderingDiagnostics() is the supported way to observe
// whether WebGL is attached.
const attached = await page
.waitForFunction(
(worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const diagnostics = manager?.getRenderingDiagnostics?.() ?? []
return diagnostics.some((diagnostic) => diagnostic.hasWebgl)
},
FLOATING_WORKTREE_ID,
{ timeout: 10_000 }
)
.then(() => true)
.catch(() => false)
if (!attached) {
const probe = await page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tabs = state?.tabsByWorktree?.[worktreeId] ?? []
const tab = tabs[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
return {
tabCount: tabs.length,
hasManager: Boolean(manager),
diagnostics: manager?.getRenderingDiagnostics?.() ?? null,
gpuSetting: state?.settings?.terminalGpuAcceleration ?? null
}
}, FLOATING_WORKTREE_ID)
console.log(`[floating-harness] webgl attach failed: ${JSON.stringify(probe)}`)
}
return attached
}
async function writeStaticContent(page: Page, marker: string): Promise<void> {
await page.evaluate(
async ({ worktreeId, content }) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('Floating pane unavailable')
}
await new Promise<void>((resolve) => pane.terminal.write(content, resolve))
},
{
worktreeId: FLOATING_WORKTREE_ID,
// Why: clear screen + scrollback and hide the cursor so screenshots are
// time-invariant, then render dense mixed glyphs so the WebGL atlas
// origin region is populated.
content: `\x1b[2J\x1b[3J\x1b[H\x1b[?25l${Array.from(
{ length: 14 },
(_, row) =>
`${marker} row ${row} | abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~ |\r\n`
).join('')}`
}
)
// Why: let xterm's renderer paint the new content before baseline capture.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
}
/**
* Corrupts the live glyph-atlas textures of the floating terminal's WebGL
* context by overwriting texels in every bound TEXTURE_2D, without raising a
* context-loss event. This simulates the in-the-wild Chromium failure that
* #5042 documents ("rapid TUI redraws can corrupt xterm's WebGL glyph atlas
* without a context-loss event") so recovery triggers can be tested
* deterministically.
*/
async function corruptFloatingAtlas(page: Page): Promise<number> {
return page.evaluate(
({ worktreeId, panelSelector }) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return 0
}
const panel = document.querySelector(panelSelector)
const canvases = panel ? Array.from(panel.querySelectorAll('canvas')) : []
const noise = new Uint8Array(64 * 64 * 4)
for (let i = 0; i < noise.length; i += 4) {
noise[i] = (i * 7) % 256
noise[i + 1] = (i * 13) % 256
noise[i + 2] = (i * 29) % 256
noise[i + 3] = 255
}
let corrupted = 0
for (const canvas of canvases) {
const gl =
(canvas.getContext('webgl2') as WebGL2RenderingContext | null) ??
(canvas.getContext('webgl') as WebGLRenderingContext | null)
if (!gl) {
continue
}
const maxUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) as number
for (let unit = 0; unit < maxUnits; unit += 1) {
gl.activeTexture(gl.TEXTURE0 + unit)
const bound = gl.getParameter(gl.TEXTURE_BINDING_2D)
if (!bound) {
continue
}
// Why: glyphs rasterize from the atlas origin outward, so noise
// tiles across the top-left region garble the visible text.
for (const [x, y] of [
[0, 0],
[64, 0],
[128, 0],
[192, 0],
[0, 64],
[64, 64],
[128, 64],
[192, 64]
]) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, noise)
if (gl.getError() === gl.NO_ERROR) {
corrupted += 1
}
}
}
gl.activeTexture(gl.TEXTURE0)
}
pane.terminal.refresh(0, pane.terminal.rows - 1)
return corrupted
},
{ worktreeId: FLOATING_WORKTREE_ID, panelSelector: PANEL_SELECTOR }
)
}
async function instrumentRecoveryCounters(page: Page): Promise<boolean> {
return page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
if (!manager?.resetWebglTextureAtlases || !manager.resumeRendering) {
return false
}
const counterWindow = window as RecoveryCounterWindow
counterWindow.__floatingManagerResets = 0
counterWindow.__floatingRenderResumes = 0
const originalReset = manager.resetWebglTextureAtlases.bind(manager)
manager.resetWebglTextureAtlases = () => {
counterWindow.__floatingManagerResets = (counterWindow.__floatingManagerResets ?? 0) + 1
originalReset()
}
// Why: a suspend/resume cycle also rebuilds the atlas; count it so any
// future fix routed through resumeRendering() is recognized as recovery.
const originalResume = manager.resumeRendering.bind(manager)
manager.resumeRendering = () => {
counterWindow.__floatingRenderResumes = (counterWindow.__floatingRenderResumes ?? 0) + 1
originalResume()
}
return true
}, FLOATING_WORKTREE_ID)
}
async function readRecoveryCounters(
page: Page
): Promise<{ managerResets: number; renderResumes: number }> {
return page.evaluate(() => {
const counterWindow = window as RecoveryCounterWindow
return {
managerResets: counterWindow.__floatingManagerResets ?? 0,
renderResumes: counterWindow.__floatingRenderResumes ?? 0
}
})
}
async function screenshotFloatingTerminal(page: Page): Promise<Buffer> {
const screen = page.locator(`${PANEL_SELECTOR} .xterm-screen`).first()
await expect(screen).toBeVisible()
return screen.screenshot({ animations: 'disabled' })
}
async function settleRecoveryWindows(page: Page): Promise<void> {
// Why: #5042-style recovery schedules resets up to 500ms after its trigger;
// waiting past that window keeps "no recovery fired" assertions honest.
await page.waitForTimeout(800)
}
async function captureStableBaseline(page: Page): Promise<Buffer> {
// Why: shell startup output can still be painting when content lands; two
// consecutive identical captures prove the surface is byte-stable before
// corruption comparisons begin.
let previous = await screenshotFloatingTerminal(page)
for (let attempt = 0; attempt < 10; attempt += 1) {
await page.waitForTimeout(250)
const next = await screenshotFloatingTerminal(page)
if (next.equals(previous)) {
return next
}
previous = next
}
throw new Error('Floating terminal surface did not stabilize for a baseline screenshot')
}
async function setUpCorruptedFloatingTerminal(
page: Page,
marker: string
): Promise<{ baseline: Buffer; corrupted: Buffer } | null> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await enableFloatingWorkspaceWithWebgl(page)
await toggleFloatingPanel(page, true)
if (!(await waitForFloatingWebglPane(page))) {
return null
}
const ptyId = await waitForFloatingPanePtyId(page)
await sendToTerminal(page, ptyId, 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, marker)
// Why: glyphs rasterized during startup can predate web font readiness; a
// clean atlas rebuild here makes the baseline byte-identical to any later
// recovery re-rasterization, so equality is a sound "healed" oracle.
await page.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
manager?.resetWebglTextureAtlases?.()
}, FLOATING_WORKTREE_ID)
const baseline = await captureStableBaseline(page)
const corruptedTiles = await corruptFloatingAtlas(page)
console.log(`[floating-harness] corrupted atlas tiles: ${corruptedTiles}`)
if (corruptedTiles === 0) {
return null
}
// Why: xterm paints on the next animation frame after refresh(); poll until
// the injected noise is actually visible so later "still corrupted" and
// "healed" comparisons are meaningful. Skip if the noise landed outside the
// atlas region glyphs are drawn from.
for (let attempt = 0; attempt < 8; attempt += 1) {
await page.waitForTimeout(250)
const shot = await screenshotFloatingTerminal(page)
if (!shot.equals(baseline)) {
return { baseline, corrupted: shot }
}
}
console.log('[floating-harness] injected atlas noise never became visible')
return null
}
test.describe('floating workspace reopen WebGL recovery @headful', () => {
test('reopening the floating workspace recovers a corrupted glyph atlas', async ({
orcaPage
}, testInfo) => {
// Why: the floating panel hides via CSS visibility only — its terminal
// keeps isVisible=true and a live WebGL context while hidden, so the
// visibility-resume/window-focus/paste recovery triggers never run on
// reopen. The panel dispatches RESET_TERMINAL_WEBGL_ATLAS_EVENT when it
// opens; this guards that a glyph atlas corrupted while hidden repaints.
const shots = await setUpCorruptedFloatingTerminal(orcaPage, 'REOPEN')
test.skip(!shots, 'WebGL was not active or atlas corruption could not be injected')
const { baseline, corrupted } = shots!
expect(corrupted.equals(baseline)).toBe(false)
expect(await instrumentRecoveryCounters(orcaPage)).toBe(true)
await toggleFloatingPanel(orcaPage, false)
// Why: the bug's precondition — the hidden floating terminal keeps its
// WebGL addon attached because closing the panel never suspends rendering.
const webglStillAttached = await orcaPage.evaluate((worktreeId) => {
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0]
const manager = tab ? window.__paneManagers?.get(tab.id) : null
const diagnostics = manager?.getRenderingDiagnostics?.() ?? []
return diagnostics.some((diagnostic) => diagnostic.hasWebgl)
}, FLOATING_WORKTREE_ID)
expect(webglStillAttached).toBe(true)
await toggleFloatingPanel(orcaPage, true)
await settleRecoveryWindows(orcaPage)
const counters = await readRecoveryCounters(orcaPage)
const afterReopen = await screenshotFloatingTerminal(orcaPage)
await testInfo.attach('baseline', {
body: baseline,
contentType: 'image/png'
})
await testInfo.attach('corrupted', {
body: corrupted,
contentType: 'image/png'
})
await testInfo.attach('after-reopen', {
body: afterReopen,
contentType: 'image/png'
})
console.log(
`[floating-reopen] managerResets=${counters.managerResets} renderResumes=${counters.renderResumes} healed=${afterReopen.equals(baseline)}`
)
expect(
counters.managerResets + counters.renderResumes,
'reopen should reset or rebuild the corrupted atlas'
).toBeGreaterThan(0)
expect(afterReopen.equals(baseline), 'reopened terminal should render clean glyphs').toBe(true)
})
test('window focus regain recovers the corrupted atlas (harness control)', async ({
orcaPage
}) => {
// Why: control proving the injected corruption is exactly the class the
// existing recovery machinery heals — isolating the reopen gap above as a
// missing trigger rather than a broken harness or unrecoverable state.
const shots = await setUpCorruptedFloatingTerminal(orcaPage, 'CONTROL')
test.skip(!shots, 'WebGL was not active or atlas corruption could not be injected')
const { baseline, corrupted } = shots!
expect(corrupted.equals(baseline)).toBe(false)
await orcaPage.evaluate(() => {
window.dispatchEvent(new Event('focus'))
})
await settleRecoveryWindows(orcaPage)
const afterFocus = await screenshotFloatingTerminal(orcaPage)
console.log(`[floating-control] healedByFocus=${afterFocus.equals(baseline)}`)
expect(afterFocus.equals(baseline), 'window focus should heal the atlas').toBe(true)
})
})