feat(canvas/terminal): not-available banner for runtimes without a TTY
Pre-fix TerminalTab tried to open /ws/terminal/<id> for every workspace including external ones (which have no shell endpoint on the workspace-server). The server returned 404, status flipped to "error", the 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 (currently just "external"), TerminalTab renders a NotAvailablePanel with a big terminal-off icon and a one-line explanation including the runtime name. The xterm + WebSocket dance is skipped entirely — no spurious 404s, no scary error UI, no Reconnect that can't help. The runtime is determined from the data prop now threaded by SidePanel.tsx (existing pattern for ChatTab/ConfigTab/etc). Tests: 4 new in TerminalTab.notAvailable.test.tsx pin: external renders banner with runtime name, external doesn't open WS, claude- code mounts normally (regression cover for the early-return scope), data omitted falls through (back-compat). Build clean. 1258 tests pass.
This commit is contained in:
parent
111c3d2c01
commit
ba63f76e10
@ -283,7 +283,7 @@ export function SidePanel() {
|
||||
{panelTab === "skills" && <SkillsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@ -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 <TerminalTab workspaceId=... />
|
||||
* 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/<id> 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 (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-surface-sunken/30">
|
||||
{/* 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. */}
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-ink-soft mb-4"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="14"
|
||||
width="52"
|
||||
height="44"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M22 30 L30 36 L22 42"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<path
|
||||
d="M34 44 L44 44"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* Diagonal cancel slash */}
|
||||
<path
|
||||
d="M14 14 L58 58"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3>
|
||||
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
|
||||
This workspace runs the{" "}
|
||||
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
||||
which doesn't expose a shell. Use the Chat tab to interact with the
|
||||
agent directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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/<id> 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 <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@ -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/<id> 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(<TerminalTab workspaceId="ws-ext" data={externalData} />);
|
||||
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(<TerminalTab workspaceId="ws-ext" data={externalData} />);
|
||||
// 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(<TerminalTab workspaceId="ws-claude" data={claudeData} />);
|
||||
expect(screen.queryByText(/Terminal not available/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("data prop omitted falls through to normal mount (back-compat)", () => {
|
||||
render(<TerminalTab workspaceId="ws-no-data" />);
|
||||
expect(screen.queryByText(/Terminal not available/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user