Compare commits
2 Commits
perf/diff-
...
cj/hack/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5a9110f6 | ||
|
|
e5312ba763 |
@@ -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,
|
||||
|
||||
343
site/src/components/Terminal/Terminal.tsx
Normal file
343
site/src/components/Terminal/Terminal.tsx
Normal 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>>;
|
||||
@@ -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[]] {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user