From 12f87acad60596ac070274b949069062b87d42e4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 1 Apr 2026 15:38:23 +0200 Subject: [PATCH] feat(site): add terminal panel to chat sidebar (#23231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`_ --- site/src/api/queries/workspaces.ts | 2 +- .../modules/terminal/WorkspaceTerminal.tsx | 516 ++++++++++++++++++ .../terminal/WorkspaceTerminalAlerts.tsx} | 18 +- site/src/modules/terminal/terminalConfig.ts | 19 + .../terminal}/types.ts | 0 .../AgentsPage/AgentChatPage.stories.tsx | 16 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 2 + .../AgentsPage/AgentChatPageView.stories.tsx | 3 +- .../pages/AgentsPage/AgentChatPageView.tsx | 22 + .../components/TerminalPanel.stories.tsx | 95 ++++ .../AgentsPage/components/TerminalPanel.tsx | 109 ++++ ...nalPage.jest.tsx => TerminalPage.test.tsx} | 79 +-- site/src/pages/TerminalPage/TerminalPage.tsx | 386 ++----------- 13 files changed, 865 insertions(+), 402 deletions(-) create mode 100644 site/src/modules/terminal/WorkspaceTerminal.tsx rename site/src/{pages/TerminalPage/TerminalAlerts.tsx => modules/terminal/WorkspaceTerminalAlerts.tsx} (88%) create mode 100644 site/src/modules/terminal/terminalConfig.ts rename site/src/{pages/TerminalPage => modules/terminal}/types.ts (100%) create mode 100644 site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/TerminalPanel.tsx rename site/src/pages/TerminalPage/{TerminalPage.jest.tsx => TerminalPage.test.tsx} (72%) diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 3828d7b560..5bad8cee66 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -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"; diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx new file mode 100644 index 0000000000..353b7259fd --- /dev/null +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -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; + 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) => { + 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(null); + const fitAddonRef = useRef(undefined); + const websocketRef = useRef(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(); + 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 ( + <> + +
+ + ); +}; diff --git a/site/src/pages/TerminalPage/TerminalAlerts.tsx b/site/src/modules/terminal/WorkspaceTerminalAlerts.tsx similarity index 88% rename from site/src/pages/TerminalPage/TerminalAlerts.tsx rename to site/src/modules/terminal/WorkspaceTerminalAlerts.tsx index 74e12f7d40..5adec2efbd 100644 --- a/site/src/pages/TerminalPage/TerminalAlerts.tsx +++ b/site/src/modules/terminal/WorkspaceTerminalAlerts.tsx @@ -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(null); useEffect(() => { if (!wrapperRef.current) { diff --git a/site/src/modules/terminal/terminalConfig.ts b/site/src/modules/terminal/terminalConfig.ts new file mode 100644 index 0000000000..796f4e9467 --- /dev/null +++ b/site/src/modules/terminal/terminalConfig.ts @@ -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], + }; +} diff --git a/site/src/pages/TerminalPage/types.ts b/site/src/modules/terminal/types.ts similarity index 100% rename from site/src/pages/TerminalPage/types.ts rename to site/src/modules/terminal/types.ts diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 3153adba44..50687411f8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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: [], }, ], }, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c449bf3a29..83dfad0682 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1034,6 +1034,8 @@ const AgentChatPage: FC = () => { persistedError={persistedError} isArchived={isArchived} hasWorkspace={Boolean(workspaceId)} + workspaceAgent={workspaceAgent} + workspace={workspace} store={store} initialInputValue={initialInputValue} editing={editing} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index d61ba7a9d2..546dbe73ee 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -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 = ({ editing, ...overrides }) => { const meta: Meta = { title: "pages/AgentsPage/AgentChatPageView", component: AgentChatPageView, - decorators: [withAuthProvider, withDashboardProvider], + decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], parameters: { layout: "fullscreen", user: MockUserOwner, diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 30a079d4de..d43eacccca 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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["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 = ({ persistedError, isArchived, hasWorkspace, + workspaceAgent, + workspace, store, editing, pendingEditMessageId, @@ -387,6 +392,23 @@ export const AgentChatPageView: FC = ({ /> ), }, + ...(hasWorkspace && workspaceAgent + ? [ + { + id: "terminal", + label: "Terminal", + content: ( + + ), + }, + ] + : []), ]} onClose={() => onSetShowSidebarPanel(false)} isExpanded={visualExpanded} diff --git a/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx b/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx new file mode 100644 index 0000000000..7a0cc4775e --- /dev/null +++ b/site/src/pages/AgentsPage/components/TerminalPanel.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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" }], + }, +}; diff --git a/site/src/pages/AgentsPage/components/TerminalPanel.tsx b/site/src/pages/AgentsPage/components/TerminalPanel.tsx new file mode 100644 index 0000000000..65db88ec16 --- /dev/null +++ b/site/src/pages/AgentsPage/components/TerminalPanel.tsx @@ -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 = ({ + isVisible, + workspace, + workspaceAgent, +}) => { + const { proxy } = useProxy(); + const { metadata } = useEmbeddedMetadata(); + const terminalRef = useRef(null); + const [reconnectionToken] = useState(() => crypto.randomUUID()); + const [connectionStatus, setConnectionStatus] = + useState("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 ( +
+
+ Terminal will be available once the workspace agent is ready. +
+
+ ); + } + + return ( +
+ +
+ +
+
+ ); +}; diff --git a/site/src/pages/TerminalPage/TerminalPage.jest.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx similarity index 72% rename from site/src/pages/TerminalPage/TerminalPage.jest.tsx rename to site/src/pages/TerminalPage/TerminalPage.test.tsx index bbea5a97f8..3417a9bbed 100644 --- a/site/src/pages/TerminalPage/TerminalPage.jest.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -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("[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"); diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 0e2963859b..e4bcd9142a 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -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(null); - // The terminal is maintained as a state to trigger certain effects when it - // updates. - const [terminal, setTerminal] = useState(); + const terminalRef = useRef(null); const [connectionStatus, setConnectionStatus] = useState("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(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(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 ( {workspace.data && ( @@ -411,17 +128,34 @@ const TerminalPage: FC = () => { )}
- { - fitAddonRef.current?.fit(); + terminalRef.current?.refit(); }} /> -
@@ -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>; - export default TerminalPage;