forked from molecule-ai/molecule-core
- ApprovalBanner: add role="alert" aria-live="assertive" aria-atomic="true" to each pending approval card; aria-hidden="true" on decorative ⚠ icon span - TerminalTab: add role="status" aria-live="polite" to connection status bar; add role="alert" to inline error message div - BundleDropZone: extract shared processFile(); add hidden <input type="file"> with id/accept/aria-label; add sr-only focus:not-sr-only keyboard trigger button; add role="status" aria-live="polite" to result toast Tests: 7 new assertions in aria-time-sensitive.test.tsx covering all 3 fixes (496/496 pass, build clean) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|
|
|
interface Props {
|
|
workspaceId: string;
|
|
}
|
|
|
|
const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", "");
|
|
|
|
export function TerminalTab({ workspaceId }: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<{ dispose: () => void } | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">("disconnected");
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [connectKey, setConnectKey] = useState(0);
|
|
|
|
const cleanup = useCallback(() => {
|
|
wsRef.current?.close();
|
|
wsRef.current = null;
|
|
observerRef.current?.disconnect();
|
|
observerRef.current = null;
|
|
termRef.current?.dispose();
|
|
termRef.current = null;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
let cancelled = false;
|
|
|
|
async function init() {
|
|
const el = containerRef.current;
|
|
if (!el || cancelled) return;
|
|
|
|
const { Terminal } = await import("xterm");
|
|
const { FitAddon } = await import("@xterm/addon-fit");
|
|
if (cancelled) return;
|
|
|
|
const fitAddon = new FitAddon();
|
|
const term = new Terminal({
|
|
theme: {
|
|
background: "#18181b",
|
|
foreground: "#e4e4e7",
|
|
cursor: "#3b82f6",
|
|
selectionBackground: "#3b82f644",
|
|
},
|
|
fontFamily: "JetBrains Mono, Menlo, Monaco, monospace",
|
|
fontSize: 12,
|
|
cursorBlink: true,
|
|
});
|
|
|
|
term.loadAddon(fitAddon);
|
|
term.open(el);
|
|
fitAddon.fit();
|
|
termRef.current = term;
|
|
|
|
// Connect WebSocket
|
|
setStatus("connecting");
|
|
const wsUrl = `${WS_URL}/workspaces/${workspaceId}/terminal`;
|
|
const socket = new WebSocket(wsUrl);
|
|
wsRef.current = socket;
|
|
socket.binaryType = "arraybuffer";
|
|
|
|
socket.onopen = () => {
|
|
if (cancelled) return;
|
|
setStatus("connected");
|
|
setErrorMsg(null);
|
|
term.writeln("\x1b[32mConnected to workspace shell\x1b[0m");
|
|
term.writeln("");
|
|
fitAddon.fit();
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
term.write(new Uint8Array(event.data));
|
|
} else {
|
|
term.write(event.data);
|
|
}
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
if (cancelled) return;
|
|
setStatus("disconnected");
|
|
term.writeln("");
|
|
term.writeln("\x1b[33mSession ended\x1b[0m");
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
if (cancelled) return;
|
|
setStatus("error");
|
|
setErrorMsg("Failed to connect — is the workspace container running?");
|
|
};
|
|
|
|
term.onData((data: string) => {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(data);
|
|
}
|
|
});
|
|
|
|
const observer = new ResizeObserver(() => fitAddon.fit());
|
|
observer.observe(el);
|
|
observerRef.current = observer;
|
|
}
|
|
|
|
init();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup();
|
|
};
|
|
}, [workspaceId, connectKey, cleanup]);
|
|
|
|
const reconnect = useCallback(() => {
|
|
cleanup();
|
|
setErrorMsg(null);
|
|
setConnectKey((k) => k + 1);
|
|
}, [cleanup]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Status bar — role="status" so connection state changes are announced politely */}
|
|
<div role="status" aria-live="polite" className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${
|
|
status === "connected" ? "bg-green-500" :
|
|
status === "connecting" ? "bg-yellow-500 motion-safe:animate-pulse" :
|
|
status === "error" ? "bg-red-500" : "bg-zinc-500"
|
|
}`} />
|
|
<span className="text-[10px] text-zinc-400">
|
|
{status === "connected" ? "Shell active" :
|
|
status === "connecting" ? "Connecting..." :
|
|
status === "error" ? "Connection failed" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
{(status === "disconnected" || status === "error") && (
|
|
<button
|
|
onClick={reconnect}
|
|
className="text-[10px] text-blue-400 hover:text-blue-300"
|
|
>
|
|
Reconnect
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error message — role="alert" announces immediately via assertive live region */}
|
|
{errorMsg && (
|
|
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
|
{errorMsg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Terminal */}
|
|
<div ref={containerRef} className="flex-1 p-1" />
|
|
</div>
|
|
);
|
|
}
|