From bdfdf1b11f860621feca0dce43d84a6e88d0583a Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 2 Jul 2026 19:16:37 +0000 Subject: [PATCH] Polish custom image browser terminal UX --- ...vironment-custom-image-terminal-ws.test.ts | 74 +++++++++++++++++-- .../environment-custom-image-terminal-ws.ts | 32 ++++++-- ui/src/pages/CompanyEnvironments.test.tsx | 10 +++ ui/src/pages/CompanyEnvironments.tsx | 63 ++++++++++++++-- 4 files changed, 158 insertions(+), 21 deletions(-) diff --git a/server/src/__tests__/environment-custom-image-terminal-ws.test.ts b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts index 8ffd1742985..b0119bb67d2 100644 --- a/server/src/__tests__/environment-custom-image-terminal-ws.test.ts +++ b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts @@ -94,6 +94,30 @@ async function flushPromises() { await new Promise((resolve) => setImmediate(resolve)); } +async function waitForAssertion(assertion: () => void) { + let lastError: unknown; + for (let i = 0; i < 20; i += 1) { + await flushPromises(); + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (err: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + async function listen(server: HttpServer) { await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); @@ -321,8 +345,9 @@ describe("custom image terminal websocket bridge", () => { }); ws.send(JSON.stringify({ type: "input", data: "echo ok\r" })); - await flushPromises(); - expect(fakeShell.writes).toEqual(["echo ok\r"]); + await waitForAssertion(() => { + expect(fakeShell.writes).toEqual(["echo ok\r"]); + }); const outputPromise = waitForJsonMessage(ws, (frame) => frame.type === "output"); fakeShell.emitData("shell output\r\n"); @@ -332,14 +357,51 @@ describe("custom image terminal websocket bridge", () => { }); ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 })); - await flushPromises(); - expect(fakeShell.resizes).toEqual([{ cols: 120, rows: 40 }]); + await waitForAssertion(() => { + expect(fakeShell.resizes).toEqual([{ cols: 120, rows: 40 }]); + }); const closePromise = waitForClose(ws); ws.close(); await closePromise; - expect(fakeShell.closeCalls).toBeGreaterThan(0); - expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + await waitForAssertion(() => { + expect(fakeShell.closeCalls).toBeGreaterThan(0); + expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + }); + }); + + it("applies the latest resize sent while the SSH shell is still opening", async () => { + const pendingShell = deferred(); + connector.connect.mockReturnValueOnce(pendingShell.promise); + const { port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + token: minted.token, + })); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + + await waitForOpen(ws); + const unsupportedFramePromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); + ws.send(JSON.stringify({ type: "resize", cols: 110, rows: 31 })); + ws.send(JSON.stringify({ type: "resize", cols: 132, rows: 43 })); + ws.send(JSON.stringify({ type: "unsupported-test-frame" })); + await unsupportedFramePromise; + expect(fakeShell.resizes).toEqual([]); + + pendingShell.resolve(fakeShell); + await readyPromise; + expect(fakeShell.resizes).toEqual([{ cols: 132, rows: 43 }]); + + ws.close(); + await waitForClose(ws); }); it("sends a redacted fallback error when SSH bridge connection fails", async () => { diff --git a/server/src/realtime/environment-custom-image-terminal-ws.ts b/server/src/realtime/environment-custom-image-terminal-ws.ts index a7ea9529ace..84934e76cbc 100644 --- a/server/src/realtime/environment-custom-image-terminal-ws.ts +++ b/server/src/realtime/environment-custom-image-terminal-ws.ts @@ -97,6 +97,10 @@ const { WebSocket, WebSocketServer } = require("ws") as { WebSocket: { OPEN: number }; WebSocketServer: new (opts: { noServer: boolean }) => TerminalWsServer; }; +const CUSTOM_IMAGE_TERMINAL_UTF8_ENV = { + LANG: "C.UTF-8", + LC_CTYPE: "C.UTF-8", +}; function isWritableUpgradeSocket(socket: Duplex) { const maybeWritableState = socket as Duplex & { writable?: boolean; writableEnded?: boolean; writableDestroyed?: boolean }; @@ -203,6 +207,7 @@ function handleClientFrame( socket: TerminalWsSocket, shell: EnvironmentCustomImageSshShell | null, raw: unknown, + onPendingResize?: (dimensions: { cols: number; rows: number }) => void, ) { const text = decodeClientMessage(raw); if (!text) return; @@ -226,18 +231,21 @@ function handleClientFrame( return; } if (frame.type === "resize") { - const cols = typeof frame.cols === "number" ? frame.cols : null; - const rows = typeof frame.rows === "number" ? frame.rows : null; + const cols = typeof frame.cols === "number" && Number.isInteger(frame.cols) ? frame.cols : null; + const rows = typeof frame.rows === "number" && Number.isInteger(frame.rows) ? frame.rows : null; if ( - shell - && Number.isInteger(cols) - && Number.isInteger(rows) + cols !== null + && rows !== null && cols > 0 && rows > 0 && cols <= 9999 && rows <= 9999 ) { - shell.resize(cols, rows); + if (shell) { + shell.resize(cols, rows); + } else { + onPendingResize?.({ cols, rows }); + } } return; } @@ -372,6 +380,7 @@ export function createSsh2EnvironmentCustomImageSshConnector(): EnvironmentCusto connect(config: Record): void; shell( window: { term: string; cols: number; rows: number }, + options: { env?: Record }, callback: (err: Error | undefined, stream: ConstructorParameters[1]) => void, ): void; end(): void; @@ -394,7 +403,7 @@ export function createSsh2EnvironmentCustomImageSshConnector(): EnvironmentCusto }; client.once("ready", () => { - client.shell({ term, cols, rows }, (err, stream) => { + client.shell({ term, cols, rows }, { env: CUSTOM_IMAGE_TERMINAL_UTF8_ENV }, (err, stream) => { if (err || !stream) { fail(err ?? new Error("SSH shell failed to open.")); return; @@ -445,6 +454,7 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( } let shell: EnvironmentCustomImageSshShell | null = null; + let pendingResize: { cols: number; rows: number } | null = null; let cleanupRegistry: (() => void) | null = null; let expiryTimer: ReturnType | null = null; let cleanedUp = false; @@ -486,7 +496,9 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( }, expiresInMs); socket.on("message", (data: unknown) => { - handleClientFrame(socket, shell, data); + handleClientFrame(socket, shell, data, (dimensions) => { + pendingResize = dimensions; + }); }); socket.on("close", () => { @@ -509,6 +521,10 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( return; } shell = connectedShell; + if (pendingResize) { + shell.resize(pendingResize.cols, pendingResize.rows); + pendingResize = null; + } shell.onData((data) => { sendJson(socket, { type: "output", data }); }); diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index b2ae96eee40..7712ad7c9e3 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -788,6 +788,16 @@ describe("CompanyEnvironments — test provider button", () => { expect(FakeWebSocket.instances[0].url).toContain("cols=120"); expect(FakeWebSocket.instances[0].url).toContain("rows=32"); expect(xtermMocks.terminalInstances[0].options.cursorBlink).toBe(true); + expect(xtermMocks.terminalInstances[0].options.cursorInactiveStyle).toBe("bar"); + expect(xtermMocks.terminalInstances[0].options.cursorStyle).toBe("bar"); + expect(xtermMocks.terminalInstances[0].options.cursorWidth).toBe(2); + expect(xtermMocks.terminalInstances[0].options.customGlyphs).toBe(true); + expect(xtermMocks.terminalInstances[0].options.letterSpacing).toBe(0); + expect(xtermMocks.terminalInstances[0].options.theme).toMatchObject({ + cursor: "#22d3ee", + cursorAccent: "#020617", + }); + expect(String(xtermMocks.terminalInstances[0].options.fontFamily)).toContain("Nerd Font"); await act(async () => { FakeWebSocket.instances[0].open(); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index b96ee6115e3..f10c28f7bde 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -232,6 +232,20 @@ function setupConnectionFallbackMessage(input: { const CUSTOM_IMAGE_TERMINAL_COLS = 100; const CUSTOM_IMAGE_TERMINAL_ROWS = 28; const CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS = 5_000; +const CUSTOM_IMAGE_TERMINAL_FONT_FAMILY = [ + "MesloLGS NF", + "MesloLGS Nerd Font Mono", + "CaskaydiaCove Nerd Font Mono", + "CaskaydiaMono Nerd Font", + "JetBrainsMono Nerd Font", + "FiraCode Nerd Font Mono", + "Symbols Nerd Font Mono", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "monospace", +].join(", "); type CustomImageTerminalConnectionState = | "idle" @@ -288,6 +302,7 @@ function EnvironmentCustomImageBrowserTerminal({ const fitAddonRef = useRef(null); const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null); const resizeObserverRef = useRef(null); + const fitFrameRef = useRef(null); const socketRef = useRef(null); const autoConnectAttemptedSessionRef = useRef(null); const lastSentResizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -336,6 +351,16 @@ function EnvironmentCustomImageBrowserTerminal({ } }, [sendTerminalResize]); + const requestFitTerminal = useCallback(() => { + if (fitFrameRef.current !== null) { + window.cancelAnimationFrame(fitFrameRef.current); + } + fitFrameRef.current = window.requestAnimationFrame(() => { + fitFrameRef.current = null; + fitTerminal(); + }); + }, [fitTerminal]); + const sendTerminalInput = useCallback((data: string) => { const socket = socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) return; @@ -359,15 +384,20 @@ function EnvironmentCustomImageBrowserTerminal({ rows: CUSTOM_IMAGE_TERMINAL_ROWS, convertEol: false, cursorBlink: true, - cursorStyle: "block", - fontFamily: "Menlo, Monaco, Consolas, 'Liberation Mono', monospace", + cursorInactiveStyle: "bar", + cursorStyle: "bar", + cursorWidth: 2, + customGlyphs: true, + fontFamily: CUSTOM_IMAGE_TERMINAL_FONT_FAMILY, fontSize: 12, + letterSpacing: 0, lineHeight: 1.35, scrollback: CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS, theme: { background: "#0a0a0a", foreground: "#f5f5f5", - cursor: "#f8fafc", + cursor: "#22d3ee", + cursorAccent: "#020617", selectionBackground: "#2563eb55", }, }); @@ -380,14 +410,29 @@ function EnvironmentCustomImageBrowserTerminal({ terminalInputDisposableRef.current = terminal.onData(sendTerminalInput); if (typeof ResizeObserver !== "undefined") { - const resizeObserver = new ResizeObserver(() => fitTerminal()); + const resizeObserver = new ResizeObserver(() => requestFitTerminal()); resizeObserver.observe(element); resizeObserverRef.current = resizeObserver; } - window.setTimeout(fitTerminal, 0); + terminal.focus(); + requestFitTerminal(); + const fitTimeouts = [50, 250].map((delay) => window.setTimeout(fitTerminal, delay)); + const fontsReady = "fonts" in document + ? (document as Document & { fonts?: { ready?: Promise } }).fonts?.ready + : null; + if (fontsReady) { + void fontsReady.then(() => fitTerminal()); + } return () => { + if (fitFrameRef.current !== null) { + window.cancelAnimationFrame(fitFrameRef.current); + fitFrameRef.current = null; + } + for (const timeoutId of fitTimeouts) { + window.clearTimeout(timeoutId); + } resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; terminalInputDisposableRef.current?.dispose(); @@ -396,7 +441,7 @@ function EnvironmentCustomImageBrowserTerminal({ xtermRef.current = null; terminal.dispose(); }; - }, [fitTerminal, sendTerminalInput]); + }, [fitTerminal, requestFitTerminal, sendTerminalInput]); useEffect(() => () => closeSocket("component_unmounted"), [closeSocket]); @@ -427,6 +472,7 @@ function EnvironmentCustomImageBrowserTerminal({ lastSentResizeRef.current = null; setErrorMessage(null); resetTerminalScreen(); + xtermRef.current?.focus(); try { fitTerminal(); @@ -441,6 +487,7 @@ function EnvironmentCustomImageBrowserTerminal({ socket.onopen = () => { if (socketRef.current !== socket) return; + xtermRef.current?.focus(); sendTerminalResize(true); }; @@ -452,6 +499,7 @@ function EnvironmentCustomImageBrowserTerminal({ if (frame.type === "ready") { setConnectionState("connected"); + xtermRef.current?.focus(); return; } @@ -539,8 +587,9 @@ function EnvironmentCustomImageBrowserTerminal({ aria-label="Custom image browser terminal" role="application" tabIndex={0} + onFocus={() => xtermRef.current?.focus()} onClick={() => xtermRef.current?.focus()} - className="h-[18rem] w-full overflow-hidden bg-neutral-950 outline-none sm:h-[22rem] [&_.xterm-screen]:focus:outline-none [&_.xterm-viewport]:!overflow-y-auto" + className="h-[18rem] w-full overflow-hidden bg-neutral-950 outline-none sm:h-[22rem] [&_.xterm-cursor-bar]:!border-l-2 [&_.xterm-cursor-bar]:!border-l-cyan-300 [&_.xterm-cursor-layer_.xterm-cursor]:!bg-cyan-300 [&_.xterm-helper-textarea]:!opacity-0 [&_.xterm-screen]:focus:outline-none [&_.xterm-viewport]:!overflow-y-auto [&_.xterm]:h-full" /> {errorMessage ? (