fix(terminal): Reconnect button swallowed by xterm's link-layer canvas #71

Merged
lz merged 1 commit from fix/reconnect into main 2026-07-16 22:15:24 +02:00
Owner

The Reconnect button at the centre of a disconnected terminal pane did nothing.

Diagnosis

The button was never broken — the click never reached it.

xterm's webgl addon creates an xterm-link-layer canvas spanning the whole pane with an inline z-index: 2 and pointer-events: auto. Nothing between that canvas and the overlay creates a stacking context — every ancestor (.xterm-screen, .xterm, .terminal-mount, .terminal-body) is z-index: auto — so xterm's internal z-indices escaped their container and competed directly with .disconnected-overlay's z-index: auto (= 0). The canvas won every hit test.

That canvas is transparent (it only paints link underlines), which is why the failure looked so strange: the overlay and its button render perfectly, you click dead centre, and nothing happens.

Measured on the unfixed build:

  • document.elementFromPoint() at the button's centre returned xterm-link-layer, not the button.
  • Playwright refused the click outright: "canvas … intercepts pointer events".
  • With the WebSocket constructor instrumented, clicking Reconnect produced zero connection attempts.

There's a nice irony in the existing comment above the overlay markup, which worried that a permanently-present overlay "would swallow clicks meant for the terminal." The real relationship was inverted: the terminal was swallowing the overlay's clicks.

Fix

One line — isolation: isolate on .terminal-mount.

This scopes xterm's layers to the mount rather than bidding the overlay above a number xterm owns. Hardcoding z-index: 12 would work today but is brittle: xterm's own CSS already reaches 11 (the scrollbar), and that's free to change on any upgrade — silently, with this exact invisible-but-broken failure mode.

Verification

The suite is environment: 'node' with no DOM, so it structurally cannot catch this. Verified by driving the real UI in a browser (server seeded with a fake worker/session so the pane lands in the disconnected state):

  • Before: 0 WebSocket attempts on click; canvas intercepts.
  • After: clean click; 3 clicks → 3 fresh attempts to ws://…/api/workers/w1/sessions/s1/terminal.
  • No regression: with the overlay hidden (the normal connected state), the centre hit target reverts to xterm-link-layer — the terminal still receives its own clicks — and xterm's internal layer order (5, 2, auto) is unchanged. Isolation scoped those layers without reordering them.
  • 725 tests pass, lint clean, typecheck 0 errors.

Caveats

  1. Driven against a seeded fake worker, so the reconnect legitimately fails server-side with a 409 (no container). This proves the button now fires correctly; it does not prove a reconnect against a live container succeeds end-to-end. The client path is identical either way, but a real pane is the honest last check.
  2. Related but out of scope: when a reconnect does fail server-side (container stopped → 409/401), the overlay just sits there reading "disconnected" with no feedback — which would look to an operator like "the button doesn't work" all over again. Worth a follow-up.
The Reconnect button at the centre of a disconnected terminal pane did nothing. ## Diagnosis The button was never broken — **the click never reached it.** xterm's webgl addon creates an `xterm-link-layer` canvas spanning the whole pane with an inline `z-index: 2` and `pointer-events: auto`. Nothing between that canvas and the overlay creates a stacking context — every ancestor (`.xterm-screen`, `.xterm`, `.terminal-mount`, `.terminal-body`) is `z-index: auto` — so xterm's internal z-indices escaped their container and competed directly with `.disconnected-overlay`'s `z-index: auto` (= 0). The canvas won every hit test. That canvas is transparent (it only paints link underlines), which is why the failure looked so strange: the overlay and its button render perfectly, you click dead centre, and nothing happens. Measured on the unfixed build: - `document.elementFromPoint()` at the button's centre returned `xterm-link-layer`, not the button. - Playwright refused the click outright: *"canvas … intercepts pointer events"*. - With the `WebSocket` constructor instrumented, clicking Reconnect produced **zero** connection attempts. There's a nice irony in the existing comment above the overlay markup, which worried that a permanently-present overlay *"would swallow clicks meant for the terminal."* The real relationship was inverted: the terminal was swallowing the overlay's clicks. ## Fix One line — `isolation: isolate` on `.terminal-mount`. This scopes xterm's layers to the mount rather than bidding the overlay above a number xterm owns. Hardcoding `z-index: 12` would work today but is brittle: xterm's own CSS already reaches 11 (the scrollbar), and that's free to change on any upgrade — silently, with this exact invisible-but-broken failure mode. ## Verification The suite is `environment: 'node'` with no DOM, so it structurally cannot catch this. Verified by driving the real UI in a browser (server seeded with a fake worker/session so the pane lands in the disconnected state): - **Before:** 0 WebSocket attempts on click; canvas intercepts. - **After:** clean click; 3 clicks → 3 fresh attempts to `ws://…/api/workers/w1/sessions/s1/terminal`. - **No regression:** with the overlay hidden (the normal connected state), the centre hit target reverts to `xterm-link-layer` — the terminal still receives its own clicks — and xterm's internal layer order (5, 2, auto) is unchanged. Isolation scoped those layers without reordering them. - 725 tests pass, lint clean, typecheck 0 errors. ## Caveats 1. Driven against a **seeded fake worker**, so the reconnect legitimately fails server-side with a 409 (no container). This proves the button now *fires* correctly; it does not prove a reconnect against a live container succeeds end-to-end. The client path is identical either way, but a real pane is the honest last check. 2. Related but out of scope: when a reconnect *does* fail server-side (container stopped → 409/401), the overlay just sits there reading "disconnected" with no feedback — which would look to an operator like "the button doesn't work" all over again. Worth a follow-up.
fix(terminal): Reconnect button swallowed by xterm's link-layer canvas
All checks were successful
ci / nexus (pull_request) Successful in 3m55s
ci / images (pull_request) Successful in 7m18s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 16s
8be2663c4e
The Reconnect button in the disconnect overlay never received a click.
The button was fine; the click never reached it.

xterm's webgl addon creates an `xterm-link-layer` canvas that spans the
whole pane with an inline `z-index: 2` and `pointer-events: auto`. Nothing
between that canvas and the overlay creates a stacking context — every
ancestor (.xterm-screen, .xterm, .terminal-mount, .terminal-body) is
`z-index: auto` — so xterm's internal z-indices escaped their container and
competed directly with `.disconnected-overlay`'s `z-index: auto` (= 0). The
canvas won every hit test.

The canvas is transparent (it only paints link underlines), which is why the
failure looked so odd: the overlay rendered perfectly, and clicking dead
centre did nothing. `elementFromPoint()` at the button's centre returned
`xterm-link-layer`, and with the WebSocket constructor instrumented, a click
produced zero connection attempts.

Fix: `isolation: isolate` on .terminal-mount, scoping xterm's layers to the
mount rather than bidding the overlay above a number xterm owns (its CSS
already reaches 11 for the scrollbar, and that is free to change on upgrade).

Verified in a real browser (the suite is node-only and cannot catch this):
before, 0 WS attempts on click; after, 3 clicks -> 3 attempts. With the
overlay hidden — the normal connected state — the centre hit target reverts
to `xterm-link-layer`, so the terminal still receives its own clicks, and
xterm's internal layer order (5, 2, auto) is unchanged.
lz merged commit 6824411267 into main 2026-07-16 22:15:24 +02:00
lz deleted branch fix/reconnect 2026-07-16 22:15:26 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!71
No description provided.