mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+5
-13
@@ -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>
|
||||
);
|
||||
};
|
||||
+47
-32
@@ -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");
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user