diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx
index 437aff90..2db6c4a1 100644
--- a/canvas/src/components/SidePanel.tsx
+++ b/canvas/src/components/SidePanel.tsx
@@ -283,7 +283,7 @@ export function SidePanel() {
{panelTab === "skills" && }
{panelTab === "activity" && }
{panelTab === "chat" && }
- {panelTab === "terminal" && }
+ {panelTab === "terminal" && }
{panelTab === "config" && }
{panelTab === "schedule" && }
{panelTab === "channels" && }
diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx
index 1c2c2027..43fc8e04 100644
--- a/canvas/src/components/tabs/TerminalTab.tsx
+++ b/canvas/src/components/tabs/TerminalTab.tsx
@@ -1,16 +1,105 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
+import type { WorkspaceNodeData } from "@/store/canvas";
interface Props {
workspaceId: string;
+ /** Workspace metadata from the canvas store. Optional for back-compat
+ * with any caller that still mounts
+ * without threading data through (e.g. tests). When present, the
+ * runtime field gates the early-return below. */
+ data?: WorkspaceNodeData;
}
import { deriveWsBaseUrl } from "@/lib/ws-url";
const WS_URL = deriveWsBaseUrl();
-export function TerminalTab({ workspaceId }: Props) {
+/**
+ * NotAvailablePanel — full-tab placeholder with a big terminal-off icon
+ * for runtimes that don't expose a TTY (e.g. external workspaces, where
+ * the platform doesn't own the process). Pre-fix the tab tried to open
+ * a WebSocket against /ws/terminal/ for these workspaces, the server
+ * 404'd, and the user saw "Connection failed" — which reads as a bug,
+ * not as "this runtime intentionally has no shell". This banner makes
+ * the absence intentional.
+ */
+function NotAvailablePanel({ runtime }: { runtime: string }) {
+ return (
+
+ {/* Big terminal-off icon — bracket "[_]" with a slash through it.
+ Custom inline SVG so we don't depend on an icon set being
+ present at canvas build-time. */}
+
+
Terminal not available
+
+ This workspace runs the{" "}
+ {runtime} runtime,
+ which doesn't expose a shell. Use the Chat tab to interact with the
+ agent directly.
+
+
+ );
+}
+
+/** Runtimes that don't expose a TTY. Keep narrow — only add a runtime
+ * here when its provisioner genuinely has no shell endpoint, otherwise
+ * the user loses access to a real debugging surface. */
+const RUNTIMES_WITHOUT_TERMINAL = new Set(["external"]);
+
+export function TerminalTab({ workspaceId, data }: Props) {
+ // Early-return for runtimes that have no shell. Skips the entire
+ // xterm + WebSocket dance below — without this, mounting the tab
+ // for an external workspace pops the WS, gets a 404 from the
+ // workspace-server (no /ws/terminal/ route registered for it),
+ // and shows "Connection failed" with a Reconnect button — confusing
+ // because the workspace IS healthy, just doesn't have a TTY.
+ if (data && RUNTIMES_WITHOUT_TERMINAL.has(data.runtime)) {
+ return ;
+ }
+
const containerRef = useRef(null);
const termRef = useRef<{ dispose: () => void } | null>(null);
const wsRef = useRef(null);
diff --git a/canvas/src/components/tabs/__tests__/TerminalTab.notAvailable.test.tsx b/canvas/src/components/tabs/__tests__/TerminalTab.notAvailable.test.tsx
new file mode 100644
index 00000000..df955a46
--- /dev/null
+++ b/canvas/src/components/tabs/__tests__/TerminalTab.notAvailable.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+//
+// Pins the "Terminal not available" early-return added 2026-05-05.
+//
+// Pre-fix: TerminalTab tried to open /ws/terminal/ for every
+// workspace including external runtimes (which have no shell endpoint).
+// The server returned 404, status flipped to "error", user saw
+// "Connection failed" with a Reconnect button — reading as a bug
+// when really the runtime intentionally has no TTY. Now: when
+// data.runtime is in RUNTIMES_WITHOUT_TERMINAL, render a banner +
+// big icon instead of mounting xterm/WS.
+//
+// Pinned branches:
+// 1. external runtime → "Terminal not available" banner renders,
+// runtime name surfaces in the body so the user knows WHY.
+// 2. external runtime → xterm + WebSocket are NOT initialised.
+// Verified by checking the global WebSocket constructor isn't
+// called.
+// 3. claude-code (or any other runtime) → no banner, normal mount
+// proceeds. Pre-fix regression cover.
+// 4. data prop omitted (back-compat with any caller that doesn't
+// thread it through) → no early-return, falls through to normal
+// mount. Tested via the absence of the banner.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import React from "react";
+
+afterEach(cleanup);
+
+// xterm + addon-fit are dynamically imported by TerminalTab. Stub them
+// so the tests don't pull a 200KB+ dependency just to verify the
+// not-available banner. The stubs only matter for the non-banner
+// branches; the banner returns BEFORE the dynamic import.
+vi.mock("xterm", () => ({
+ Terminal: vi.fn().mockImplementation(() => ({
+ loadAddon: vi.fn(),
+ open: vi.fn(),
+ onData: vi.fn(),
+ write: vi.fn(),
+ dispose: vi.fn(),
+ onResize: vi.fn(),
+ cols: 80,
+ rows: 24,
+ })),
+}));
+vi.mock("@xterm/addon-fit", () => ({
+ FitAddon: vi.fn().mockImplementation(() => ({
+ fit: vi.fn(),
+ })),
+}));
+
+// Track WebSocket constructor calls — this is the load-bearing
+// assertion for "external doesn't even try to connect".
+let wsConstructed = 0;
+beforeEach(() => {
+ wsConstructed = 0;
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = vi
+ .fn()
+ .mockImplementation(() => {
+ wsConstructed++;
+ return {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ send: vi.fn(),
+ close: vi.fn(),
+ readyState: 0,
+ };
+ });
+});
+
+import { TerminalTab } from "../TerminalTab";
+
+const externalData = { runtime: "external", status: "online" } as unknown as Parameters<
+ typeof TerminalTab
+>[0]["data"];
+
+const claudeData = { runtime: "claude-code", status: "online" } as unknown as Parameters<
+ typeof TerminalTab
+>[0]["data"];
+
+describe("TerminalTab not-available early-return for runtimes without TTY", () => {
+ it("external runtime renders the not-available banner with runtime name", () => {
+ render();
+ expect(screen.getByText(/Terminal not available/i)).not.toBeNull();
+ // Runtime name surfaces so user knows WHY there's no terminal.
+ expect(screen.getByText(/external/)).not.toBeNull();
+ });
+
+ it("external runtime does NOT open a WebSocket", async () => {
+ render();
+ // Wait a tick for any deferred init (there shouldn't be any, but
+ // tolerate a microtask boundary).
+ await new Promise((r) => setTimeout(r, 0));
+ expect(wsConstructed).toBe(0);
+ });
+
+ it("claude-code runtime does NOT render the banner (normal mount)", () => {
+ render();
+ expect(screen.queryByText(/Terminal not available/i)).toBeNull();
+ });
+
+ it("data prop omitted falls through to normal mount (back-compat)", () => {
+ render();
+ expect(screen.queryByText(/Terminal not available/i)).toBeNull();
+ });
+});