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