Compare commits

...

2 Commits

Author SHA1 Message Date
Prebuilds Owner
7d5a9110f6 fixup! hack: add terminal to TaskPage 2025-10-17 18:57:22 +00:00
Cian Johnston
e5312ba763 hack: add terminal to TaskPage 2025-10-17 13:43:21 +00:00
9 changed files with 502 additions and 402 deletions

View File

@@ -16,7 +16,7 @@ import {
type WorkspacePermissions,
workspaceChecks,
} from "modules/workspaces/permissions";
import type { ConnectionStatus } from "pages/TerminalPage/types";
import type { ConnectionStatus } from "components/Terminal/types";
import type {
QueryClient,
QueryOptions,

View File

@@ -0,0 +1,343 @@
import "@xterm/xterm/css/xterm.css";
import type { Interpolation, Theme } from "@emotion/react";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTermTerminal } from "@xterm/xterm";
import { deploymentConfig } from "api/queries/deployment";
import { appearanceSettings } from "api/queries/users";
import { useProxy } from "contexts/ProxyContext";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query";
import themes from "theme";
import { DEFAULT_TERMINAL_FONT, terminalFonts } from "theme/constants";
import { openMaybePortForwardedURL } from "utils/portForward";
import { terminalWebsocketUrl } from "utils/terminal";
import {
ExponentialBackoff,
type Websocket,
WebsocketBuilder,
WebsocketEvent,
} from "websocket-ts";
import type { ConnectionStatus } from "./types";
/**
* @public
*/
export interface TerminalProps {
agentId: string;
agentName?: string;
agentOS?: string;
workspaceName: string;
username: string;
reconnectionToken: string;
command?: string;
containerName?: string;
containerUser?: string;
onConnectionStatus?: (status: ConnectionStatus) => void;
className?: string;
}
export const Terminal: FC<TerminalProps> = ({
agentId,
agentName,
agentOS,
workspaceName,
username,
reconnectionToken,
command,
containerName,
containerUser,
onConnectionStatus,
className,
}) => {
const theme = themes.dark;
const { proxy } = useProxy();
const terminalWrapperRef = useRef<HTMLDivElement>(null);
const [terminal, setTerminal] = useState<XTermTerminal>();
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const config = useQuery(deploymentConfig());
const renderer = config.data?.config.web_terminal_renderer;
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const currentTerminalFont =
appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT;
// Notify parent of connection status changes
useEffect(() => {
onConnectionStatus?.(connectionStatus);
}, [connectionStatus, onConnectionStatus]);
// handleWebLink handles opening of URLs in the terminal
const handleWebLink = useCallback(
(uri: string) => {
openMaybePortForwardedURL(
uri,
proxy.preferredWildcardHostname,
agentName,
workspaceName,
username,
);
},
[agentName, workspaceName, username, proxy.preferredWildcardHostname],
);
const handleWebLinkRef = useRef(handleWebLink);
useEffect(() => {
handleWebLinkRef.current = handleWebLink;
}, [handleWebLink]);
// Create the terminal
const fitAddonRef = useRef<FitAddon | undefined>(undefined);
const websocketRef = useRef<Websocket | undefined>(undefined);
useEffect(() => {
if (!terminalWrapperRef.current || config.isLoading) {
return;
}
const xterm = new XTermTerminal({
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
theme: {
background: theme.palette.background.default,
},
});
if (renderer === "webgl") {
try {
xterm.loadAddon(new WebglAddon());
} catch {
// Fallback to canvas if WebGL fails
xterm.loadAddon(new CanvasAddon());
}
} else if (renderer === "canvas") {
xterm.loadAddon(new CanvasAddon());
}
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
xterm.loadAddon(fitAddon);
xterm.loadAddon(new Unicode11Addon());
xterm.unicode.activeVersion = "11";
xterm.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
// Make shift+enter send escaped carriage return
const escapedCarriageReturn = "\x1b\r";
xterm.attachCustomKeyEventHandler((ev) => {
if (ev.shiftKey && ev.key === "Enter") {
if (ev.type === "keydown") {
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
}
return false;
}
return true;
});
xterm.open(terminalWrapperRef.current);
// Fit twice to avoid overflow issues
fitAddon.fit();
fitAddon.fit();
const listener = () => fitAddon.fit();
window.addEventListener("resize", listener);
setTerminal(xterm);
return () => {
window.removeEventListener("resize", listener);
xterm.dispose();
};
}, [
config.isLoading,
renderer,
theme.palette.background.default,
currentTerminalFont,
]);
// Hook up the terminal through WebSocket
useEffect(() => {
if (!terminal) {
return;
}
terminal.clear();
terminal.focus();
terminal.options.disableStdin = true;
let websocket: Websocket | null;
const disposers = [
terminal.onData((data) => {
websocket?.send(
new TextEncoder().encode(JSON.stringify({ data: data })),
);
}),
terminal.onResize((event) => {
websocket?.send(
new TextEncoder().encode(
JSON.stringify({
height: event.rows,
width: event.cols,
}),
),
);
}),
];
let disposed = false;
terminalWebsocketUrl(
process.env.NODE_ENV !== "development"
? proxy.preferredPathAppURL
: undefined,
reconnectionToken,
agentId,
command,
terminal.rows,
terminal.cols,
containerName,
containerUser,
)
.then((url) => {
if (disposed) {
return;
}
websocket = new WebsocketBuilder(url)
.withBackoff(new ExponentialBackoff(1000, 6))
.build();
websocket.binaryType = "arraybuffer";
websocketRef.current = websocket;
websocket.addEventListener(WebsocketEvent.open, () => {
terminal.options = {
disableStdin: false,
windowsMode: agentOS === "windows",
};
websocket?.send(
new TextEncoder().encode(
JSON.stringify({
height: terminal.rows,
width: terminal.cols,
}),
),
);
setConnectionStatus("connected");
});
websocket.addEventListener(WebsocketEvent.error, (_, event) => {
console.error("WebSocket error:", event);
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.close, () => {
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.message, (_, event) => {
if (typeof event.data === "string") {
terminal.write(event.data);
} else {
terminal.write(new Uint8Array(event.data));
}
});
websocket.addEventListener(WebsocketEvent.reconnect, () => {
if (websocket) {
websocket.binaryType = "arraybuffer";
websocket.send(
new TextEncoder().encode(
JSON.stringify({
height: terminal.rows,
width: terminal.cols,
}),
),
);
}
});
})
.catch((error) => {
if (disposed) {
return;
}
console.error("WebSocket connection failed:", error);
setConnectionStatus("disconnected");
});
return () => {
disposed = true;
for (const d of disposers) {
d.dispose();
}
websocket?.close(1000);
websocketRef.current = undefined;
};
}, [
command,
proxy.preferredPathAppURL,
terminal,
agentId,
agentOS,
containerName,
containerUser,
reconnectionToken,
]);
return (
<div
css={styles.terminal}
ref={terminalWrapperRef}
data-testid="terminal"
data-status={connectionStatus}
className={className}
/>
);
};
const styles = {
terminal: (theme) => ({
width: "100%",
height: "100%",
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
"& .xterm": {
padding: 4,
width: "100%",
height: "100%",
},
"& .xterm-viewport": {
width: "auto !important",
},
"& .xterm-viewport::-webkit-scrollbar": {
width: "10px",
},
"& .xterm-viewport::-webkit-scrollbar-track": {
backgroundColor: "inherit",
},
"& .xterm-viewport::-webkit-scrollbar-thumb": {
minHeight: 20,
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@@ -9,7 +9,8 @@ import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
import { Link } from "components/Link/Link";
import { ScrollArea, ScrollBar } from "components/ScrollArea/ScrollArea";
import { ChevronDownIcon, LayoutGridIcon } from "lucide-react";
import { Terminal } from "components/Terminal/Terminal";
import { ChevronDownIcon, LayoutGridIcon, TerminalIcon } from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import {
getTaskApps,
@@ -17,12 +18,15 @@ import {
type WorkspaceAppWithAgent,
} from "modules/tasks/tasks";
import type React from "react";
import { type FC, useState } from "react";
import { type FC, useMemo, useState } from "react";
import { Link as RouterLink } from "react-router";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import { v4 as uuidv4 } from "uuid";
import { TaskAppIFrame } from "./TaskAppIframe";
const TERMINAL_TAB_ID = "__terminal__";
type TaskAppsProps = {
task: Task;
};
@@ -36,39 +40,66 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
app.health !== "disabled",
);
const [embeddedApps, externalApps] = splitEmbeddedAndExternalApps(apps);
const [activeAppId, setActiveAppId] = useState(embeddedApps.at(0)?.id);
const hasAvailableAppsToDisplay =
embeddedApps.length > 0 || externalApps.length > 0;
// Default to first embedded app, or terminal if no apps exist
const defaultTab = embeddedApps.at(0)?.id ?? TERMINAL_TAB_ID;
const [activeAppId, setActiveAppId] = useState(defaultTab);
const hasAppsToDisplay = embeddedApps.length > 0 || externalApps.length > 0;
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents ?? [])
.filter((a) => !!a)
.at(0);
// Generate a stable reconnection token for the terminal session.
// This token is stored in sessionStorage to persist across component remounts
// but is cleared when the browser tab/window is closed.
const terminalReconnectToken = useMemo(() => {
if (!agent) return "";
const storageKey = `terminal-reconnect-${agent.id}`;
const stored = sessionStorage.getItem(storageKey);
if (stored) return stored;
const newToken = uuidv4();
sessionStorage.setItem(storageKey, newToken);
return newToken;
}, [agent]);
return (
<main className="flex flex-col h-full">
{hasAvailableAppsToDisplay && (
<div className="w-full flex items-center border-0 border-b border-border border-solid">
<ScrollArea className="max-w-full">
<div className="flex w-max gap-2 items-center p-2 pb-0">
{embeddedApps.map((app) => (
<TaskAppTab
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
</div>
<ScrollBar orientation="horizontal" className="h-2" />
</ScrollArea>
<div className="w-full flex items-center border-0 border-b border-border border-solid">
<ScrollArea className="max-w-full">
<div className="flex w-max gap-2 items-center p-2 pb-0">
{embeddedApps.map((app) => (
<TaskAppTab
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
{agent && (
<TerminalTab
active={activeAppId === TERMINAL_TAB_ID}
onClick={(e) => {
e.preventDefault();
setActiveAppId(TERMINAL_TAB_ID);
}}
/>
)}
</div>
<ScrollBar orientation="horizontal" className="h-2" />
</ScrollArea>
{externalApps.length > 0 && (
<ExternalAppsDropdown task={task} externalApps={externalApps} />
)}
</div>
)}
{externalApps.length > 0 && (
<ExternalAppsDropdown task={task} externalApps={externalApps} />
)}
</div>
{embeddedApps.length > 0 ? (
{hasAppsToDisplay || agent ? (
<div className="flex-1">
{embeddedApps.map((app) => (
<TaskAppIFrame
@@ -78,6 +109,16 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
task={task}
/>
))}
{agent && activeAppId === TERMINAL_TAB_ID && (
<Terminal
agentId={agent.id}
agentName={agent.name}
agentOS={agent.operating_system}
workspaceName={task.workspace.name}
username={task.workspace.owner_name}
reconnectionToken={terminalReconnectToken}
/>
)}
</div>
) : (
<div className="mx-auto my-auto flex flex-col items-center">
@@ -191,6 +232,32 @@ const TaskAppTab: FC<TaskAppTabProps> = ({ task, app, active, onClick }) => {
);
};
type TerminalTabProps = {
active: boolean;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const TerminalTab: FC<TerminalTabProps> = ({ active, onClick }) => {
return (
<Button
size="sm"
variant="subtle"
className={cn([
"px-3",
{
"text-content-primary bg-surface-tertiary rounded-sm rounded-b-none":
active,
},
{ "opacity-75 hover:opacity-100": !active },
])}
onClick={onClick}
>
<TerminalIcon />
Terminal
</Button>
);
};
function splitEmbeddedAndExternalApps(
apps: WorkspaceAppWithAgent[],
): [WorkspaceAppWithAgent[], WorkspaceAppWithAgent[]] {

View File

@@ -298,10 +298,12 @@ export const Active: Story = {
const vscodeIframe = await canvas.findByTitle("VS Code Web");
const zedIframe = await canvas.findByTitle("Zed");
const claudeIframe = await canvas.findByTitle("Claude Code");
const terminalButton = await canvas.findByText("Terminal");
expect(vscodeIframe).toBeVisible();
expect(zedIframe).not.toBeVisible();
expect(claudeIframe).toBeVisible();
expect(terminalButton).toBeVisible();
},
};
@@ -492,6 +494,32 @@ function mockTaskWorkspace(
sidebarApp: WorkspaceApp,
activeApp: WorkspaceApp,
): Workspace {
const apps: WorkspaceApp[] = [
sidebarApp,
activeApp,
{
...MockWorkspaceApp,
slug: "zed",
id: "zed",
display_name: "Zed",
icon: "/icon/zed.svg",
health: "healthy",
},
{
...MockWorkspaceApp,
slug: "preview",
id: "preview",
display_name: "Preview",
health: "healthy",
},
{
...MockWorkspaceApp,
slug: "disabled",
id: "disabled",
display_name: "Disabled",
},
];
return {
...MockWorkspace,
latest_build: {
@@ -504,31 +532,7 @@ function mockTaskWorkspace(
agents: [
{
...MockWorkspaceAgentReady,
apps: [
sidebarApp,
activeApp,
{
...MockWorkspaceApp,
slug: "zed",
id: "zed",
display_name: "Zed",
icon: "/icon/zed.svg",
health: "healthy",
},
{
...MockWorkspaceApp,
slug: "preview",
id: "preview",
display_name: "Preview",
health: "healthy",
},
{
...MockWorkspaceApp,
slug: "disabled",
id: "disabled",
display_name: "Disabled",
},
],
apps,
},
],
},

View File

@@ -142,7 +142,8 @@ const TaskPage = () => {
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
} else {
const chatApp = getTaskApps(task).find(
const apps = getTaskApps(task);
const chatApp = apps.find(
(app) => app.id === task.workspace.latest_build.ai_task_sidebar_app_id,
);
content = (

View File

@@ -2,9 +2,9 @@ import Link from "@mui/material/Link";
import type { WorkspaceAgent } from "api/typesGenerated";
import { Alert, type AlertProps } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import type { ConnectionStatus } from "components/Terminal/types";
import { type FC, useEffect, useRef, useState } from "react";
import { docs } from "utils/docs";
import type { ConnectionStatus } from "./types";
type TerminalAlertsProps = {
agent: WorkspaceAgent | undefined;

View File

@@ -11,7 +11,7 @@ import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import WS from "jest-websocket-mock";
import { HttpResponse, http } from "msw";
import TerminalPage, { Language } from "./TerminalPage";
import TerminalPage from "./TerminalPage";
const renderTerminal = async (
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
@@ -83,7 +83,11 @@ describe("TerminalPage", () => {
const { container } = await renderTerminal();
await expectTerminalText(container, Language.workspaceErrorMessagePrefix);
// Terminal should not be rendered when workspace fetch fails
await waitFor(() => {
const elements = container.getElementsByClassName("xterm-rows");
expect(elements.length).toBe(0);
});
});
it("shows reconnect message when websocket fails", async () => {

View File

@@ -1,61 +1,25 @@
import "@xterm/xterm/css/xterm.css";
import type { Interpolation, Theme } from "@emotion/react";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import { deploymentConfig } from "api/queries/deployment";
import { appearanceSettings } from "api/queries/users";
import {
workspaceByOwnerAndName,
workspaceUsage,
} from "api/queries/workspaces";
import { useProxy } from "contexts/ProxyContext";
import { Terminal } from "components/Terminal/Terminal";
import type { ConnectionStatus } from "components/Terminal/types";
import { ThemeOverride } from "contexts/ThemeProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import themes from "theme";
import { DEFAULT_TERMINAL_FONT, terminalFonts } from "theme/constants";
import { pageTitle } from "utils/page";
import { openMaybePortForwardedURL } from "utils/portForward";
import { terminalWebsocketUrl } from "utils/terminal";
import { getMatchingAgentOrFirst } from "utils/workspace";
import { v4 as uuidv4 } from "uuid";
// Use websocket-ts for better WebSocket handling and auto-reconnection.
import {
ExponentialBackoff,
type Websocket,
WebsocketBuilder,
WebsocketEvent,
} from "websocket-ts";
import { TerminalAlerts } from "./TerminalAlerts";
import type { ConnectionStatus } from "./types";
export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ",
websocketErrorMessagePrefix: "WebSocket failed: ",
};
const TerminalPage: FC = () => {
// Maybe one day we'll support a light themed terminal, but terminal coloring
// is notably a pain because of assumptions certain programs might make about your
// background color.
const theme = themes.dark;
const navigate = useNavigate();
const { proxy, proxyLatencies } = useProxy();
const params = useParams() as { username: string; workspace: string };
const username = params.username.replace("@", "");
const terminalWrapperRef = useRef<HTMLDivElement>(null);
// The terminal is maintained as a state to trigger certain effects when it
// updates.
const [terminal, setTerminal] = useState<Terminal>();
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const [searchParams] = useSearchParams();
const isDebugging = searchParams.has("debug");
// The reconnection token is a unique token that identifies
@@ -74,11 +38,9 @@ const TerminalPage: FC = () => {
const workspaceAgent = workspace.data
? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1])
: undefined;
const selectedProxy = proxy.proxy;
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
const config = useQuery(deploymentConfig());
const renderer = config.data?.config.web_terminal_renderer;
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
// Periodically report workspace usage.
useQuery(
@@ -90,107 +52,6 @@ const TerminalPage: FC = () => {
}),
);
// handleWebLink handles opening of URLs in the terminal!
const handleWebLink = useCallback(
(uri: string) => {
openMaybePortForwardedURL(
uri,
proxy.preferredWildcardHostname,
workspaceAgent?.name,
workspace.data?.name,
username,
);
},
[workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname],
);
const handleWebLinkRef = useRef(handleWebLink);
useEffect(() => {
handleWebLinkRef.current = handleWebLink;
}, [handleWebLink]);
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const currentTerminalFont =
appearanceSettingsQuery.data?.terminal_font || DEFAULT_TERMINAL_FONT;
// Create the terminal!
const fitAddonRef = useRef<FitAddon>(undefined);
useEffect(() => {
if (!terminalWrapperRef.current || config.isLoading) {
return;
}
const terminal = new Terminal({
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: terminalFonts[currentTerminalFont],
fontSize: 16,
theme: {
background: theme.palette.background.default,
},
});
if (renderer === "webgl") {
terminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
terminal.loadAddon(new CanvasAddon());
}
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleWebLinkRef.current(uri);
}),
);
// Make shift+enter send ^[^M (escaped carriage return). Applications
// typically take this to mean to insert a literal newline. There is no way
// to remove this handler, so we must attach it once and rely on a ref to
// send it to the current socket.
const escapedCarriageReturn = "\x1b\r";
terminal.attachCustomKeyEventHandler((ev) => {
if (ev.shiftKey && ev.key === "Enter") {
if (ev.type === "keydown") {
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
}
return false;
}
return true;
});
terminal.open(terminalWrapperRef.current);
// We have to fit twice here. It's unknown why, but the first fit will
// overflow slightly in some scenarios. Applying a second fit resolves this.
fitAddon.fit();
fitAddon.fit();
// This will trigger a resize event on the terminal.
const listener = () => fitAddon.fit();
window.addEventListener("resize", listener);
// Terminal is correctly sized and is ready to be used.
setTerminal(terminal);
return () => {
window.removeEventListener("resize", listener);
terminal.dispose();
};
}, [
config.isLoading,
renderer,
theme.palette.background.default,
currentTerminalFont,
]);
// Updates the reconnection token into the URL if necessary.
useEffect(() => {
if (searchParams.get("reconnect") === reconnectionToken) {
@@ -207,166 +68,6 @@ const TerminalPage: FC = () => {
);
}, [navigate, reconnectionToken, searchParams]);
// Hook up the terminal through a web socket.
const websocketRef = useRef<Websocket>(undefined);
useEffect(() => {
if (!terminal) {
return;
}
// The terminal should be cleared on each reconnect
// because all data is re-rendered from the backend.
terminal.clear();
// Focusing on connection allows users to reload the page and start
// typing immediately.
terminal.focus();
// Disable input while we connect.
terminal.options.disableStdin = true;
// Show a message if we failed to find the workspace or agent.
if (workspace.isLoading) {
return;
}
if (workspace.error instanceof Error) {
terminal.writeln(
Language.workspaceErrorMessagePrefix + workspace.error.message,
);
setConnectionStatus("disconnected");
return;
}
if (!workspaceAgent) {
terminal.writeln(
`${Language.workspaceAgentErrorMessagePrefix}no agent found with ID, is the workspace started?`,
);
setConnectionStatus("disconnected");
return;
}
// Hook up terminal events to the websocket.
let websocket: Websocket | null;
const disposers = [
terminal.onData((data) => {
websocket?.send(
new TextEncoder().encode(JSON.stringify({ data: data })),
);
}),
terminal.onResize((event) => {
websocket?.send(
new TextEncoder().encode(
JSON.stringify({
height: event.rows,
width: event.cols,
}),
),
);
}),
];
let disposed = false;
terminalWebsocketUrl(
// When on development mode we can bypass the proxy and connect directly.
process.env.NODE_ENV !== "development"
? proxy.preferredPathAppURL
: undefined,
reconnectionToken,
workspaceAgent.id,
command,
terminal.rows,
terminal.cols,
containerName,
containerUser,
)
.then((url) => {
if (disposed) {
return; // Unmounted while we waited for the async call.
}
websocket = new WebsocketBuilder(url)
.withBackoff(new ExponentialBackoff(1000, 6))
.build();
websocket.binaryType = "arraybuffer";
websocketRef.current = websocket;
websocket.addEventListener(WebsocketEvent.open, () => {
// Now that we are connected, allow user input.
terminal.options = {
disableStdin: false,
windowsMode: workspaceAgent?.operating_system === "windows",
};
// Send the initial size.
websocket?.send(
new TextEncoder().encode(
JSON.stringify({
height: terminal.rows,
width: terminal.cols,
}),
),
);
setConnectionStatus("connected");
});
websocket.addEventListener(WebsocketEvent.error, (_, event) => {
console.error("WebSocket error:", event);
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.close, () => {
terminal.options.disableStdin = true;
setConnectionStatus("disconnected");
});
websocket.addEventListener(WebsocketEvent.message, (_, event) => {
if (typeof event.data === "string") {
// This exclusively occurs when testing.
// "jest-websocket-mock" doesn't support ArrayBuffer.
terminal.write(event.data);
} else {
terminal.write(new Uint8Array(event.data));
}
});
websocket.addEventListener(WebsocketEvent.reconnect, () => {
if (websocket) {
websocket.binaryType = "arraybuffer";
websocket.send(
new TextEncoder().encode(
JSON.stringify({
height: terminal.rows,
width: terminal.cols,
}),
),
);
}
});
})
.catch((error) => {
if (disposed) {
return; // Unmounted while we waited for the async call.
}
console.error("WebSocket connection failed:", error);
setConnectionStatus("disconnected");
});
return () => {
disposed = true; // Could use AbortController instead?
for (const d of disposers) {
d.dispose();
}
websocket?.close(1000);
websocketRef.current = undefined;
};
}, [
command,
proxy.preferredPathAppURL,
reconnectionToken,
terminal,
workspace.error,
workspace.isLoading,
workspaceAgent,
containerName,
containerUser,
]);
return (
<ThemeOverride theme={theme}>
{workspace.data && (
@@ -386,17 +87,27 @@ const TerminalPage: FC = () => {
agent={workspaceAgent}
status={connectionStatus}
onAlertChange={() => {
fitAddonRef.current?.fit();
// Terminal component handles its own resizing
}}
/>
<div
css={styles.terminal}
ref={terminalWrapperRef}
data-testid="terminal"
/>
{workspaceAgent && (
<Terminal
agentId={workspaceAgent.id}
agentName={workspaceAgent.name}
agentOS={workspaceAgent.operating_system}
workspaceName={workspace.data?.name || ""}
username={username}
reconnectionToken={reconnectionToken}
command={command}
containerName={containerName}
containerUser={containerUser}
onConnectionStatus={setConnectionStatus}
className="flex-1"
/>
)}
</div>
{latency && isDebugging && (
{isDebugging && (
<span
css={{
position: "absolute",
@@ -406,41 +117,11 @@ const TerminalPage: FC = () => {
fontSize: 14,
}}
>
Latency: {latency.latencyMS.toFixed(0)}ms
Debug mode enabled
</span>
)}
</ThemeOverride>
);
};
const styles = {
terminal: (theme) => ({
width: "100%",
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
flex: 1,
// These styles attempt to mimic the VS Code scrollbar.
"& .xterm": {
padding: 4,
width: "100%",
height: "100%",
},
"& .xterm-viewport": {
// This is required to force full-width on the terminal.
// Otherwise there's a small white bar to the right of the scrollbar.
width: "auto !important",
},
"& .xterm-viewport::-webkit-scrollbar": {
width: "10px",
},
"& .xterm-viewport::-webkit-scrollbar-track": {
backgroundColor: "inherit",
},
"& .xterm-viewport::-webkit-scrollbar-thumb": {
minHeight: 20,
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
export default TerminalPage;