Polish custom image browser terminal UX

This commit is contained in:
Cody committed 2026-07-02 19:16:37 +00:00
1 parent 4e72919a99
commit bdfdf1b11f
4 files changed
+158 -21

No files matched your search

@@ -94,6 +94,30 @@ async function flushPromises() {
await new Promise<void>((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<T>() {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
async function listen(server: HttpServer) {
await new Promise<void>((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<EnvironmentCustomImageSshShell>();
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 () => {
@@ -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<string, unknown>): void;
shell(
window: { term: string; cols: number; rows: number },
options: { env?: Record<string, string> },
callback: (err: Error | undefined, stream: ConstructorParameters<typeof Ssh2Shell>[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<typeof setTimeout> | 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 });
});
+10
View File
@@ -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();
+56 -7
View File
@@ -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<FitAddon | null>(null);
const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const fitFrameRef = useRef<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const autoConnectAttemptedSessionRef = useRef<string | null>(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<unknown> } }).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"
/>
</div>
{errorMessage ? (