mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:03:27 +01:00
Polish custom image browser terminal UX
This commit is contained in:
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 });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user