feat(site): add terminal panel to chat sidebar (#23231)

## Add terminal panel to chat sidebar

Extract the reusable terminal runtime from `TerminalPage` into
`modules/terminal/` and wire it into the agents chat right sidebar as a
new **Terminal** tab.

### Changes

- **`modules/terminal/WorkspaceTerminal.tsx`** — Shared xterm +
websocket terminal component (container-sized, no route dependency)
- **`modules/terminal/WorkspaceTerminalAlerts.tsx`** — Moved from
`TerminalPage/` to shared module
- **`pages/AgentsPage/TerminalPanel.tsx`** — Sidebar wrapper around
`WorkspaceTerminal`
- **`pages/AgentsPage/AgentDetailView.tsx`** — Terminal tab added (gated
on `hasWorkspace`)
- **`pages/TerminalPage/TerminalPage.tsx`** — Slimmed to page-shell
using shared component

### Demo


[dogfood-terminal-demo.webm](https://github.com/user-attachments/assets/359200dc-f8e4-4a9a-b00b-923f142dc228)

### Behavior

- Terminal tab appears only when the chat has a workspace with a
connected agent
- Connects via the existing workspace agent PTY websocket
- Resizes correctly on panel width changes, expand/collapse, and
viewport resize
- `fitAddon.fit()` guarded against pre-renderer crashes (fixes proxy
access)
- Tab switching unmounts/remounts cleanly (reconnects via session token)
- No changes to Git or Desktop panel behavior

---

_Generated with [`mux`](https://github.com/coder/mux) • Model:
`anthropic:claude-opus-4-6` • Thinking: `xhigh`_
This commit is contained in:
Thomas Kosiewski
2026-04-01 13:38:23 +00:00
committed by GitHub
parent 7198f9040d
commit 12f87acad6
13 changed files with 865 additions and 402 deletions
+1 -1
View File
@@ -24,11 +24,11 @@ import type {
WorkspacesRequest,
WorkspacesResponse,
} from "#/api/typesGenerated";
import type { ConnectionStatus } from "#/modules/terminal/types";
import {
type WorkspacePermissions,
workspaceChecks,
} from "#/modules/workspaces/permissions";
import type { ConnectionStatus } from "#/pages/TerminalPage/types";
import { checkAuthorization } from "./authCheck";
import { disabledRefetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";
@@ -0,0 +1,516 @@
import "@xterm/xterm/css/xterm.css";
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 {
type Ref,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
} from "react";
import {
ExponentialBackoff,
type Websocket,
WebsocketBuilder,
WebsocketEvent,
} from "websocket-ts";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useClipboard } from "#/hooks/useClipboard";
import { cn } from "#/utils/cn";
import { terminalWebsocketUrl } from "#/utils/terminal";
import type { ConnectionStatus } from "./types";
export type WorkspaceTerminalHandle = {
refit: () => void;
};
type WorkspaceTerminalProps = {
ref?: Ref<WorkspaceTerminalHandle>;
agentId: string | undefined;
operatingSystem?: string;
className?: string;
autoFocus?: boolean;
isVisible?: boolean;
initialCommand?: string;
containerName?: string;
containerUser?: string;
onStatusChange?: (status: ConnectionStatus) => void;
onError?: (error: Error) => void;
reconnectionToken: string;
baseUrl?: string;
terminalFontFamily?: string;
renderer?: string;
backgroundColor?: string;
onOpenLink?: (uri: string) => void;
loading?: boolean;
errorMessage?: string;
testId?: string;
};
const DEFAULT_TERMINAL_FONT_FAMILY = "monospace";
const ESCAPED_CARRIAGE_RETURN = "\x1b\r";
const encodeTerminalPayload = (payload: Record<string, number | string>) => {
return new TextEncoder().encode(JSON.stringify(payload));
};
export const WorkspaceTerminal = ({
ref,
agentId,
operatingSystem,
className,
autoFocus = true,
isVisible = true,
initialCommand,
containerName,
containerUser,
onStatusChange,
onError,
reconnectionToken,
baseUrl,
terminalFontFamily = DEFAULT_TERMINAL_FONT_FAMILY,
renderer,
backgroundColor,
onOpenLink,
loading = false,
errorMessage,
testId,
}: WorkspaceTerminalProps) => {
const scopeId = useId();
const terminalWrapperRef = useRef<HTMLDivElement>(null);
const fitAddonRef = useRef<FitAddon | undefined>(undefined);
const websocketRef = useRef<Websocket | undefined>(undefined);
const handleOpenLink = useEffectEvent((uri: string) => {
onOpenLink ? onOpenLink(uri) : window.open(uri, "_blank", "noopener");
});
const handleStatusChange = useEffectEvent((status: ConnectionStatus) => {
onStatusChange?.(status);
});
const [terminal, setTerminal] = useState<Terminal>();
const { copyToClipboard } = useClipboard();
const [hasBeenVisible, setHasBeenVisible] = useState(false);
if (isVisible && !hasBeenVisible) {
setHasBeenVisible(true);
}
const reportTerminalError = useEffectEvent((error: Error) => {
console.error(error);
onError?.(error);
});
const getTerminalDimensions = useCallback(
(terminal: Terminal): { height: number; width: number } | null => {
if (terminal.rows <= 0 || terminal.cols <= 0) {
reportTerminalError(
new Error(
`Terminal has non-positive dimensions: ${terminal.rows}x${terminal.cols}`,
),
);
return null;
}
return {
height: terminal.rows,
width: terminal.cols,
};
},
[reportTerminalError],
);
const refit = useCallback(() => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) {
return;
}
// 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.
try {
fitAddon.fit();
fitAddon.fit();
} catch (error) {
// biome-ignore lint/suspicious/noConsole: Expected transient fit failure while xterm initializes.
console.debug("Terminal fit skipped: renderer not ready", error);
}
}, []);
useImperativeHandle(
ref,
() => ({
refit,
}),
[refit],
);
useEffect(() => {
if (!hasBeenVisible) {
return;
}
const mountNode = terminalWrapperRef.current;
if (!mountNode) {
reportTerminalError(new Error("Terminal mount container is unavailable"));
return;
}
const nextTerminal = new Terminal({
allowProposedApi: true,
allowTransparency: true,
disableStdin: false,
fontFamily: terminalFontFamily,
fontSize: 16,
...(backgroundColor ? { theme: { background: backgroundColor } } : {}),
});
if (renderer === "webgl") {
nextTerminal.loadAddon(new WebglAddon());
} else if (renderer === "canvas") {
nextTerminal.loadAddon(new CanvasAddon());
}
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
nextTerminal.loadAddon(fitAddon);
nextTerminal.loadAddon(new Unicode11Addon());
nextTerminal.unicode.activeVersion = "11";
nextTerminal.loadAddon(
new WebLinksAddon((_, uri) => {
handleOpenLink(uri);
}),
);
const isMac = navigator.platform.match("Mac");
const copySelection = () => {
const selection = nextTerminal.getSelection();
if (selection) {
copyToClipboard(selection);
}
};
// 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.
nextTerminal.attachCustomKeyEventHandler((event) => {
// Make shift+enter send ^[^M (escaped carriage return). Applications
// typically take this to mean to insert a literal newline.
if (event.shiftKey && event.key === "Enter") {
if (event.type === "keydown") {
websocketRef.current?.send(
encodeTerminalPayload({ data: ESCAPED_CARRIAGE_RETURN }),
);
}
return false;
}
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text.
// By default this usually launches the browser dev tools, but users
// expect this keybinding to copy when in the context of the web terminal.
if (
(isMac ? event.metaKey : event.ctrlKey) &&
event.shiftKey &&
event.key === "C"
) {
event.preventDefault();
if (event.type === "keydown") {
copySelection();
}
return false;
}
return true;
});
// Browsers don't support automatic copy to the X11 primary
// selection (highlighted text that can be pasted with
// middle-click). Instead, copy-on-select writes to the
// system clipboard. This means users can't middle-click
// paste in the terminal after selecting, but this tradeoff
// is necessary because web browsers don't expose primary
// selection APIs. Most web terminal users expect Ctrl+V or
// right-click paste anyway.
nextTerminal.onSelectionChange(() => {
copySelection();
});
nextTerminal.open(mountNode);
refit();
window.addEventListener("resize", refit);
const resizeObserver = new ResizeObserver(() => {
refit();
});
resizeObserver.observe(mountNode);
setTerminal(nextTerminal);
return () => {
window.removeEventListener("resize", refit);
resizeObserver.disconnect();
fitAddonRef.current = undefined;
nextTerminal.dispose();
setTerminal(undefined);
};
}, [
hasBeenVisible,
copyToClipboard,
handleOpenLink,
refit,
renderer,
reportTerminalError,
terminalFontFamily,
backgroundColor,
]);
useEffect(() => {
if (!isVisible) {
return;
}
refit();
}, [isVisible, refit]);
useEffect(() => {
if (!terminal || !hasBeenVisible) {
return;
}
terminal.clear();
if (autoFocus) {
terminal.focus();
}
terminal.options.disableStdin = true;
if (loading) {
return;
}
if (errorMessage) {
terminal.writeln(errorMessage);
handleStatusChange("disconnected");
return;
}
if (!agentId) {
const error = new Error("Terminal requires agentId to connect");
reportTerminalError(error);
terminal.writeln(error.message);
handleStatusChange("disconnected");
return;
}
refit();
// Fall back to standard dimensions if the terminal hasn't rendered
// yet (e.g. fit() failed during renderer startup). The correct
// size will be sent once the ResizeObserver fires.
const initialDimensions = getTerminalDimensions(terminal) ?? {
height: 24,
width: 80,
};
let websocket: Websocket | null;
const disposers = [
terminal.onData((data) => {
websocket?.send(encodeTerminalPayload({ data }));
}),
terminal.onResize((event) => {
if (event.rows <= 0 || event.cols <= 0) {
reportTerminalError(
new Error(
`Terminal received non-positive resize: ${event.rows}x${event.cols}`,
),
);
return;
}
websocket?.send(
encodeTerminalPayload({ height: event.rows, width: event.cols }),
);
}),
];
let disposed = false;
terminalWebsocketUrl(
baseUrl,
reconnectionToken,
agentId,
initialCommand,
initialDimensions.height,
initialDimensions.width,
containerName,
containerUser,
)
.then((url) => {
if (disposed) {
return;
}
websocket = new WebsocketBuilder(url)
.withBackoff(new ExponentialBackoff(1000, 6))
.build();
const scheduleTerminalResize = () => {
window.setTimeout(() => {
if (disposed) {
return;
}
const dimensions = getTerminalDimensions(terminal);
if (!dimensions) {
return;
}
websocket?.send(
encodeTerminalPayload({
height: dimensions.height,
width: dimensions.width,
}),
);
}, 0);
};
websocket.binaryType = "arraybuffer";
websocketRef.current = websocket;
websocket.addEventListener(WebsocketEvent.open, () => {
if (disposed) {
return;
}
terminal.options = {
disableStdin: false,
windowsMode: operatingSystem === "windows",
};
refit();
scheduleTerminalResize();
handleStatusChange("connected");
});
websocket.addEventListener(WebsocketEvent.error, (_, event) => {
if (disposed) {
return;
}
console.error("WebSocket error:", event);
terminal.options.disableStdin = true;
handleStatusChange("disconnected");
});
websocket.addEventListener(WebsocketEvent.close, () => {
if (disposed) {
return;
}
terminal.options.disableStdin = true;
handleStatusChange("disconnected");
});
websocket.addEventListener(WebsocketEvent.message, (_, event) => {
if (disposed) {
return;
}
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 (disposed || !websocket) {
return;
}
websocket.binaryType = "arraybuffer";
refit();
const dimensions = getTerminalDimensions(terminal);
if (!dimensions) {
return;
}
websocket.send(
encodeTerminalPayload({
height: dimensions.height,
width: dimensions.width,
}),
);
});
})
.catch((error) => {
if (disposed) {
return;
}
console.error("WebSocket connection failed:", error);
reportTerminalError(
error instanceof Error ? error : new Error(String(error)),
);
handleStatusChange("disconnected");
});
return () => {
disposed = true;
for (const disposer of disposers) {
disposer.dispose();
}
websocket?.close(1000);
websocketRef.current = undefined;
};
}, [
hasBeenVisible,
agentId,
autoFocus,
baseUrl,
containerName,
containerUser,
errorMessage,
getTerminalDimensions,
handleStatusChange,
initialCommand,
loading,
operatingSystem,
reconnectionToken,
refit,
reportTerminalError,
terminal,
]);
const terminalScopeSelector = `[data-terminal-scope="${scopeId}"]`;
return (
<>
<style>{`
${terminalScopeSelector} .xterm {
padding: 4px;
width: 100%;
height: 100%;
}
${terminalScopeSelector} .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;
}
${terminalScopeSelector} .xterm-viewport::-webkit-scrollbar {
width: 10px;
}
${terminalScopeSelector} .xterm-viewport::-webkit-scrollbar-track {
background-color: inherit;
}
${terminalScopeSelector} .xterm-viewport::-webkit-scrollbar-thumb {
min-height: 20px;
background-color: rgba(255, 255, 255, 0.18);
}
`}</style>
<div
className={cn(
"workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary",
className,
)}
ref={terminalWrapperRef}
data-terminal-scope={scopeId}
data-testid={testId}
/>
</>
);
};
@@ -12,33 +12,25 @@ import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import type { ConnectionStatus } from "./types";
type TerminalAlertsProps = {
type WorkspaceTerminalAlertsProps = {
agent: WorkspaceAgent | undefined;
status: ConnectionStatus;
onAlertChange: () => void;
};
export const TerminalAlerts = ({
export const WorkspaceTerminalAlerts = ({
agent,
status,
onAlertChange,
}: TerminalAlertsProps) => {
}: WorkspaceTerminalAlertsProps) => {
const lifecycleState = agent?.lifecycle_state;
const prevLifecycleState = useRef(lifecycleState);
useEffect(() => {
prevLifecycleState.current = lifecycleState;
}, [lifecycleState]);
// We want to observe the children of the wrapper to detect when the alert
// changes. So the terminal page can resize itself.
//
// Would it be possible to just always call fit() when this component
// re-renders instead of using an observer?
//
// This is a good question and the why this does not work is that the .fit()
// needs to run after the render so in this case, I just think the mutation
// observer is more reliable. I could use some hacky setTimeout inside of
// useEffect to do that, I guess, but I don't think it would be any better.
// MutationObserver triggers onAlertChange after DOM updates so
// the terminal can refit once alert height changes.
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!wrapperRef.current) {
@@ -0,0 +1,19 @@
import type {
DeploymentConfig,
UserAppearanceSettings,
} from "#/api/typesGenerated";
import { DEFAULT_TERMINAL_FONT, terminalFonts } from "#/theme/constants";
export function getTerminalConfig(
config: DeploymentConfig | undefined,
appearance: UserAppearanceSettings | undefined,
proxyPathAppURL: string | undefined,
) {
return {
renderer: config?.config?.web_terminal_renderer,
baseUrl:
process.env.NODE_ENV !== "development" ? proxyPathAppURL : undefined,
fontFamily:
terminalFonts[appearance?.terminal_font || DEFAULT_TERMINAL_FONT],
};
}
@@ -19,11 +19,7 @@ import {
} from "#/api/queries/chats";
import { workspaceByIdKey } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockUserOwner,
MockWorkspace,
MockWorkspaceAgent,
} from "#/testHelpers/entities";
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
@@ -76,14 +72,6 @@ const AgentChatPageLayout: FC = () => {
const CHAT_ID = "chat-1";
const MODEL_CONFIG_ID = "model-config-1";
const mockWorkspaceAgent: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "workspace-agent-1",
name: "workspace-agent",
expanded_directory: "/workspace/project",
apps: [],
};
const mockWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "workspace-1",
@@ -94,7 +82,7 @@ const mockWorkspace: TypesGen.Workspace = {
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [mockWorkspaceAgent],
agents: [],
},
],
},
@@ -1034,6 +1034,8 @@ const AgentChatPage: FC = () => {
persistedError={persistedError}
isArchived={isArchived}
hasWorkspace={Boolean(workspaceId)}
workspaceAgent={workspaceAgent}
workspace={workspace}
store={store}
initialInputValue={initialInputValue}
editing={editing}
@@ -9,6 +9,7 @@ import { MockUserOwner } from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
withProxyProvider,
} from "#/testHelpers/storybook";
import {
AgentChatPageLoadingView,
@@ -166,7 +167,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
const meta: Meta<typeof AgentChatPageView> = {
title: "pages/AgentsPage/AgentChatPageView",
component: AgentChatPageView,
decorators: [withAuthProvider, withDashboardProvider],
decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
@@ -23,6 +23,7 @@ import { ChatTopBar } from "./components/ChatTopBar";
import { GitPanel } from "./components/GitPanel/GitPanel";
import { RightPanel } from "./components/RightPanel/RightPanel";
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import { TerminalPanel } from "./components/TerminalPanel";
import type { ChatDetailError } from "./utils/usageLimitMessage";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -58,6 +59,8 @@ interface AgentChatPageViewProps {
persistedError: ChatDetailError | undefined;
isArchived: boolean;
hasWorkspace: boolean;
workspaceAgent?: TypesGen.WorkspaceAgent;
workspace?: TypesGen.Workspace;
// Store handle.
store: ChatStoreHandle;
@@ -150,6 +153,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
persistedError,
isArchived,
hasWorkspace,
workspaceAgent,
workspace,
store,
editing,
pendingEditMessageId,
@@ -387,6 +392,23 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
/>
),
},
...(hasWorkspace && workspaceAgent
? [
{
id: "terminal",
label: "Terminal",
content: (
<TerminalPanel
isVisible={
shouldShowSidebar && sidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
),
},
]
: []),
]}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentLifecycle } from "#/api/typesGenerated";
import {
MockDeploymentConfig,
MockUserAppearanceSettings,
MockWorkspaceAgent,
} from "#/testHelpers/entities";
import { withProxyProvider, withWebSocket } from "#/testHelpers/storybook";
import { TerminalPanel } from "./TerminalPanel";
const terminalQueries = [
{
key: ["deployment", "config"],
data: {
...MockDeploymentConfig,
config: {
...MockDeploymentConfig.config,
web_terminal_renderer: "canvas",
},
},
},
{ key: ["me", "appearance"], data: MockUserAppearanceSettings },
];
const createAgent = (lifecycleState: WorkspaceAgentLifecycle) => ({
...MockWorkspaceAgent,
lifecycle_state: lifecycleState,
});
const meta = {
title: "pages/AgentsPage/TerminalPanel",
component: TerminalPanel,
args: {
workspaceAgent: createAgent("ready"),
},
parameters: {
layout: "centered",
chromatic: { disableSnapshot: true },
queries: terminalQueries,
},
decorators: [
withProxyProvider(),
(Story) => (
<div style={{ width: 480, height: 600 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof TerminalPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
const promptMessage =
"\u001b[H\u001b[2J\u001b[1m\u001b[32m➜ \u001b[36mcoder\u001b[C\u001b[34mgit:(\u001b[31mmain\u001b[34m) \u001b[33m✗";
export const Connected: Story = {
decorators: [withWebSocket],
parameters: {
webSocket: [{ event: "message", data: promptMessage }],
},
};
export const AgentUnavailable: Story = {
args: {
workspaceAgent: undefined,
},
};
export const StartingAgent: Story = {
args: {
workspaceAgent: createAgent("starting"),
},
decorators: [withWebSocket],
parameters: {
webSocket: [{ event: "message", data: promptMessage }],
},
};
export const StartError: Story = {
args: {
workspaceAgent: createAgent("start_error"),
},
decorators: [withWebSocket],
parameters: {
webSocket: [],
},
};
export const Disconnected: Story = {
decorators: [withWebSocket],
parameters: {
webSocket: [{ event: "error" }],
},
};
@@ -0,0 +1,109 @@
import { type FC, useRef, useState } from "react";
import { useQuery } from "react-query";
import { deploymentConfig } from "#/api/queries/deployment";
import { appearanceSettings } from "#/api/queries/users";
import { workspaceUsage } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { useProxy } from "#/contexts/ProxyContext";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { getTerminalConfig } from "#/modules/terminal/terminalConfig";
import type { ConnectionStatus } from "#/modules/terminal/types";
import {
WorkspaceTerminal,
type WorkspaceTerminalHandle,
} from "#/modules/terminal/WorkspaceTerminal";
import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAlerts";
import { openMaybePortForwardedURL } from "#/utils/portForward";
interface TerminalPanelProps {
isVisible?: boolean;
workspace?: TypesGen.Workspace;
workspaceAgent?: TypesGen.WorkspaceAgent;
}
export const TerminalPanel: FC<TerminalPanelProps> = ({
isVisible,
workspace,
workspaceAgent,
}) => {
const { proxy } = useProxy();
const { metadata } = useEmbeddedMetadata();
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
const [reconnectionToken] = useState(() => crypto.randomUUID());
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const config = useQuery(deploymentConfig());
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const terminalConfig = getTerminalConfig(
config.data,
appearanceSettingsQuery.data,
proxy.preferredPathAppURL,
);
useQuery(
workspaceUsage({
usageApp: "reconnecting-pty",
connectionStatus,
workspaceId: workspace?.id,
agentId: workspaceAgent?.id,
}),
);
const handleOpenLink = (uri: string) => {
openMaybePortForwardedURL(
uri,
proxy.preferredWildcardHostname,
workspaceAgent?.name,
workspace?.name,
workspace?.owner_name,
);
};
const handleTerminalError = (error: Error) => {
console.error("WebSocket failed:", error);
};
const handleAlertChange = () => {
terminalRef.current?.refit();
};
if (!workspaceAgent) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs text-content-secondary">
Terminal will be available once the workspace agent is ready.
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<WorkspaceTerminalAlerts
agent={workspaceAgent}
status={connectionStatus}
onAlertChange={handleAlertChange}
/>
<div className="min-h-0 flex-1">
<WorkspaceTerminal
ref={terminalRef}
agentId={workspaceAgent.id}
operatingSystem={workspaceAgent.operating_system}
autoFocus={false}
isVisible={isVisible}
onStatusChange={setConnectionStatus}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
onOpenLink={handleOpenLink}
loading={config.isLoading || appearanceSettingsQuery.isLoading}
testId="agents-sidebar-terminal"
/>
</div>
</div>
);
};
@@ -1,8 +1,7 @@
import "jest-canvas-mock";
import { waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import WS from "jest-websocket-mock";
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { API } from "#/api/api";
import {
MockUserOwner,
@@ -13,6 +12,37 @@ import { renderWithAuth } from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server";
import TerminalPage from "./TerminalPage";
const reconnectToken = "terminal-page-test-reconnect-token";
vi.mock("uuid", () => ({
v4: () => "terminal-page-test-reconnect-token",
}));
vi.stubGlobal("jest", vi);
await import("jest-canvas-mock");
const { default: WS } = await import("jest-websocket-mock");
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const createWorkspaceTerminalWebSocket = () => {
const websocketProtocol =
window.location.protocol === "https:" ? "wss" : "ws";
const websocketUrl = `${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&height=24&width=80`;
return new WS(websocketUrl);
};
const renderTerminal = async (
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
) => {
@@ -28,7 +58,7 @@ const renderTerminal = async (
// rely on other screen elements to indicate completion.
const wrapper =
utils.container.querySelector<HTMLDivElement>("[data-status]")!;
expect(wrapper.dataset.state).not.toBe("initializing");
expect(wrapper.dataset.status).not.toBe("initializing");
});
return utils;
};
@@ -51,17 +81,16 @@ const expectTerminalText = (container: HTMLElement, text: string) => {
};
describe("TerminalPage", () => {
afterEach(() => {
WS.clean();
afterEach(async () => {
vi.restoreAllMocks();
await WS.clean();
});
it("loads the right workspace data", async () => {
jest
.spyOn(API, "getWorkspaceByOwnerAndName")
.mockResolvedValue(MockWorkspace);
new WS(
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
MockWorkspace,
);
createWorkspaceTerminalWebSocket();
await renderTerminal(
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
);
@@ -101,15 +130,10 @@ describe("TerminalPage", () => {
});
it("renders data from the backend", async () => {
const ws = new WS(
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
);
const ws = createWorkspaceTerminalWebSocket();
const text = "something to render";
const { container } = await renderTerminal();
// Ideally we could use ws.connected but that seems to pause React updates.
// For now, wait for the initial resize message instead.
await ws.nextMessage;
ws.send(text);
await expectTerminalText(container, text);
@@ -121,44 +145,35 @@ describe("TerminalPage", () => {
// in the other tests since ws.connected appears to pause React updates. So
// for now the initial resize message (and this test) are here to stay.
it("resizes on connect", async () => {
const ws = new WS(
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
);
const ws = createWorkspaceTerminalWebSocket();
const resizeMessage = ws.nextMessage;
await renderTerminal();
const msg = await ws.nextMessage;
const msg = await resizeMessage;
const req = JSON.parse(new TextDecoder().decode(msg as Uint8Array));
expect(req.height).toBeGreaterThan(0);
expect(req.width).toBeGreaterThan(0);
});
it("supports workspace.agent syntax", async () => {
const ws = new WS(
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
);
const ws = createWorkspaceTerminalWebSocket();
const text = "something to render";
const { container } = await renderTerminal(
`/some-user/${MockWorkspace.name}.${MockWorkspaceAgent.name}/terminal`,
);
// Ideally we could use ws.connected but that seems to pause React updates.
// For now, wait for the initial resize message instead.
await ws.nextMessage;
ws.send(text);
await expectTerminalText(container, text);
});
it("supports shift+enter", async () => {
const ws = new WS(
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
);
const ws = createWorkspaceTerminalWebSocket();
const initialResizeMessage = ws.nextMessage;
const { container } = await renderTerminal();
// Ideally we could use ws.connected but that seems to pause React updates.
// For now, wait for the initial resize message instead.
await ws.nextMessage;
await initialResizeMessage;
const msg = ws.nextMessage;
const terminal = container.getElementsByClassName("xterm");
+45 -341
View File
@@ -1,22 +1,7 @@
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 { type FC, useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
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 { deploymentConfig } from "#/api/queries/deployment";
import { appearanceSettings } from "#/api/queries/users";
import {
@@ -25,16 +10,18 @@ import {
} from "#/api/queries/workspaces";
import { useProxy } from "#/contexts/ProxyContext";
import { ThemeOverride } from "#/contexts/ThemeProvider";
import { useClipboard } from "#/hooks/useClipboard";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { getTerminalConfig } from "#/modules/terminal/terminalConfig";
import type { ConnectionStatus } from "#/modules/terminal/types";
import {
WorkspaceTerminal,
type WorkspaceTerminalHandle,
} from "#/modules/terminal/WorkspaceTerminal";
import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAlerts";
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 { TerminalAlerts } from "./TerminalAlerts";
import type { ConnectionStatus } from "./types";
const TerminalPage: FC = () => {
// Maybe one day we'll support a light themed terminal, but terminal coloring
@@ -45,10 +32,7 @@ const TerminalPage: FC = () => {
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 terminalRef = useRef<WorkspaceTerminalHandle>(null);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("initializing");
const [searchParams] = useSearchParams();
@@ -73,9 +57,6 @@ const TerminalPage: FC = () => {
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
const config = useQuery(deploymentConfig());
const renderer = config.data?.config.web_terminal_renderer;
const { copyToClipboard } = useClipboard();
// Periodically report workspace usage.
useQuery(
@@ -87,7 +68,6 @@ const TerminalPage: FC = () => {
}),
);
// handleWebLink handles opening of URLs in the terminal!
const handleWebLink = useCallback(
(uri: string) => {
openMaybePortForwardedURL(
@@ -100,130 +80,25 @@ const TerminalPage: FC = () => {
},
[workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname],
);
const handleWebLinkRef = useRef(handleWebLink);
useEffect(() => {
handleWebLinkRef.current = handleWebLink;
}, [handleWebLink]);
const handleTerminalError = useCallback((error: Error) => {
console.error("WebSocket failed:", error);
}, []);
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);
}),
);
const isMac = navigator.platform.match("Mac");
const copySelection = () => {
const selection = terminal.getSelection();
if (selection) {
copyToClipboard(selection);
}
};
// 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) => {
// Make shift+enter send ^[^M (escaped carriage return). Applications
// typically take this to mean to insert a literal newline.
if (ev.shiftKey && ev.key === "Enter") {
if (ev.type === "keydown") {
websocketRef.current?.send(
new TextEncoder().encode(
JSON.stringify({ data: escapedCarriageReturn }),
),
);
}
return false;
}
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text.
// By default this usually launches the browser dev tools, but users
// expect this keybinding to copy when in the context of the web terminal.
if ((isMac ? ev.metaKey : ev.ctrlKey) && ev.shiftKey && ev.key === "C") {
ev.preventDefault();
if (ev.type === "keydown") {
copySelection();
}
return false;
}
return true;
});
// Copy using the clipboard API on selection. This selected text will go
// into the clipboard, not the primary selection, as the browser does not
// give us an API to set the primary selection (only relevant to systems
// that have this distinction, like X11).
//
// We could bind the middle mouse button to paste from the clipboard to
// compensate, but then we would break pasting selections from external
// applications into the web terminal. Not sure which tradeoff is worse; it
// probably varies between users.
//
// In other words, this copied text can be pasted with a keybinding
// (typically ctrl+v, ctrl+shift+v, or shift+insert), but *not* with the
// middle mouse button.
terminal.onSelectionChange(() => {
copySelection();
});
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,
copyToClipboard,
]);
const terminalConfig = getTerminalConfig(
config.data,
appearanceSettingsQuery.data,
proxy.preferredPathAppURL,
);
const terminalErrorMessage =
workspace.error instanceof Error
? `Unable to fetch workspace: ${workspace.error.message}`
: !workspace.isLoading && !workspaceAgent
? "Unable to fetch workspace agent: no agent found with ID, is the workspace started?"
: undefined;
// Updates the reconnection token into the URL if necessary.
useEffect(() => {
@@ -241,164 +116,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(`Unable to fetch workspace: ${workspace.error.message}`);
setConnectionStatus("disconnected");
return;
}
if (!workspaceAgent) {
terminal.writeln(
"Unable to fetch workspace agent: 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 && (
@@ -411,17 +128,34 @@ const TerminalPage: FC = () => {
)}
<div className="flex flex-col h-screen" data-status={connectionStatus}>
<TerminalAlerts
<WorkspaceTerminalAlerts
agent={workspaceAgent}
status={connectionStatus}
onAlertChange={() => {
fitAddonRef.current?.fit();
terminalRef.current?.refit();
}}
/>
<div
css={styles.terminal}
ref={terminalWrapperRef}
data-testid="terminal"
<WorkspaceTerminal
ref={terminalRef}
agentId={workspaceAgent?.id}
operatingSystem={workspaceAgent?.operating_system}
initialCommand={command}
containerName={containerName}
containerUser={containerUser}
onStatusChange={setConnectionStatus}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
onOpenLink={handleWebLink}
loading={
workspace.isLoading ||
config.isLoading ||
appearanceSettingsQuery.isLoading
}
errorMessage={terminalErrorMessage}
testId="terminal"
/>
</div>
@@ -442,34 +176,4 @@ const TerminalPage: FC = () => {
);
};
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;