From d8c8c7fc834f7462e6bebb5676ab2b6e7dd700eb Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:36:12 +0800 Subject: [PATCH] feat(web): wire terminal panel to live process websocket Co-Authored-By: Claude --- web/src/components/layout/WorkspaceShell.tsx | 2 +- web/src/components/terminal/TerminalPanel.tsx | 92 ++++++++++++++++--- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/web/src/components/layout/WorkspaceShell.tsx b/web/src/components/layout/WorkspaceShell.tsx index f57bf95..19792ee 100644 --- a/web/src/components/layout/WorkspaceShell.tsx +++ b/web/src/components/layout/WorkspaceShell.tsx @@ -135,7 +135,7 @@ export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) { <> - + )} diff --git a/web/src/components/terminal/TerminalPanel.tsx b/web/src/components/terminal/TerminalPanel.tsx index 4016a1b..4d97167 100644 --- a/web/src/components/terminal/TerminalPanel.tsx +++ b/web/src/components/terminal/TerminalPanel.tsx @@ -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(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(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(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 ( +
+ Select a workspace to use the terminal. +
+ ); + } + if (failed) { return (
@@ -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 ( -
-
+
+ {statusHint && ( +
+ {statusHint} +
+ )} +
); }