From 53fe249dd6bf99190ac16e3dee9a4277449ba7e2 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:48:49 +0800 Subject: [PATCH] feat(web): wire terminal panel to workspace shell ws Co-Authored-By: Claude --- web/src/components/terminal/TerminalPanel.tsx | 36 ++- web/src/lib/api/process.ts | 244 ++++++++++++++++++ 2 files changed, 269 insertions(+), 11 deletions(-) diff --git a/web/src/components/terminal/TerminalPanel.tsx b/web/src/components/terminal/TerminalPanel.tsx index 43b103d..94ca9be 100644 --- a/web/src/components/terminal/TerminalPanel.tsx +++ b/web/src/components/terminal/TerminalPanel.tsx @@ -2,7 +2,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; -import { useProcessStatus, useProcessWebSocket } from "@/lib/api/process"; +import { Button } from "@/components/ui/button"; +import { + useShellStart, + useShellStatus, + useShellWebSocket, +} from "@/lib/api/process"; interface TerminalPanelProps { workspaceId: string | null; @@ -16,12 +21,10 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { const isDeadRef = useRef(false); const [failed, setFailed] = useState(false); const [isDead, setIsDead] = useState(false); - const { data: processStatus } = useProcessStatus(workspaceId); - const processRunning = processStatus?.running === true; - const { status, send, onData } = useProcessWebSocket( - workspaceId, - processRunning, - ); + const { data: shellStatus } = useShellStatus(workspaceId); + const shellRunning = shellStatus?.running === true; + const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning); + const startShell = useShellStart(); // Initialize xterm once and keep it alive across WS status changes. useEffect(() => { @@ -146,22 +149,33 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { } const statusHint = isDead - ? "process exited — use the toolbar to start" + ? "shell exited" : status === "error" ? "terminal disconnected" : status === "connecting" ? "connecting…" : null; - const hint = processRunning + const hint = shellRunning ? statusHint - : "process not running — start it from the toolbar"; + : "shell not running - start it to use the terminal"; return (
{hint && ( -
+
{hint} + {!shellRunning && ( + + )}
)}
diff --git a/web/src/lib/api/process.ts b/web/src/lib/api/process.ts index 863b44e..be9ce19 100644 --- a/web/src/lib/api/process.ts +++ b/web/src/lib/api/process.ts @@ -17,10 +17,20 @@ export interface ProcessStatus { pid?: number; } +export interface ShellStatus { + workspaceId: string; + running: boolean; + pid?: number; +} + function processStatusQueryKey(workspaceId: string) { return ["workspaces", workspaceId, "process", "status"] as const; } +function shellStatusQueryKey(workspaceId: string) { + return ["workspaces", workspaceId, "shell", "status"] as const; +} + async function fetchProcessStatus(workspaceId: string): Promise { const res = await apiClient( `/api/workspaces/${encodeURIComponent(workspaceId)}/process/status`, @@ -31,6 +41,16 @@ async function fetchProcessStatus(workspaceId: string): Promise { return (await res.json()) as ProcessStatus; } +async function fetchShellStatus(workspaceId: string): Promise { + const res = await apiClient( + `/api/workspaces/${encodeURIComponent(workspaceId)}/shell/status`, + ); + if (!res.ok) { + throw new Error(`Failed to fetch shell status: ${res.status}`); + } + return (await res.json()) as ShellStatus; +} + export function useProcessStatus( workspaceId: string | null, ): UseQueryResult { @@ -42,6 +62,17 @@ export function useProcessStatus( }); } +export function useShellStatus( + workspaceId: string | null, +): UseQueryResult { + return useQuery({ + queryKey: shellStatusQueryKey(workspaceId ?? ""), + queryFn: () => fetchShellStatus(workspaceId!), + enabled: !!workspaceId, + refetchInterval: 5000, + }); +} + async function postProcessAction( workspaceId: string, action: "start" | "stop" | "restart", @@ -55,6 +86,19 @@ async function postProcessAction( } } +async function postShellAction( + workspaceId: string, + action: "start" | "stop" | "restart", +): Promise { + const res = await apiClient( + `/api/workspaces/${encodeURIComponent(workspaceId)}/shell/${action}`, + { method: "POST" }, + ); + if (!res.ok) { + throw new Error(`Failed to ${action} shell: ${res.status}`); + } +} + function useProcessActionMutation( action: "start" | "stop" | "restart", ): UseMutationResult { @@ -69,6 +113,20 @@ function useProcessActionMutation( }); } +function useShellActionMutation( + action: "start" | "stop" | "restart", +): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (workspaceId: string) => postShellAction(workspaceId, action), + onSuccess: (_, workspaceId) => { + queryClient.invalidateQueries({ + queryKey: shellStatusQueryKey(workspaceId), + }); + }, + }); +} + export function useProcessStart(): UseMutationResult { return useProcessActionMutation("start"); } @@ -81,6 +139,18 @@ export function useProcessRestart(): UseMutationResult { return useProcessActionMutation("restart"); } +export function useShellStart(): UseMutationResult { + return useShellActionMutation("start"); +} + +export function useShellStop(): UseMutationResult { + return useShellActionMutation("stop"); +} + +export function useShellRestart(): UseMutationResult { + return useShellActionMutation("restart"); +} + export type ProcessWebSocketStatus = | "idle" | "connecting" @@ -88,6 +158,13 @@ export type ProcessWebSocketStatus = | "closed" | "error"; +export type ShellWebSocketStatus = + | "idle" + | "connecting" + | "open" + | "closed" + | "error"; + export interface ProcessWebSocket { status: ProcessWebSocketStatus; send: (data: string) => void; @@ -95,6 +172,13 @@ export interface ProcessWebSocket { onData: (cb: (data: string) => void) => () => void; } +export interface ShellWebSocket { + status: ShellWebSocketStatus; + send: (data: string) => void; + close: () => void; + onData: (cb: (data: string) => void) => () => void; +} + export function useProcessWebSocket( workspaceId: string | null, enabled: boolean = true, @@ -254,3 +338,163 @@ export function useProcessWebSocket( return { status, send, close: closeSocket, onData }; } + +export function useShellWebSocket( + workspaceId: string | null, + enabled: boolean = true, +): ShellWebSocket { + const [status, setStatus] = useState(() => + workspaceId && enabled ? "connecting" : "idle", + ); + const statusRef = useRef(status); + useEffect(() => { + statusRef.current = status; + }, [status]); + + const socketRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const intentionalCloseRef = useRef(false); + const lastFrameWasExitRef = useRef(false); + const serverCleanCloseRef = useRef(false); + const callbacksRef = useRef(new Set<(data: string) => void>()); + + const clearReconnectTimeout = useCallback(() => { + if (reconnectTimeoutRef.current !== null) { + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }, []); + + const closeSocket = useCallback(() => { + intentionalCloseRef.current = true; + clearReconnectTimeout(); + const socket = socketRef.current; + if (socket) { + socket.close(); + socketRef.current = null; + } + setStatus("closed"); + }, [clearReconnectTimeout]); + + useEffect(() => { + if (!workspaceId || !enabled) { + setStatus("idle"); + clearReconnectTimeout(); + const socket = socketRef.current; + if (socket) { + socket.onclose = null; + socket.close(); + socketRef.current = null; + } + reconnectAttemptsRef.current = 0; + lastFrameWasExitRef.current = false; + intentionalCloseRef.current = false; + serverCleanCloseRef.current = false; + return; + } + + reconnectAttemptsRef.current = 0; + lastFrameWasExitRef.current = false; + intentionalCloseRef.current = false; + serverCleanCloseRef.current = false; + + const openSocket = () => { + const wsUrl = + (window.location.protocol === "https:" ? "wss:" : "ws:") + + "//" + + window.location.host + + "/api/workspaces/" + + encodeURIComponent(workspaceId) + + "/shell/ws"; + + const ws = new WebSocket(wsUrl); + socketRef.current = ws; + setStatus("connecting"); + + ws.onopen = () => { + reconnectAttemptsRef.current = 0; + lastFrameWasExitRef.current = false; + serverCleanCloseRef.current = false; + setStatus("open"); + }; + + ws.onmessage = (event) => { + const data = typeof event.data === "string" ? event.data : ""; + if ( + data.startsWith("[process exited") || + data.startsWith("\r\n[process exited") + ) { + lastFrameWasExitRef.current = true; + } + callbacksRef.current.forEach((cb) => cb(data)); + }; + + ws.onclose = (event) => { + socketRef.current = null; + if ( + (event.code === 1000 || event.code === 1001) && + !intentionalCloseRef.current + ) { + serverCleanCloseRef.current = true; + } + if ( + intentionalCloseRef.current || + lastFrameWasExitRef.current || + serverCleanCloseRef.current + ) { + setStatus("closed"); + return; + } + + const attempts = reconnectAttemptsRef.current; + if (attempts < 3) { + setStatus("connecting"); + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + openSocket(); + }, Math.pow(2, attempts) * 1000); + reconnectAttemptsRef.current = attempts + 1; + } else { + setStatus("error"); + } + }; + + ws.onerror = () => { + // The browser fires onclose after an error; handle retries there. + }; + }; + + openSocket(); + + return () => { + clearReconnectTimeout(); + const socket = socketRef.current; + if (socket) { + socket.onclose = null; + socket.close(); + socketRef.current = null; + } + }; + }, [workspaceId, enabled, clearReconnectTimeout]); + + const send = useCallback((data: string) => { + const socket = socketRef.current; + if ( + statusRef.current === "open" && + socket && + socket.readyState === WebSocket.OPEN + ) { + socket.send(data); + } + }, []); + + const onData = useCallback((cb: (data: string) => void) => { + callbacksRef.current.add(cb); + return () => { + callbacksRef.current.delete(cb); + }; + }, []); + + return { status, send, close: closeSocket, onData }; +}