fix(agent-manager): fall back to DOM renderer on WebGL context loss and keep hidden terminal layout size

This commit is contained in:
marius-kilocode
2026-08-25 11:04:43 +02:00
parent 80024c0192
commit 229c27dc4f
4 changed files with 70 additions and 48 deletions
@@ -4878,17 +4878,18 @@ body.vscode-high-contrast-light {
/*
* Stacking layout for xterm terminal tabs.
*
* Terminals are never unmounted once mounted; inactive slots are hidden
* with `display: none`. xterm 6's render service observes the screen
* element and pauses hidden terminals' render loops (rAF, model
* updates, GPU draws), then replays a full refresh when a slot becomes
* visible again — the activation fit + refresh in the TerminalTab
* component is insurance on top of that. The historical `display: none`
* avoidance was for the "press Enter to see content" bug, caused by older
* xterm versions not resuming after a paint-tree detach; opacity-only hiding
* instead kept the render loop running at full rate for output no one
* sees (such as background setup scripts), which is measurable on the
* main thread.
* Terminals are never unmounted once mounted; inactive slots hide one
* viewport to the left while keeping their layout box. xterm 6's render
* service observes the screen element and pauses hidden terminals'
* render loops (rAF, model updates, GPU draws), then replays a full
* refresh when a slot becomes visible again — the activation fit +
* refresh in the TerminalTab component is insurance on top of that. The
* historical `display: none` avoidance was for the "press Enter to see
* content" bug, caused by older xterm versions not resuming after a
* paint-tree detach; it also stops FitAddon from measuring, so hidden
* content wraps wrong. Keeping the box fixes wrap and still lets the
* render observer pause, saving the full-rate render cost for output no
* one sees (such as background setup scripts).
*
* Stack: position:relative container hosting the main pane, review,
* and side panel. Sits below the tab bar inside `.am-detail`.
@@ -4926,26 +4927,28 @@ body.vscode-high-contrast-light {
}
.am-terminal-slot {
/* Hidden slots are display:none, not opacity:0. xterm 6's render
service observes the screen element and pauses its render loop for
non-intersecting terminals (rAF loop, model updates, WebGL draws),
then replays a full refresh when the slot becomes visible again.
Opacity-only hiding kept the loop running at full rate for output
no one sees, such as background setup scripts streaming while the
user works in another context's terminal. */
/* Hidden slots stay in layout but are translated one viewport to the
left, so xterm's render observer sees no intersection and pauses the
render loop (rAF loop, model updates, WebGL draws), then replays a
full refresh when the slot slides back in. Keeping the layout box
(unlike display:none) lets FitAddon measure the real panel size
while hidden — background-created terminals such as setup scripts
wrap their output at the panel's width from the start instead of
the 80 column default. */
position: absolute;
inset: 0;
display: none;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
opacity: 0;
pointer-events: none;
transform: translate(-100vw, 0);
z-index: 0;
}
.am-terminal-slot-visible {
display: flex;
transform: none;
opacity: 1;
pointer-events: auto;
z-index: 1;
@@ -4954,8 +4957,8 @@ body.vscode-high-contrast-light {
/* Side terminal panel — lives inside .am-diff-panel-wrapper next to the
diff and PR panels. Header reuses .am-diff-header metrics so the
chrome does not shift when switching inspector modes. The panel is
opacity-toggled, its terminal slots flip display (per-slot render
pause, see .am-terminal-slot above). */
opacity-toggled, its terminal slots hide by off-screen translate
(per-slot render pause, see .am-terminal-slot above). */
.am-side-terminal {
position: absolute;
inset: 0;
@@ -5,12 +5,14 @@
* panels, so every mode uses the same persisted resize width. The tab row is
* the shared inspector strip used by subagents as well.
*
* Visibility toggles between none and flex, never unmount: xterm keeps its
* buffer, socket, and parser alive, while xterm's own render observer
* (IntersectionObserver on the screen element) pauses the render loop for
* hidden slots and replays a full refresh when a slot becomes visible
* again. `TerminalTab` still does an explicit fit + refresh on activation
* as insurance (see `render.tsx`).
* Hidden slots are translated off-screen while keeping their layout
* box, never unmounted: xterm keeps its buffer, socket, and parser
* alive, while xterm's own render observer (IntersectionObserver on the
* screen element) pauses the render loop for hidden slots and replays a
* full refresh when a slot becomes visible again. Keeping the box means
* FitAddon can measure the panel (correct wrapping) even while hidden.
* `TerminalTab` still does an explicit fit + refresh on activation as
* insurance (see `render.tsx`).
*/
import type { Accessor, Component } from "solid-js"
@@ -33,8 +33,8 @@ interface Props {
font: TerminalFont
/** Whether this terminal is currently the focused tab.
*
* Inactive slots are `display: none` (see the layer / slot CSS in
* `terminal/render.tsx` and `agent-manager.css`): xterm's render
* Inactive slots are translated off-screen (see the layer / slot CSS
* in `terminal/render.tsx` and `agent-manager.css`): xterm's render
* observer pauses invisible terminals and resumes them with a full
* refresh on activation. This prop drives that activation repaint
* plus auto-focus on activation — xterm's own resume is primary, the
@@ -174,7 +174,19 @@ export const TerminalTab: Component<Props> = (props) => {
// draw batched by first glyph. Falls back to the DOM renderer when
// WebGL2 is unavailable; the catch logs without breaking the session.
try {
term.loadAddon(new WebglAddon())
const webgl = new WebglAddon()
term.loadAddon(webgl)
// Browsers hand out a limited number of WebGL contexts and evict
// old ones; when a context is lost beyond recovery, dispose the
// addon so xterm re-installs its DOM renderer for this terminal
// instead of leaving a dead canvas.
webgl.onContextLoss(() => {
try {
webgl.dispose()
} catch (err) {
log("webgl dispose after context loss failed", err)
}
})
} catch (err) {
log("webgl renderer unavailable, using DOM renderer", err)
}
@@ -454,10 +466,11 @@ export const TerminalTab: Component<Props> = (props) => {
// ---- Repaint recovery ----
//
// Inactive xterm slots are display:none, so their canvases are not
// composed while hidden. xterm's render observer pauses hidden
// terminals and replays a full refresh when they re-enter the tree,
// but browsers still defer some canvas/render work: forcing a
// Inactive xterm slots slide off-screen with their layout box
// intact, so their canvases are not composed while hidden but
// FitAddon keeps measuring. xterm's render observer pauses hidden
// terminals and replays a full refresh when they slide back in, but
// browsers still defer some canvas/render work: forcing a
// `fit + refresh(0, rows-1)` once per activation reclaims paint
// priority immediately; from then on the renderer keeps the canvas
// live. Historically the missing insurance step here was "press
@@ -71,16 +71,19 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
* ## Invariant
*
* **Once an xterm instance is mounted, its DOM subtree is only ever
* hidden, never unmounted.** Inactive slots use `display: none`; xterm
* 6's render service observes the screen element and pauses the render
* loop (rAF, model updates, GPU draws) for non-intersecting terminals,
* then replays a full refresh when the slot re-enters the tree. Toggling
* visibility via opacity instead — the historical workaround for the
* "press Enter to see content" bug, where reattachment left a stale
* canvas — kept the render loop running at full rate for output no one
* sees, e.g. background setup scripts streaming while the user works in
* another context's terminal. `TerminalTab`'s activation repaint
* (fit + refresh) remains as insurance on top of xterm's own resume.
* hidden, never unmounted.** Inactive slots keep their layout box but
* are translated one viewport off-screen; xterm 6's render service
* observes the screen element and pauses the render loop (rAF, model
* updates, GPU draws) for non-intersecting terminals, then replays a
* full refresh when the slot slides back in. Keeping the box (unlike
* `display: none`) also lets FitAddon measure the real panel size while
* hidden, so background-created terminals such as setup scripts wrap
* output at the panel's width from the start. Hiding via opacity instead
* — the historical workaround for the "press Enter to see content" bug,
* where reattachment left a stale canvas — kept the render loop running
* at full rate for output no one sees. `TerminalTab`'s activation
* repaint (fit + refresh) remains as insurance on top of xterm's own
* resume.
*
* ## Design
*
@@ -137,9 +140,10 @@ export function renderTerminalLayer(props: {
* Render the side-panel terminal layer inside the right-hand inspector.
*
* Same invariant as `renderTerminalLayer`: every side terminal stays
* mounted, inactive slots are hidden with `display: none` so xterm's
* render observer pauses them, and only the visible tab's slot is in the
* tree. The layer is scoped to `contextKey` — side terminals from other
* mounted, inactive slots are translated off-screen so xterm's render
* observer pauses them while FitAddon keeps measuring, and only the
* visible tab's slot is shown. The layer is scoped to `contextKey` —
* side terminals from other
* contexts stay paused in the background and never refit — and within a
* context only the active strip tab's terminal is shown.
*/