diff --git a/.changeset/fn-7692-mobile-terminal-blank-screen.md b/.changeset/fn-7692-mobile-terminal-blank-screen.md deleted file mode 100644 index e5d8b8bc6e..0000000000 --- a/.changeset/fn-7692-mobile-terminal-blank-screen.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -summary: Fix the terminal rendering blank on mobile even though the shell prompt already loaded. -category: fix -dev: Adds `guardAgainstCollapsedTerminalScreen` (app/utils/terminalPreferences.ts), wired into TerminalModal and SessionTerminal. On the mobile fullscreen layout xterm could measure a 0-width character cell, so FitAddon.fit() proposed 0 columns/rows and `.xterm-screen` (plus the WebGL canvas) collapsed to 0x0 — prompt bytes arrived but painted into a zero-size box. The guard watches for that collapsed state (container has width, `.xterm-screen` does not) and forces a genuine remeasure+fit, re-driven by a ResizeObserver until the screen has a real width. Renderer-agnostic and bounded. Recurrence of FN-7620/FN-7686. diff --git a/.changeset/fn-7693-mobile-terminal-blank-screen.md b/.changeset/fn-7693-mobile-terminal-blank-screen.md new file mode 100644 index 0000000000..7301317b5d --- /dev/null +++ b/.changeset/fn-7693-mobile-terminal-blank-screen.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the terminal rendering blank on mobile even though the shell prompt already loaded. +category: fix +dev: The global mobile `@media (max-width: 768px) { * { max-width: 100% } }` reset in styles.css also matched xterm's hidden character-measurement subtree (`.xterm-helpers` / `.xterm-char-measure-element`). That subtree's containing block is a 0x0 box, so `max-width: 100%` resolved to 0 and hard-capped xterm's character-cell measurement at 0 — FitAddon.fit() then proposed 0 columns and `.xterm-screen` (plus the WebGL canvas) collapsed to 0x0, so the prompt painted into a zero-size box. Exempt xterm's measurement subtree from that reset (`max-width: none`). Mobile-only; desktop was unaffected. Recurrence of FN-7620/FN-7686. diff --git a/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md b/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md index 02ed7fd912..19369dd083 100644 --- a/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md +++ b/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md @@ -1,74 +1,88 @@ --- -title: "Mobile terminal renders blank because xterm's screen collapses to 0x0" +title: "Mobile terminal renders blank: global `* { max-width: 100% }` collapses xterm's char measurement" date: 2026-07-08 category: ui-bugs -module: packages/dashboard/app/utils/terminalPreferences.ts +module: packages/dashboard/app/styles.css problem_type: rendering component: embedded_terminal applies_when: "The standalone (TerminalModal) or task-session (SessionTerminal) terminal shows nothing on mobile even though the WebSocket says Connected and the prompt has arrived." symptoms: - - "Mobile terminal is blank for many seconds (or indefinitely) after opening" + - "Mobile terminal is blank after opening; desktop renders fine" - "Header shows Connected; no prompt text is visible" - ".xterm-screen has style width:0px;height:0px while the .xterm container has a real size" -root_cause: xterm_charsize_service_measured_zero_width_cell_on_mobile_layout -resolution_type: measurement_validity_guard + - ".xterm-char-measure-element measures 0px width even though an identical monospace span measures ~295px" +root_cause: mobile_universal_max_width_100pct_caps_xterm_char_measure_element_at_zero +resolution_type: css_exemption severity: high related_components: + - packages/dashboard/app/styles.css - packages/dashboard/app/components/TerminalModal.tsx - packages/dashboard/app/components/SessionTerminal.tsx -tags: [terminal, xterm, mobile, fit-addon, char-size, blank-screen, fn-7620, fn-7686, fn-7692] +tags: [terminal, xterm, mobile, css, max-width, char-size, blank-screen, fn-7620, fn-7686, fn-7693] --- -# Mobile terminal renders blank: xterm screen collapses to 0x0 +# Mobile terminal renders blank: universal `max-width: 100%` collapses xterm's character measurement ## Problem -On the mobile fullscreen terminal layout the terminal opens, the WebSocket connects, the shell prompt -data streams in and is written into xterm's row DOM — but the terminal is visibly blank. It is NOT a +On the mobile terminal layout the terminal opens, the WebSocket connects, the shell prompt data +streams in and is written into xterm's row DOM — but the terminal is visibly blank. It is NOT a network, PTY, or login-shell latency problem (measured live: `POST /terminal/sessions` 3ms, WS first prompt bytes ~215ms, desktop renders <1s). ## Root cause -`terminal.open()` + `FitAddon.fit()` can run while xterm's `CharSizeService` resolves the character -cell to **0 width**. `FitAddon.proposeDimensions()` then yields 0 columns/rows and `.xterm-screen` -(and, on desktop, the WebGL renderer canvas) collapses to **0x0**. The prompt is painted into a -zero-size box, so it is invisible. Reproduced live via mobile emulation: the `.xterm` container -measured 385x758 while `.xterm-screen` stayed `width:0px;height:0px` and xterm's own -`.xterm-char-measure-element` read 0 — while an identical monospace span in the same container measured -~295px. So the correct measurement is achievable and xterm is holding a stale 0. +`styles.css` has a global mobile reset to prevent horizontal overflow: -The bug is: +```css +@media (max-width: 768px) { + * { max-width: 100%; max-inline-size: 100%; } +} +``` -- **Renderer-independent** — reproduced on both the DOM renderer (touch/mobile) and the WebGL renderer. -- **Mobile-layout-specific** — desktop widths render immediately. -- **Not self-healing** — a `window.resize` and a font-size change both re-fit but do not recover, because - every prior guard validates the *container* width (`clientWidth > 0`) and font load - (`waitForTerminalFontMetrics`), never the *resulting* measured screen/cell width, and a single 0 - measurement is cached and never re-validated. +This universal `* { max-width: 100% }` also matches xterm's hidden character-measurement subtree — +`.xterm-helpers` and its `.xterm-char-measure-element`. That subtree's containing block +(`.xterm-helpers`) is a 0x0 absolutely-positioned box, so `max-width: 100%` resolves to **`max-width: +0`** and hard-caps the measurement element at 0 width. xterm's `CharSizeService` therefore reads a +0-width character cell, `FitAddon.proposeDimensions()` yields 0 columns/rows, and `.xterm-screen` (plus +the WebGL renderer canvas) collapses to **0x0** — the prompt is painted into a zero-size box. + +Reproduced live via mobile emulation: `.xterm-char-measure-element` measured 0 while an identical +monospace span in the same `.xterm` container measured ~295px; setting `max-width: none` on the measure +element immediately restored ~295px, and reopening the terminal with the exemption active rendered the +prompt with `.xterm-screen` sized 369x760. + +The bug is **mobile-only** because the reset is inside a `max-width: 768px` media query — which is +exactly why the terminal renders fine at desktop widths. + +### Why prior remeasure-based attempts failed + +FN-7620/FN-7686 (and an initial FN-7693 attempt) tried to force xterm to remeasure/refit after mount. +That can never work here: the CSS re-caps the measurement element to 0 width on **every** remeasure, so +a resize, a font-size change, and a forced `CharSizeService` remeasure all still read 0. The fix must +remove the CSS cap, not re-run the measurement. ## Fix -`guardAgainstCollapsedTerminalScreen(container, terminal, fit, fontFamily)` in -`app/utils/terminalPreferences.ts`, armed by both TerminalModal and SessionTerminal right after their -initial fit: +Exempt xterm's measurement subtree from the mobile universal reset (in the same `@media` block in +`styles.css`): -- Detects the collapsed state: container has a width but `.xterm-screen` does not - (`isTerminalScreenCollapsed`). -- Forces a genuine `CharSizeService` remeasure via `withDomBasedTerminalCharacterMeasurement(() => - forceTerminalFontRemeasure(...))` followed by `fit()`. -- Re-driven by a `ResizeObserver` so it re-attempts exactly when the mobile modal/keyboard geometry - finally settles (it waits, rather than giving up, while the container itself is not yet measurable). -- Bounded by `maxAttempts` so it never spins, and a no-op once the screen has a non-zero width. -- Tied to the xterm instance lifetime — disposed on every re-init/close/reinitialize path. +```css +.xterm-helpers, +.xterm-helpers *, +.xterm-char-measure-element { + max-width: none !important; + max-inline-size: none !important; +} +``` + +`.xterm-helpers` is xterm's own class, so this covers both terminal surfaces (TerminalModal and +SessionTerminal) without per-component changes. ## Verification -- Unit tests in `app/utils/__tests__/terminalPreferences.test.ts` - (describe "guardAgainstCollapsedTerminalScreen"): classifies collapse only when the container is - measurable; forces remeasure+fit and stops once the screen reports a width; waits while the container - is not yet measurable and recovers on the ResizeObserver relayout; stays bounded when the screen never - recovers; no-op when already healthy. -- Physical-device note: the root cause and DOM signature were reproduced in the automation browser via +- Live: with the exemption active, `.xterm-char-measure-element` measures ~295px (was 0) and + `.xterm-screen` gets a real width (was 0x0), rendering the prompt on the mobile viewport. +- Physical-device note: root cause and fix were reproduced/validated in the automation browser via mobile emulation (393px, iPhone UA, forced touch), not a physical iPhone. Confirm on a real device when possible. diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 4725cfcb8a..f81c358432 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -11,13 +11,11 @@ import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; import { TERMINAL_PREFERENCES_KEY, forceTerminalFontRemeasure, - guardAgainstCollapsedTerminalScreen, readTerminalPreferences, resolveTerminalFontFamily, resolveTerminalGlyphFontFamily, waitForTerminalFontMetrics, withDomBasedTerminalCharacterMeasurement, - type TerminalScreenGuardHandle, } from "../utils/terminalPreferences"; /** @@ -194,8 +192,6 @@ export function SessionTerminal({ const containerRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); - /** FN-7692: active collapsed-screen guard for the current xterm; disposed on teardown. */ - const screenGuardRef = useRef(null); const wsRef = useRef(null); const [postureTooltipOpen, setPostureTooltipOpen] = useState(false); @@ -512,28 +508,6 @@ export function SessionTerminal({ /* container not measurable yet */ } - /* - FNXC:Terminal 2026-07-08-16:20: - FN-7692: arm the collapsed-screen guard on this attach surface too. The task-session terminal - opens inside the same mobile fullscreen layout, so its initial open()+fit() can likewise measure a - 0-width cell and collapse .xterm-screen to 0x0 — prompt bytes stream in but paint into a zero-size - box (blank terminal). Force a genuine remeasure+fit (re-driven by a ResizeObserver as the layout - settles) until the screen has a real width. Tied to this xterm instance; disposed on teardown. - */ - screenGuardRef.current?.dispose(); - if (containerRef.current) { - screenGuardRef.current = guardAgainstCollapsedTerminalScreen( - containerRef.current, - term, - () => { - if (disposed || xtermRef.current !== term) return; - (fitAddon as unknown as { fit: () => void }).fit(); - sendResize(term.cols, term.rows); - }, - resolvedFontFamily, - ); - } - void (async () => { const fontMetricsSettled = await waitForTerminalFontMetrics( terminalPreferences.fontSize, @@ -710,8 +684,6 @@ export function SessionTerminal({ } wsRef.current = null; } - screenGuardRef.current?.dispose(); // FN-7692: stop the collapsed-screen watchdog on teardown - screenGuardRef.current = null; const term = xtermRef.current; if (term) { try { diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 3c4b30b5dd..dd2b73dc9c 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -42,7 +42,6 @@ import { TERMINAL_FONT_FAMILY_PRESETS, clampTerminalFontSize, forceTerminalFontRemeasure, - guardAgainstCollapsedTerminalScreen, readTerminalPreferences, resolveTerminalFontFamily, resolveTerminalGlyphFontFamily, @@ -51,7 +50,6 @@ import { writeTerminalPreferences, type TerminalPreferences, type TerminalRenderer, - type TerminalScreenGuardHandle, } from "../utils/terminalPreferences"; import "@xterm/xterm/css/xterm.css"; @@ -608,12 +606,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const initializedRendererRef = useRef(terminalPreferences.renderer); /** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */ const pendingFitRef = useRef(null); - /** - * FN-7692: active collapsed-screen guard for the current xterm instance. Disposed and re-armed on - * every (re)init so the mobile 0x0-screen watchdog never outlives its terminal. See - * `guardAgainstCollapsedTerminalScreen`. - */ - const screenGuardRef = useRef(null); /* FNXC:Terminal 2026-06-22-09:00: Docked-resize, floating-drag, and floating-resize each attach pointer listeners and schedule a rAF for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect. @@ -1396,8 +1388,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG if (xtermRef.current && (xtermInitializedRef.current !== currentSessionId || projectChanged)) { xtermRef.current.dispose(); xtermRef.current = null; - screenGuardRef.current?.dispose(); // FN-7692: guard is tied to the disposed xterm instance - screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -1568,28 +1558,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermInitializedRef.current = currentSessionId; void remeasureAfterTerminalFontLoad(currentSessionId, terminal, fitAddon); - /* - FNXC:Terminal 2026-07-08-16:20: - FN-7692: arm the collapsed-screen guard for this instance. On the mobile fullscreen layout the - initial open()+fit() above can measure a 0-width cell and collapse .xterm-screen to 0x0, leaving - the terminal blank even though the prompt already streamed in. The guard forces a genuine - remeasure+fit (re-driven by a ResizeObserver as the mobile modal/keyboard geometry settles) until - the screen has a real width. Dispose any prior guard first so it is tied to this xterm's lifetime. - */ - screenGuardRef.current?.dispose(); - if (terminalRef.current) { - screenGuardRef.current = guardAgainstCollapsedTerminalScreen( - terminalRef.current, - terminal, - () => { - if (xtermInitializedRef.current !== currentSessionId) return; - fitAddon.fit(); - resizeRef.current?.(terminal.cols, terminal.rows); - }, - fontFamilyAtInit, - ); - } - // If the virtual keyboard opened while xterm was still in async // initialization for this tab, force a post-init fit so this new // session uses the already-constrained mobile modal height. @@ -1735,8 +1703,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } - screenGuardRef.current?.dispose(); // FN-7692: stop the collapsed-screen watchdog on close - screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -2123,8 +2089,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } - screenGuardRef.current?.dispose(); // FN-7692: re-armed by the re-running init effect - screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -2190,8 +2154,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } - screenGuardRef.current?.dispose(); // FN-7692: drop the watchdog before a manual re-init - screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 4436a1f9bb..1a7ed512fc 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -3484,6 +3484,29 @@ Toast text must contrast its status background across every dashboard theme and max-inline-size: 100%; } + /* + FNXC:Terminal 2026-07-08-17:10: + FN-7693 (real root cause of the mobile blank terminal — supersedes the FN-7620/FN-7686 remeasure + attempts): the universal `* { max-width: 100% }` reset above ALSO matches xterm's hidden character- + measurement subtree (`.xterm-helpers` and its `.xterm-char-measure-element`). That subtree's containing + block (`.xterm-helpers`) is a 0x0 absolutely-positioned box, so `max-width: 100%` resolves to + `max-width: 0` and hard-caps xterm's character-cell measurement at 0. FitAddon.fit() then proposes 0 + columns/rows and `.xterm-screen` (plus the WebGL renderer canvas) collapses to 0x0 — the shell prompt + DOES stream in and IS written into xterm's row DOM, but paints into a zero-size box, so the terminal is + blank. This bites ONLY on mobile because the reset is mobile-scoped (this @media block), which is why the + terminal renders fine at desktop widths. Exempt xterm's measurement subtree so the cell width measures + its true content width. Verified live via mobile emulation: with this exemption + `.xterm-char-measure-element` measures ~295px (was 0) and `.xterm-screen` gets a real width (was 0x0), + rendering the prompt. Applies to both terminal surfaces (TerminalModal + SessionTerminal) since + `.xterm-helpers` is xterm's own class. + */ + .xterm-helpers, + .xterm-helpers *, + .xterm-char-measure-element { + max-width: none !important; + max-inline-size: none !important; + } + pre, code, .code-block { diff --git a/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts index 9c9e42685b..70431c3b95 100644 --- a/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts +++ b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts @@ -1,12 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_TERMINAL_PREFERENCES, LEGACY_TERMINAL_FONT_SIZE_KEY, TERMINAL_PREFERENCES_KEY, XTERM_FONT_FAMILY, forceTerminalFontRemeasure, - guardAgainstCollapsedTerminalScreen, - isTerminalScreenCollapsed, readTerminalPreferences, waitForTerminalFontMetrics, writeTerminalPreferences, @@ -171,138 +169,4 @@ describe("terminalPreferences", () => { expect(terminal.options.fontFamily).toBe("system-mono, monospace"); }); }); - - /* - FN-7692 regression: the mobile terminal rendered blank because xterm's `.xterm-screen` collapsed to - 0x0 (character cell measured 0) even though the prompt data had already arrived. These tests pin the - guard that detects the collapsed state (container has width, screen does not), forces a genuine - remeasure + fit until the screen has a real width, waits (does not give up) while the container is not - yet measurable, and stays bounded when the screen never recovers. Symptom under test = a screen stuck - at width 0; assertion it is gone = a forced remeasure+fit runs until the screen reports a width. - */ - describe("guardAgainstCollapsedTerminalScreen", () => { - let rafQueue: Array<() => void>; - let resizeObservers: Array<{ cb: () => void; disconnected: boolean }>; - - beforeEach(() => { - rafQueue = []; - resizeObservers = []; - vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { - rafQueue.push(cb); - return rafQueue.length; - }); - vi.stubGlobal("cancelAnimationFrame", () => {}); - vi.stubGlobal( - "ResizeObserver", - class { - constructor(cb: () => void) { - resizeObservers.push({ cb, disconnected: false }); - } - observe(): void {} - disconnect(): void { - const entry = resizeObservers[resizeObservers.length - 1]; - if (entry) entry.disconnected = true; - } - }, - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function flushRaf(max = 200): void { - let n = 0; - while (rafQueue.length && n++ < max) { - const cb = rafQueue.shift(); - cb?.(); - } - } - - function makeContainer(init: { containerWidth: number; screenWidth: number }) { - const state = { ...init }; - const screen = { - getBoundingClientRect: () => ({ width: state.screenWidth }) as DOMRect, - }; - const container = { - getBoundingClientRect: () => ({ width: state.containerWidth }) as DOMRect, - querySelector: (sel: string) => (sel === ".xterm-screen" ? screen : null), - } as unknown as HTMLElement; - return { container, state }; - } - - it("classifies collapse only when the container is measurable but the screen is not", () => { - expect(isTerminalScreenCollapsed(385, 0)).toBe(true); - expect(isTerminalScreenCollapsed(385, 300)).toBe(false); - expect(isTerminalScreenCollapsed(0, 0)).toBe(false); - }); - - it("forces a remeasure + fit and stops once the screen reports a width", () => { - const { container, state } = makeContainer({ containerWidth: 385, screenWidth: 0 }); - const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; - let fitCalls = 0; - // A genuine remeasure+fit recovers the collapsed screen on the first attempt. - const fit = () => { - fitCalls += 1; - state.screenWidth = 300; - }; - - guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY); - - expect(fitCalls).toBe(1); - expect(state.screenWidth).toBe(300); - // Recovered synchronously on the initial kick — nothing left scheduled. - expect(rafQueue.length).toBe(0); - }); - - it("does not act while the container itself is not yet measurable, then recovers on relayout", () => { - const { container, state } = makeContainer({ containerWidth: 0, screenWidth: 0 }); - const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; - let fitCalls = 0; - const fit = () => { - fitCalls += 1; - state.screenWidth = 300; - }; - - guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY); - // Container has no width yet (mobile modal not settled): must wait, not give up. - expect(fitCalls).toBe(0); - expect(resizeObservers[0]?.disconnected).toBe(false); - - // Container becomes measurable; the ResizeObserver re-drives the guard. - state.containerWidth = 385; - resizeObservers[0].cb(); - - expect(fitCalls).toBe(1); - expect(state.screenWidth).toBe(300); - }); - - it("stays bounded (never spins) when the screen never recovers", () => { - const { container } = makeContainer({ containerWidth: 385, screenWidth: 0 }); - const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; - let fitCalls = 0; - const fit = () => { - fitCalls += 1; // never sets a width — the collapse persists - }; - - guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY, { - maxAttempts: 3, - }); - flushRaf(); - - expect(fitCalls).toBe(3); - expect(rafQueue.length).toBe(0); - }); - - it("is a no-op when the screen already has a width at mount", () => { - const { container } = makeContainer({ containerWidth: 385, screenWidth: 300 }); - const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; - let fitCalls = 0; - - guardAgainstCollapsedTerminalScreen(container, terminal, () => (fitCalls += 1), XTERM_FONT_FAMILY); - - expect(fitCalls).toBe(0); - expect(rafQueue.length).toBe(0); - }); - }); }); diff --git a/packages/dashboard/app/utils/terminalPreferences.ts b/packages/dashboard/app/utils/terminalPreferences.ts index 13e1bfc9ba..38e2aac000 100644 --- a/packages/dashboard/app/utils/terminalPreferences.ts +++ b/packages/dashboard/app/utils/terminalPreferences.ts @@ -294,127 +294,6 @@ export function withDomBasedTerminalCharacterMeasurement(fn: () => T): T { } } -/* -FNXC:Terminal 2026-07-08-16:20: -FN-7692 (mobile blank-terminal, recurrence of FN-7620/FN-7686): Both terminal surfaces (standalone -TerminalModal and task-session SessionTerminal) can run `terminal.open()` + `FitAddon.fit()` on the -mobile fullscreen layout while xterm's `CharSizeService` resolves the character cell to 0 width. When -the cell width is 0, `FitAddon.proposeDimensions()` yields 0 columns/rows and `.xterm-screen` (plus the -WebGL renderer canvas) collapses to 0x0 — the shell prompt DOES arrive over the WebSocket and IS written -into xterm's row DOM, but it is painted into a zero-size box, so the terminal looks permanently blank -("view renders but no output for many seconds" on mobile). Reproduced live: `.xterm` container measured -385x758 while `.xterm-screen` stayed `width:0px;height:0px` and xterm's own `.xterm-char-measure-element` -read 0 even though an identical monospace span in the same container measured ~295px — i.e. the correct -measurement is achievable and xterm is holding a stale 0. The bug is renderer-independent (repro'd on both -DOM and WebGL renderers) and mobile-layout-specific (desktop widths render immediately). - -Every prior guard validates the CONTAINER width (`clientWidth > 0`) and font load -(`waitForTerminalFontMetrics`), but none validate that the RESULTING measured screen/cell width is -non-zero, and a single 0 measurement is cached and never re-validated. `guardAgainstCollapsedTerminalScreen` -closes that gap: while the container has a width but `.xterm-screen` does not, it forces a genuine -CharSizeService remeasure (DOM-strategy `forceTerminalFontRemeasure`) followed by a `fit()`, re-driven by a -`ResizeObserver` so it re-attempts exactly when the mobile modal/keyboard geometry finally settles. It is -bounded (`maxAttempts`) so it never spins, and a no-op once the screen has a non-zero width. -*/ - -/** True when the terminal has a laid-out container but its rendered screen collapsed to zero width. */ -export function isTerminalScreenCollapsed(containerWidth: number, screenWidth: number): boolean { - return containerWidth > 0 && screenWidth <= 0; -} - -/** Handle returned by {@link guardAgainstCollapsedTerminalScreen}; call `dispose()` on teardown. */ -export interface TerminalScreenGuardHandle { - dispose: () => void; -} - -/** - * Watch a mounted xterm terminal for the collapsed-screen state (container has width, `.xterm-screen` - * does not) and force a genuine remeasure + fit until the screen is no longer collapsed or `maxAttempts` - * is exhausted. See the FN-7692 note above for the root cause. Renderer-agnostic and bounded. - * - * @param container The `.xterm` host element (or its wrapper) that also contains `.xterm-screen`. - * @param terminal The xterm Terminal (only its mutable `options.fontFamily` is used, to force remeasure). - * @param fit Runs `FitAddon.fit()` (callers also notify the server of the new cols/rows here). - * @param fontFamily The already-resolved xterm font family to land back on after the remeasure sentinel. - */ -export function guardAgainstCollapsedTerminalScreen( - container: HTMLElement, - terminal: { options: { fontFamily?: string } }, - fit: () => void, - fontFamily: string, - options?: { maxAttempts?: number }, -): TerminalScreenGuardHandle { - const maxAttempts = options?.maxAttempts ?? 40; - let attempts = 0; - let disposed = false; - let rafId: number | null = null; - let observer: ResizeObserver | null = null; - - const containerWidth = (): number => container.getBoundingClientRect().width; - const screenWidth = (): number => { - const screen = container.querySelector(".xterm-screen"); - return screen ? screen.getBoundingClientRect().width : 0; - }; - - const dispose = (): void => { - disposed = true; - if (rafId !== null && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(rafId); - } - rafId = null; - observer?.disconnect(); - observer = null; - }; - - const attemptRecovery = (): void => { - if (disposed) return; - // Healthy — the screen has a real width; stop watching. - if (screenWidth() > 0) { - dispose(); - return; - } - // Not yet measurable — the container itself has no width (mobile modal/keyboard geometry has - // not settled). Do NOT give up: wait for the ResizeObserver to re-drive when it becomes - // measurable. Only a screen that stays collapsed WITH a measurable container is the bug. - if (!isTerminalScreenCollapsed(containerWidth(), screenWidth())) { - return; - } - if (attempts >= maxAttempts) { - dispose(); - return; - } - attempts += 1; - try { - // Force xterm's DOM-based CharSizeService strategy for the remeasure so the cell width is - // measured through the same pipeline the DomRenderer paints through (see FN-7603 note above). - withDomBasedTerminalCharacterMeasurement(() => - forceTerminalFontRemeasure(terminal, fontFamily), - ); - fit(); - } catch { - // Ignore transient fit/measure errors during mobile viewport/keyboard transitions. - } - if (!isTerminalScreenCollapsed(containerWidth(), screenWidth())) { - dispose(); - return; - } - if (typeof requestAnimationFrame === "function") { - rafId = requestAnimationFrame(attemptRecovery); - } - }; - - // Re-drive on container relayout: on mobile the width only becomes measurable after the fullscreen - // modal and keyboard CSS vars settle, which is exactly when a fresh remeasure finally succeeds. - if (typeof ResizeObserver !== "undefined") { - observer = new ResizeObserver(() => attemptRecovery()); - observer.observe(container); - } - // Kick once immediately in case the container is already measurable at mount. - attemptRecovery(); - - return { dispose }; -} - function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }