feat(web): wire terminal panel to live process websocket

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 12:36:12 +08:00
co-authored by Claude
parent fa5b53939d
commit d8c8c7fc83
2 changed files with 82 additions and 12 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
<>
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
<Panel defaultSize={30} minSize={10} maxSize={70}>
<TerminalPanel />
<TerminalPanel workspaceId={workspaceId} />
</Panel>
</>
)}
+81 -11
View File
@@ -2,10 +2,23 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { useEffect, useRef, useState } from "react";
export function TerminalPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
import { useProcessWebSocket } from "@/lib/api/process";
interface TerminalPanelProps {
workspaceId: string | null;
}
export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const hasWelcomedRef = useRef(false);
const isDeadRef = useRef(false);
const [failed, setFailed] = useState(false);
const [isDead, setIsDead] = useState(false);
const { status, send, onData } = useProcessWebSocket(workspaceId);
// Initialize xterm once and keep it alive across WS status changes.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -28,12 +41,8 @@ export function TerminalPanel() {
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
terminal.writeln("codespace terminal");
terminal.writeln("Mock session initialized. No process is attached.");
terminal.writeln("");
terminal.writeln("$ pnpm test");
terminal.writeln("mock: 3 files checked, 0 failures");
terminal.write("$ ");
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
} catch {
setFailed(true);
terminal?.dispose();
@@ -72,9 +81,57 @@ export function TerminalPanel() {
window.removeEventListener("resize", handleResize);
terminal?.dispose();
fitAddon?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
// Reset per-workspace state and wire the active socket to xterm.
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal || !workspaceId) return;
hasWelcomedRef.current = false;
isDeadRef.current = false;
setIsDead(false);
const removeInputHandler = terminal.onData((input) => {
if (!isDeadRef.current) {
send(input);
}
}).dispose;
const unsubscribeData = onData((chunk) => {
terminal.write(chunk);
if (chunk.startsWith("[process exited")) {
isDeadRef.current = true;
setIsDead(true);
}
});
return () => {
removeInputHandler();
unsubscribeData();
};
}, [workspaceId, send, onData]);
// Print a one-time welcome banner the first time the socket opens.
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal || !workspaceId || status !== "open") return;
if (hasWelcomedRef.current) return;
hasWelcomedRef.current = true;
terminal.write(`\r\n[connected to ${workspaceId}]\r\n$ `);
}, [status, workspaceId]);
if (!workspaceId) {
return (
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
Select a workspace to use the terminal.
</section>
);
}
if (failed) {
return (
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
@@ -83,9 +140,22 @@ export function TerminalPanel() {
);
}
const statusHint = isDead
? "process exited — use the toolbar to start"
: status === "error"
? "terminal disconnected"
: status === "connecting"
? "connecting…"
: null;
return (
<section className="h-full w-full overflow-hidden bg-[#171717] p-2">
<div ref={containerRef} className="h-full w-full" />
<section className="flex h-full w-full flex-col overflow-hidden bg-[#171717]">
{statusHint && (
<div className="flex h-6 shrink-0 items-center px-2 text-[11px] text-muted-foreground">
{statusHint}
</div>
)}
<div ref={containerRef} className="min-h-0 flex-1 p-2" />
</section>
);
}