FN-7298: fix mobile terminal font metric refits
Rework mobile terminal preference refits so small font sizes keep correct xterm cell metrics. - Wait for settled terminal font metrics after live preference changes before refitting and refreshing xterm surfaces. - Guard asynchronous metric waits from overwriting newer SessionTerminal font preferences. - Cover keyboard-open 10px mobile terminal behavior and stale preference races with regression tests. - Add a patch changeset for the published Fusion CLI package. Files changed: .../fn-7298-mobile-terminal-character-spacing.md | 7 +++ .../dashboard/app/components/SessionTerminal.tsx | 58 +++++++++++++++---- .../dashboard/app/components/TerminalModal.tsx | 59 ++++++++++++++++---- .../__tests__/SessionTerminal.mobile.test.tsx | 50 +++++++++++++++++ .../components/__tests__/SessionTerminal.test.tsx | 16 +++++- .../components/__tests__/TerminalModal.test.tsx | 65 ++++++++++++++++++++++ 6 files changed, 232 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7298 Fusion-Task-Lineage: b1d6dbf1-8f2c-41e9-b7ee-aa758355345b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7298-mobile-terminal-character-spacing.md
Normal file
7
.changeset/fn-7298-mobile-terminal-character-spacing.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix mobile terminal character spacing after small font-size changes.
|
||||
category: fix
|
||||
dev: Reapplies settled xterm font metrics for TerminalModal and SessionTerminal at 10px keyboard-open states.
|
||||
@@ -270,6 +270,13 @@ export function SessionTerminal({
|
||||
if (showConfirmAdvance) setAdvanceDismissed(false);
|
||||
}, [showConfirmAdvance, sessionId]);
|
||||
|
||||
const sendResizeMessage = useCallback((cols: number, rows: number) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyLiveTerminalPreferences = useCallback(() => {
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) {
|
||||
@@ -277,7 +284,8 @@ export function SessionTerminal({
|
||||
}
|
||||
|
||||
const terminalPreferences = readTerminalPreferences();
|
||||
terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
||||
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
||||
terminal.options.fontFamily = resolvedFontFamily;
|
||||
containerRef.current?.style.setProperty(
|
||||
"--terminal-glyph-font-family",
|
||||
resolveTerminalGlyphFontFamily(terminalPreferences.fontFamily),
|
||||
@@ -288,10 +296,43 @@ export function SessionTerminal({
|
||||
|
||||
try {
|
||||
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
||||
sendResizeMessage(terminal.cols, terminal.rows);
|
||||
} catch {
|
||||
/* ignore transient measure failures */
|
||||
}
|
||||
}, [canAcceptInput]);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-30-13:22:
|
||||
SessionTerminal shares TerminalModal's mobile 10px font-size invariant through the same preference storage. Storage-driven font changes must wait for the symbols-free measured stack, then refit, send one resize frame, and refresh so embedded CLI attach surfaces do not keep stale wide ASCII cells after the soft keyboard has constrained the viewport.
|
||||
|
||||
FNXC:Terminal 2026-06-30-22:47:
|
||||
Async font-metric waits can resolve out of order when a second terminal preference change lands first. Reapply only if the currently mounted xterm options still match the preference snapshot that scheduled this wait, preserving the latest small-font cell metrics instead of resurrecting stale spacing.
|
||||
*/
|
||||
void waitForTerminalFontMetrics(terminalPreferences.fontSize, resolvedFontFamily).then(
|
||||
(fontMetricsSettled) => {
|
||||
if (
|
||||
!fontMetricsSettled ||
|
||||
xtermRef.current !== terminal ||
|
||||
terminal.options.fontSize !== terminalPreferences.fontSize ||
|
||||
terminal.options.fontFamily !== resolvedFontFamily
|
||||
) {
|
||||
return;
|
||||
}
|
||||
terminal.options.fontFamily = resolvedFontFamily;
|
||||
terminal.options.fontSize = terminalPreferences.fontSize;
|
||||
try {
|
||||
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
||||
sendResizeMessage(terminal.cols, terminal.rows);
|
||||
terminal.refresh(0, Math.max(0, terminal.rows - 1));
|
||||
} catch {
|
||||
/* ignore teardown or transient measure failures */
|
||||
}
|
||||
},
|
||||
() => {
|
||||
/* FontFaceSet failures are non-fatal; the immediate fit above remains. */
|
||||
},
|
||||
);
|
||||
}, [canAcceptInput, sendResizeMessage]);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-17-01:05:
|
||||
@@ -322,12 +363,7 @@ export function SessionTerminal({
|
||||
let unackedBytes = 0;
|
||||
setTicketReadOnly(null);
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
const sendResize = sendResizeMessage;
|
||||
|
||||
const ackBytes = (n: number) => {
|
||||
unackedBytes += n;
|
||||
@@ -440,7 +476,9 @@ export function SessionTerminal({
|
||||
!fontMetricsSettled ||
|
||||
disposed ||
|
||||
xtermRef.current !== term ||
|
||||
fitAddonRef.current !== fitAddon
|
||||
fitAddonRef.current !== fitAddon ||
|
||||
term.options.fontSize !== terminalPreferences.fontSize ||
|
||||
term.options.fontFamily !== resolvedFontFamily
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -579,7 +617,7 @@ export function SessionTerminal({
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [sessionId, readOnly, mode, projectId]);
|
||||
}, [sessionId, readOnly, mode, projectId, sendResizeMessage]);
|
||||
|
||||
const replayLabel = useMemo(() => {
|
||||
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
|
||||
|
||||
@@ -1646,23 +1646,60 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
xtermRef.current.options.cursorStyle = terminalPreferences.cursorStyle;
|
||||
xtermRef.current.options.cursorBlink = terminalPreferences.cursorBlink;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// Defer fit until the next frame so layout reflects the new font metrics
|
||||
// before FitAddon measures rows/cols. Reuse pendingFitRef so font changes and
|
||||
// visualViewport-triggered fits are coalesced into a single scheduled fit.
|
||||
if (pendingFitRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFitRef.current);
|
||||
pendingFitRef.current = null;
|
||||
}
|
||||
const scheduleRefit = () => {
|
||||
if (pendingFitRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFitRef.current);
|
||||
pendingFitRef.current = null;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
pendingFitRef.current = null;
|
||||
refitTerminal();
|
||||
});
|
||||
pendingFitRef.current = frame;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
pendingFitRef.current = null;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
refitTerminal();
|
||||
xtermRef.current?.refresh?.(0, Math.max(0, xtermRef.current.rows - 1));
|
||||
});
|
||||
pendingFitRef.current = frame;
|
||||
return frame;
|
||||
};
|
||||
|
||||
const immediateFrame = scheduleRefit();
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-30-13:18:
|
||||
The mobile screenshot recurrence happens at the visible 10px setting with the soft keyboard already open. A live font-size preference change must wait for the symbols-free measured stack to settle, then reapply xterm font options, refit, resize, and refresh; otherwise canvas/DOM metrics can keep the old wider cells until an unfold/orientation event forces a later measurement.
|
||||
*/
|
||||
void waitForTerminalFontMetrics(terminalPreferences.fontSize, resolvedFontFamily).then(
|
||||
(fontMetricsSettled) => {
|
||||
if (
|
||||
cancelled ||
|
||||
!fontMetricsSettled ||
|
||||
!xtermRef.current ||
|
||||
xtermRef.current.options.fontSize !== terminalPreferences.fontSize ||
|
||||
xtermRef.current.options.fontFamily !== resolvedFontFamily
|
||||
) {
|
||||
return;
|
||||
}
|
||||
xtermRef.current.options.fontFamily = resolvedFontFamily;
|
||||
xtermRef.current.options.fontSize = terminalPreferences.fontSize;
|
||||
scheduleRefit();
|
||||
},
|
||||
() => {
|
||||
// FontFaceSet failures are non-fatal; the immediate frame above still
|
||||
// applies the current preference and keeps terminal input usable.
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (pendingFitRef.current === frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
cancelled = true;
|
||||
if (immediateFrame !== undefined && pendingFitRef.current === immediateFrame) {
|
||||
cancelAnimationFrame(immediateFrame);
|
||||
pendingFitRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,6 +29,16 @@ vi.mock("@xterm/addon-webgl", () => ({
|
||||
const apiMock = vi.fn();
|
||||
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
|
||||
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
|
||||
const terminalPreferenceMocks = vi.hoisted(() => ({
|
||||
waitForTerminalFontMetrics: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
vi.mock("../../utils/terminalPreferences", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../utils/terminalPreferences")>();
|
||||
return {
|
||||
...actual,
|
||||
waitForTerminalFontMetrics: terminalPreferenceMocks.waitForTerminalFontMetrics,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
|
||||
class FakeWS {
|
||||
@@ -145,6 +155,8 @@ beforeEach(() => {
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
mockFitAddon.fit.mockClear();
|
||||
terminalPreferenceMocks.waitForTerminalFontMetrics.mockReset();
|
||||
terminalPreferenceMocks.waitForTerminalFontMetrics.mockResolvedValue(true);
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true); // mobile by default
|
||||
@@ -380,6 +392,44 @@ describe("SessionTerminal (mobile)", () => {
|
||||
expect(screen.getByTestId("cli-key-arrow-left")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-key-arrow-right")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not let an older mobile font-metric wait overwrite newer terminal preferences", async () => {
|
||||
await renderMobile();
|
||||
await waitFor(() => expect(terminalPreferenceMocks.waitForTerminalFontMetrics).toHaveBeenCalled());
|
||||
|
||||
const pendingFontWaits: Array<(value: boolean) => void> = [];
|
||||
terminalPreferenceMocks.waitForTerminalFontMetrics.mockReset();
|
||||
terminalPreferenceMocks.waitForTerminalFontMetrics.mockImplementation(
|
||||
() => new Promise<boolean>((resolve) => pendingFontWaits.push(resolve)),
|
||||
);
|
||||
|
||||
const setPreferenceFontSize = (fontSize: number) => {
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize }),
|
||||
);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: TERMINAL_PREFERENCES_KEY }));
|
||||
};
|
||||
|
||||
setPreferenceFontSize(10);
|
||||
expect(mockTerm.options.fontSize).toBe(10);
|
||||
setPreferenceFontSize(14);
|
||||
expect(mockTerm.options.fontSize).toBe(14);
|
||||
expect(terminalPreferenceMocks.waitForTerminalFontMetrics).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
pendingFontWaits[0]?.(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockTerm.options.fontSize).toBe(14);
|
||||
|
||||
await act(async () => {
|
||||
pendingFontWaits[1]?.(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockTerm.options.fontSize).toBe(14);
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
|
||||
|
||||
@@ -378,15 +378,25 @@ describe("SessionTerminal", () => {
|
||||
});
|
||||
|
||||
it("live-applies font and cursor preference changes from storage events", async () => {
|
||||
const fontLoad = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: {
|
||||
load: fontLoad,
|
||||
ready: Promise.resolve(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
mockFitAddon.fit.mockClear();
|
||||
mockTerm.refresh.mockClear();
|
||||
fontLoad.mockClear();
|
||||
|
||||
window.localStorage.setItem(
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
fontFamily: "jetbrains-mono",
|
||||
fontSize: 20,
|
||||
fontSize: 10,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: false,
|
||||
renderer: "canvas",
|
||||
@@ -397,11 +407,13 @@ describe("SessionTerminal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockTerm.options).toMatchObject({
|
||||
fontFamily: resolveTerminalFontFamily("jetbrains-mono"),
|
||||
fontSize: 20,
|
||||
fontSize: 10,
|
||||
cursorStyle: "bar",
|
||||
cursorBlink: false,
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(fontLoad).toHaveBeenCalledWith(expect.stringContaining("10px")));
|
||||
await waitFor(() => expect(mockTerm.refresh).toHaveBeenCalledWith(0, mockTerm.rows - 1));
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
expect(mockFitAddon.fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4665,6 +4665,8 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
value: savedDocumentElementClientHeight,
|
||||
configurable: true,
|
||||
});
|
||||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -4762,6 +4764,69 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
return { listeners, mockVV, initialHeight };
|
||||
}
|
||||
|
||||
it("remeasures the mobile keyboard-open terminal when reducing the persisted font size to 10px", async () => {
|
||||
const { listeners } = simulateIOSSafari(true, 300);
|
||||
const fontLoad = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: {
|
||||
load: fontLoad,
|
||||
ready: Promise.resolve(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 667,
|
||||
configurable: true,
|
||||
});
|
||||
const onDataListeners: Array<(data: string) => void> = [];
|
||||
const resizeForSmallFont = vi.fn();
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||||
connectionStatus: "connected",
|
||||
resize: resizeForSmallFont,
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
onDataListeners.push(cb);
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("14px"));
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||||
});
|
||||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||||
|
||||
fontLoad.mockClear();
|
||||
resizeForSmallFont.mockClear();
|
||||
const decrease = screen.getByTestId("terminal-font-size-decrease");
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
fireEvent.click(decrease);
|
||||
}
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||||
await waitFor(() => {
|
||||
expect(fontLoad).toHaveBeenCalledWith(expect.stringContaining("10px"));
|
||||
});
|
||||
await waitFor(() => expect(mockTerminalInstance.options.fontSize).toBe(10));
|
||||
await waitFor(() => expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, 23));
|
||||
await waitFor(() => expect(resizeForSmallFont).toHaveBeenCalledWith(80, 24));
|
||||
|
||||
act(() => {
|
||||
for (const cb of onDataListeners) {
|
||||
cb("❯ pnpm build\r\n@fusion/dashboard build complete main\r\n");
|
||||
}
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||||
});
|
||||
|
||||
it("keeps initial folded keyboard-open terminal metrics before any unfold repair", async () => {
|
||||
const { listeners } = simulateIOSSafari(true, 300);
|
||||
const onDataListeners: Array<(data: string) => void> = [];
|
||||
|
||||
Reference in New Issue
Block a user