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(); + }); +});