fix(terminal): force WebGL repaint after split when dimensions match (#1272)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-04-29 23:33:36 -07:00 committed by GitHub
parent a02987e604
commit 066c245d14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 540 additions and 18 deletions

View File

@ -663,7 +663,8 @@ export function useTerminalPaneLifecycle({
// Why: TerminalPane instances stay mounted for hidden visited worktrees
// so PTYs survive navigation. Creating WebGL for those offscreen panes
// still consumes Chromium's context budget and can blank visible panes.
initialRenderingSuspended: !isVisibleRef.current
initialRenderingSuspended: !isVisibleRef.current,
debugLabel: `tab:${tabId}/wt:${worktreeId}`
})
managerRef.current = manager

View File

@ -58,6 +58,7 @@ function createPane(): ManagedPaneInternal {
webglAddon: null,
ligaturesAddon: null,
compositionHandler: null,
debugLabel: null,
pendingSplitScrollState: {
wasAtBottom: true,
firstVisibleLineContent: '',

View File

@ -45,7 +45,8 @@ function createPane(): ManagedPaneInternal {
webLinksAddon: {} as never,
webglAddon: null,
compositionHandler: null,
pendingSplitScrollState: null
pendingSplitScrollState: null,
debugLabel: null
}
}

View File

@ -121,7 +121,8 @@ export function createPaneDOM(
webglAddon: null,
ligaturesAddon: null,
compositionHandler: null,
pendingSplitScrollState: null
pendingSplitScrollState: null,
debugLabel: options.debugLabel ?? null
}
// Focus handler: clicking a pane makes it active and explicitly focuses

View File

@ -28,6 +28,10 @@ export type PaneManagerOptions = {
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
onLinkClick?: (event: MouseEvent | undefined, url: string) => void
initialRenderingSuspended?: boolean
// Why: diagnostic label for log correlation. safeFit and other internal
// helpers log warnings that are hard to correlate without knowing which
// tab/worktree the PaneManager belongs to.
debugLabel?: string
}
export type PaneStyleOptions = {
@ -91,6 +95,7 @@ export type ManagedPaneInternal = {
// intermediate fit paths skip their own scroll restoration, deferring to
// the splitPane's final authoritative restore.
pendingSplitScrollState: ScrollState | null
debugLabel: string | null
} & ManagedPane
export type DropZone = 'top' | 'bottom' | 'left' | 'right'

View File

@ -52,7 +52,8 @@ function createPane({
webglAddon: null,
ligaturesAddon: null,
compositionHandler: null,
pendingSplitScrollState: null
pendingSplitScrollState: null,
debugLabel: null
}
}

View File

@ -41,18 +41,27 @@ export function safeFit(pane: ManagedPaneInternal): void {
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
// churn, which was causing visible terminal blinking while resizing.
//
// Why: diagnostic for intermittent dead-terminal-after-split. If a
// just-reparented pane's proposed dimensions match its current
// dimensions (the default 80×24 at certain screen widths), this
// early-return skips fitAddon.fit() and no terminal.resize() fires
// — leaving the WebGL canvas at stale dimensions.
// Why: wrapInSplit() reparents the pane's container, which can leave
// the WebGL canvas stale even when proposed dimensions match current
// (the browser detaches and reattaches the canvas during the DOM move).
// When pendingSplitScrollState is set we must force a fit + refresh so
// the WebGL renderer repaints. Without this, the pane appears blank
// until something forces a dimension change.
if (pane.pendingSplitScrollState) {
console.warn(
'[terminal] safeFit early-return during pending split for pane',
'[terminal] safeFit forcing fit+refresh during pending split for pane',
pane.id,
`— dims ${dims.cols}×${dims.rows} match current, webgl:`,
!!pane.webglAddon
!!pane.webglAddon,
pane.debugLabel ? `(${pane.debugLabel})` : ''
)
pane.fitAddon.fit()
try {
pane.terminal.refresh(0, pane.terminal.rows - 1)
} catch {
/* ignore — terminal may not be fully initialised */
}
return
}
return
}

View File

@ -13,6 +13,13 @@ type RegisteredTerminalTab = {
}
const registeredTabs = new Map<string, RegisteredTerminalTab>()
// Why: track when each tab was registered so we can suppress the "no live
// transport" warning during the initial PTY connection window. The warning
// is noise when it fires on mount (PTY spawn/attach is async and hasn't
// finished yet), but valuable if the transport is still missing after the
// grace period — that indicates a real stuck state.
const tabRegisteredAt = new Map<string, number>()
const NO_TRANSPORT_GRACE_MS = 10_000
let syncScheduled = false
let syncEnabled = false
let getStoreState: (() => AppState) | null = null
@ -23,9 +30,11 @@ export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null)
export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void {
registeredTabs.set(tab.tabId, tab)
tabRegisteredAt.set(tab.tabId, Date.now())
scheduleRuntimeGraphSync()
return () => {
registeredTabs.delete(tab.tabId)
tabRegisteredAt.delete(tab.tabId)
scheduleRuntimeGraphSync()
}
}
@ -89,7 +98,8 @@ async function syncRuntimeGraph(): Promise<void> {
const leafId = paneLeafId(pane.id)
const ptyId = registeredTab.getPtyIdForPane(pane.id)
const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null
if (!ptyId && savedPtyId) {
const registeredTime = tabRegisteredAt.get(tabId) ?? 0
if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) {
warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', {
tabId,
worktreeId: registeredTab.worktreeId,

View File

@ -0,0 +1,168 @@
/**
* Stress test for dead-terminal reproduction (setup-split flow).
*
* Why @headful: the dead-terminal bug is a WebGL canvas staleness issue after
* wrapInSplit() reparents the existing pane's container, the WebGL canvas can
* fail to repaint. In headless mode WebGL is NEVER active, so the DOM fallback
* renderer is used and the bug cannot manifest. Running headful ensures real
* WebGL contexts matching production.
*
* See helpers/dead-terminal.ts for the shared worktree-creation helper that
* replicates the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal
* production flow.
*/
import { test, expect } from './helpers/orca-app'
import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
switchToWorktree,
ensureTerminalVisible
} from './helpers/store'
import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal'
import {
createAndActivateWorktreeWithSetup,
removeWorktreeViaStore,
waitForAllPanesToHaveContent,
checkWebglState
} from './helpers/dead-terminal'
const STRESS_ITERATIONS = 5
test.describe('Dead Terminal Reproduction @headful', () => {
const createdWorktreeIds: string[] = []
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.evaluate(async () => {
const state = window.__store?.getState()
if (!state) {
return
}
state.updateSettings({ setupScriptLaunchMode: 'split-vertical' })
// Why: write orca.yaml into the repo so createWorktree IPC returns a
// WorktreeSetupLaunch with a runner script, triggering the setup split.
// This is scoped to the dead-terminal tests to avoid breaking other
// specs that don't expect setup scripts to fire on worktree creation.
const wt = Object.values(state.worktreesByRepo)
.flat()
.find((w) => w.id === state.activeWorktreeId)
if (wt) {
const sep = wt.path.includes('\\') ? '\\' : '/'
await window.api.fs.writeFile({
filePath: `${wt.path}${sep}orca.yaml`,
content: 'scripts:\n setup: echo SETUP_COMPLETE\n'
})
}
})
})
test.afterEach(async ({ orcaPage }) => {
for (const id of createdWorktreeIds) {
await removeWorktreeViaStore(orcaPage, id)
}
createdWorktreeIds.length = 0
})
test('@headful setup-split flow does not produce dead terminals', async ({ orcaPage }) => {
test.setTimeout(120_000)
const homeWorktreeId = await waitForActiveWorktree(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await checkWebglState(orcaPage, 'home-initial')
for (let i = 0; i < STRESS_ITERATIONS; i++) {
const direction = i % 2 === 0 ? 'vertical' : 'horizontal'
const newId = await createAndActivateWorktreeWithSetup(orcaPage, `setup-${i}`, direction)
createdWorktreeIds.push(newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 2, 15_000)
await checkWebglState(orcaPage, `setup-${i}`)
await waitForAllPanesToHaveContent(orcaPage, `setup-${i} both panes`)
await switchToWorktree(orcaPage, homeWorktreeId)
await expect
.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 })
.toBe(homeWorktreeId)
await removeWorktreeViaStore(orcaPage, newId)
createdWorktreeIds.pop()
}
})
test('@headful setup-split then switch-back does not leave panes dead', async ({ orcaPage }) => {
test.setTimeout(120_000)
const homeWorktreeId = await waitForActiveWorktree(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
for (let i = 0; i < STRESS_ITERATIONS; i++) {
const newId = await createAndActivateWorktreeWithSetup(
orcaPage,
`switchback-${i}`,
'vertical'
)
createdWorktreeIds.push(newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 2, 15_000)
await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} initial`)
await switchToWorktree(orcaPage, homeWorktreeId)
await expect
.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 })
.toBe(homeWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 15_000)
await switchToWorktree(orcaPage, newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 15_000)
await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} after return`)
await switchToWorktree(orcaPage, homeWorktreeId)
await expect
.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 })
.toBe(homeWorktreeId)
await removeWorktreeViaStore(orcaPage, newId)
createdWorktreeIds.pop()
}
})
test('@headful rapid switching between many setup-split worktrees', async ({ orcaPage }) => {
test.setTimeout(120_000)
const homeWorktreeId = await waitForActiveWorktree(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const worktreeIds = [homeWorktreeId]
for (let i = 0; i < 4; i++) {
const newId = await createAndActivateWorktreeWithSetup(orcaPage, `multi-${i}`, 'vertical')
createdWorktreeIds.push(newId)
worktreeIds.push(newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 2, 15_000)
await waitForAllPanesToHaveContent(orcaPage, `multi-create-${i}`)
}
for (let round = 0; round < 3; round++) {
for (const wId of worktreeIds) {
await switchToWorktree(orcaPage, wId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(wId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 15_000)
await waitForAllPanesToHaveContent(orcaPage, `multi-r${round}-${wId.slice(0, 8)}`)
}
}
})
})

View File

@ -0,0 +1,151 @@
/**
* Aggressive stress tests for dead-terminal reproduction.
*
* These tests target specific failure vectors beyond the basic setup-split flow:
* - Forced WebGL context loss (simulating Chromium memory pressure)
* - Rapid switching during the ~200ms scheduleSplitScrollRestore window
*
* All tests require @headful mode for WebGL to be active.
*/
import { test, expect } from './helpers/orca-app'
import {
waitForSessionReady,
waitForActiveWorktree,
getActiveWorktreeId,
switchToWorktree,
ensureTerminalVisible
} from './helpers/store'
import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal'
import {
createAndActivateWorktreeWithSetup,
removeWorktreeViaStore,
waitForAllPanesToHaveContent
} from './helpers/dead-terminal'
const STRESS_ITERATIONS = 5
test.describe('Dead Terminal Stress @headful', () => {
const createdWorktreeIds: string[] = []
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.evaluate(async () => {
const state = window.__store?.getState()
if (!state) {
return
}
state.updateSettings({ setupScriptLaunchMode: 'split-vertical' })
const wt = Object.values(state.worktreesByRepo)
.flat()
.find((w) => w.id === state.activeWorktreeId)
if (wt) {
const sep = wt.path.includes('\\') ? '\\' : '/'
await window.api.fs.writeFile({
filePath: `${wt.path}${sep}orca.yaml`,
content: 'scripts:\n setup: echo SETUP_COMPLETE\n'
})
}
})
})
test.afterEach(async ({ orcaPage }) => {
for (const id of createdWorktreeIds) {
await removeWorktreeViaStore(orcaPage, id)
}
createdWorktreeIds.length = 0
})
/**
* Force WebGL context loss on visible canvases immediately after a setup
* split. In production, Chromium reclaims WebGL contexts under memory
* pressure especially with many worktrees open. The recovery path is:
* onContextLoss dispose WebGL DOM fallback rAF fit + refresh.
*/
test('@headful setup-split with forced WebGL context loss recovers', async ({ orcaPage }) => {
test.setTimeout(120_000)
const homeWorktreeId = await waitForActiveWorktree(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
for (let i = 0; i < STRESS_ITERATIONS; i++) {
const newId = await createAndActivateWorktreeWithSetup(orcaPage, `ctxloss-${i}`, 'vertical')
createdWorktreeIds.push(newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 2, 15_000)
const lostCount = await orcaPage.evaluate(() => {
const canvases = document.querySelectorAll('.pane canvas:not(.xterm-link-layer)')
let lost = 0
for (const canvas of canvases) {
const gl =
(canvas as HTMLCanvasElement).getContext('webgl2') ??
(canvas as HTMLCanvasElement).getContext('webgl')
if (gl) {
const ext = gl.getExtension('WEBGL_lose_context')
if (ext) {
ext.loseContext()
lost++
}
}
}
return lost
})
if (lostCount > 0) {
console.log(`[ctxloss-${i}] Forced context loss on ${lostCount} canvases`)
}
await orcaPage.waitForTimeout(500)
await waitForAllPanesToHaveContent(orcaPage, `ctxloss-${i} after context loss`)
await switchToWorktree(orcaPage, homeWorktreeId)
await expect
.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 })
.toBe(homeWorktreeId)
await removeWorktreeViaStore(orcaPage, newId)
createdWorktreeIds.pop()
}
})
/**
* Switch worktrees WITHOUT waiting for the split to settle. This hits the
* race between wrapInSplit() reparenting, WebGL context creation during
* resumeRendering(), and the scheduleSplitScrollRestore 200ms timer.
*/
test('@headful rapid worktree switching during setup-split lifecycle', async ({ orcaPage }) => {
test.setTimeout(120_000)
const homeWorktreeId = await waitForActiveWorktree(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
for (let i = 0; i < 3; i++) {
const newId = await createAndActivateWorktreeWithSetup(orcaPage, `rapid-${i}`, 'vertical')
createdWorktreeIds.push(newId)
// Switch away during the ~200ms scheduleSplitScrollRestore window
await orcaPage.waitForTimeout(50)
await switchToWorktree(orcaPage, homeWorktreeId)
await orcaPage.waitForTimeout(50)
// Switch back — triggers resumeRendering on partially-initialized panes
await switchToWorktree(orcaPage, newId)
await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForPaneCount(orcaPage, 2, 15_000)
await waitForAllPanesToHaveContent(orcaPage, `rapid-${i} after return`)
await switchToWorktree(orcaPage, homeWorktreeId)
await expect
.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 })
.toBe(homeWorktreeId)
await removeWorktreeViaStore(orcaPage, newId)
createdWorktreeIds.pop()
}
})
})

View File

@ -33,7 +33,7 @@ export default function globalSetup(): void {
execSync('npx electron-vite build --mode e2e', {
cwd: root,
stdio: 'inherit',
timeout: 120_000,
timeout: 120_000
})
console.log('[e2e] Build complete.')
}
@ -53,10 +53,7 @@ export default function globalSetup(): void {
path.join(testRepoDir, 'README.md'),
'# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n'
)
writeFileSync(
path.join(testRepoDir, 'CLAUDE.md'),
'# CLAUDE.md\n\nTest instructions for E2E.\n'
)
writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n')
writeFileSync(
path.join(testRepoDir, 'package.json'),
`${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n`
@ -74,7 +71,7 @@ export default function globalSetup(): void {
const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${Date.now()}`)
execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, {
cwd: testRepoDir,
stdio: 'pipe',
stdio: 'pipe'
})
console.log(`[e2e] Secondary worktree created at ${worktreeDir}`)

View File

@ -0,0 +1,175 @@
/**
* Shared helpers for dead-terminal reproduction tests.
*
* These helpers exercise the real production worktree-creation-with-setup-split
* flow and verify that terminal panes render content after the split.
*/
import { expect } from '@playwright/test'
import type { getActiveWorktreeId } from './store'
type TestPage = Parameters<typeof getActiveWorktreeId>[0]
/**
* Create a worktree with setup via the real IPC flow, then activate it
* replicating the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal
* path. The setup split is queued on the tab BEFORE the TerminalPane mounts,
* matching production timing.
*/
export async function createAndActivateWorktreeWithSetup(
page: TestPage,
suffix: string,
direction: 'vertical' | 'horizontal'
): Promise<string> {
const name = `e2e-dead-term-${suffix}-${Date.now()}`
return page.evaluate(
async ({ worktreeName, direction }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const activeWorktreeId = state.activeWorktreeId
if (!activeWorktreeId) {
throw new Error('No active worktree')
}
const activeWorktree = Object.values(state.worktreesByRepo)
.flat()
.find((wt) => wt.id === activeWorktreeId)
if (!activeWorktree) {
throw new Error('Active worktree not found in store')
}
const result = await state.createWorktree(
activeWorktree.repoId,
worktreeName,
undefined,
'run'
)
await state.fetchWorktrees(activeWorktree.repoId)
const worktreeId = result.worktree.id
if (activeWorktree.repoId !== state.activeRepoId) {
state.setActiveRepo(activeWorktree.repoId)
}
if (store.getState().activeView !== 'terminal') {
state.setActiveView('terminal')
}
state.setActiveWorktree(worktreeId)
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (renderableTabCount > 0) {
return worktreeId
}
const tab = state.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true
})
state.setActiveTab(tab.id)
if (result.setup) {
const runnerPath = result.setup.runnerScriptPath
const command = `bash ${runnerPath}`
state.queueTabSetupSplit(tab.id, {
command,
env: result.setup.envVars,
direction
})
}
state.revealWorktreeInSidebar(worktreeId)
return worktreeId
},
{ worktreeName: name, direction }
)
}
export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise<void> {
await page.evaluate(async (id) => {
try {
await window.__store?.getState().removeWorktree(id, true)
} catch {
/* best-effort */
}
}, worktreeId)
}
/**
* Poll until every pane in the active tab's PaneManager has non-empty buffer
* content. A dead terminal has an empty serialize() result because the WebGL
* canvas never painted the shell prompt.
*/
export async function waitForAllPanesToHaveContent(
page: TestPage,
label: string,
timeoutMs = 15_000
): Promise<void> {
await expect
.poll(
async () => {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return { ok: false, reason: 'no store' }
}
const state = store.getState()
const wId = state.activeWorktreeId
if (!wId) {
return { ok: false, reason: 'no active worktree' }
}
const tabs = state.tabsByWorktree[wId] ?? []
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: (state.activeTabIdByWorktree?.[wId] ?? tabs[0]?.id)
if (!tabId) {
return { ok: false, reason: 'no tab' }
}
const manager = window.__paneManagers?.get(tabId)
if (!manager) {
return { ok: false, reason: 'no manager' }
}
const panes = manager.getPanes?.() ?? []
if (panes.length === 0) {
return { ok: false, reason: 'no panes' }
}
const paneStates = panes.map((pane) => {
const content = pane.serializeAddon?.serialize?.() ?? ''
// oxlint-disable-next-line no-control-regex -- stripping terminal control chars is intentional
const stripped = content.replace(/[\s\x00-\x1f]/g, '')
return { id: pane.id, hasContent: stripped.length > 0 }
})
return { ok: paneStates.every((p) => p.hasContent), paneStates }
})
},
{
timeout: timeoutMs,
message: `[${label}] Not all terminal panes have rendered content (possible dead terminal)`
}
)
.toMatchObject({ ok: true })
}
/**
* Log WebGL canvas state for diagnostics. In headful mode, visible panes
* should have WebGL canvases; hidden panes (suspended rendering) should not.
*/
export async function checkWebglState(page: TestPage, label: string): Promise<void> {
const paneStates = await page.evaluate(() => {
const containers = document.querySelectorAll('.pane[data-pane-id]')
return Array.from(containers).map((c) => ({
paneId: (c as HTMLElement).dataset.paneId,
canvasCount: c.querySelectorAll('canvas').length
}))
})
const hasCanvas = paneStates.some((p) => p.canvasCount > 0)
if (!hasCanvas) {
console.warn(`[${label}] No WebGL canvases — DOM renderer only.`)
}
}

View File

@ -19,6 +19,8 @@ export type PaneManagerLike = {
splitPane?(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null
closePane?(paneId: number): void
setActivePane?(paneId: number, opts?: { focus?: boolean }): void
suspendRendering?(): void
resumeRendering?(): void
}
export type ExplorerFileSummary = Pick<OpenFile, 'id' | 'filePath' | 'relativePath'>