feat(web): wire terminal panel to workspace shell ws

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 16:48:49 +08:00
co-authored by Claude
parent 0ec3efa812
commit 53fe249dd6
2 changed files with 269 additions and 11 deletions
+25 -11
View File
@@ -2,7 +2,12 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { useEffect, useRef, useState } from "react"; 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 { interface TerminalPanelProps {
workspaceId: string | null; workspaceId: string | null;
@@ -16,12 +21,10 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
const isDeadRef = useRef(false); const isDeadRef = useRef(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [isDead, setIsDead] = useState(false); const [isDead, setIsDead] = useState(false);
const { data: processStatus } = useProcessStatus(workspaceId); const { data: shellStatus } = useShellStatus(workspaceId);
const processRunning = processStatus?.running === true; const shellRunning = shellStatus?.running === true;
const { status, send, onData } = useProcessWebSocket( const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning);
workspaceId, const startShell = useShellStart();
processRunning,
);
// Initialize xterm once and keep it alive across WS status changes. // Initialize xterm once and keep it alive across WS status changes.
useEffect(() => { useEffect(() => {
@@ -146,22 +149,33 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
} }
const statusHint = isDead const statusHint = isDead
? "process exited — use the toolbar to start" ? "shell exited"
: status === "error" : status === "error"
? "terminal disconnected" ? "terminal disconnected"
: status === "connecting" : status === "connecting"
? "connecting…" ? "connecting…"
: null; : null;
const hint = processRunning const hint = shellRunning
? statusHint ? statusHint
: "process not running start it from the toolbar"; : "shell not running - start it to use the terminal";
return ( return (
<section className="flex h-full w-full flex-col overflow-hidden bg-[#171717]"> <section className="flex h-full w-full flex-col overflow-hidden bg-[#171717]">
{hint && ( {hint && (
<div className="flex h-6 shrink-0 items-center px-2 text-[11px] text-muted-foreground"> <div className="flex h-6 shrink-0 items-center gap-2 px-2 text-[11px] text-muted-foreground">
{hint} {hint}
{!shellRunning && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[11px]"
onClick={() => workspaceId && startShell.mutate(workspaceId)}
disabled={!workspaceId || startShell.isPending}
>
{startShell.isPending ? "starting…" : "Start shell"}
</Button>
)}
</div> </div>
)} )}
<div ref={containerRef} className="min-h-0 flex-1 p-2" /> <div ref={containerRef} className="min-h-0 flex-1 p-2" />
+244
View File
@@ -17,10 +17,20 @@ export interface ProcessStatus {
pid?: number; pid?: number;
} }
export interface ShellStatus {
workspaceId: string;
running: boolean;
pid?: number;
}
function processStatusQueryKey(workspaceId: string) { function processStatusQueryKey(workspaceId: string) {
return ["workspaces", workspaceId, "process", "status"] as const; return ["workspaces", workspaceId, "process", "status"] as const;
} }
function shellStatusQueryKey(workspaceId: string) {
return ["workspaces", workspaceId, "shell", "status"] as const;
}
async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> { async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> {
const res = await apiClient( const res = await apiClient(
`/api/workspaces/${encodeURIComponent(workspaceId)}/process/status`, `/api/workspaces/${encodeURIComponent(workspaceId)}/process/status`,
@@ -31,6 +41,16 @@ async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> {
return (await res.json()) as ProcessStatus; return (await res.json()) as ProcessStatus;
} }
async function fetchShellStatus(workspaceId: string): Promise<ShellStatus> {
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( export function useProcessStatus(
workspaceId: string | null, workspaceId: string | null,
): UseQueryResult<ProcessStatus, Error> { ): UseQueryResult<ProcessStatus, Error> {
@@ -42,6 +62,17 @@ export function useProcessStatus(
}); });
} }
export function useShellStatus(
workspaceId: string | null,
): UseQueryResult<ShellStatus, Error> {
return useQuery({
queryKey: shellStatusQueryKey(workspaceId ?? ""),
queryFn: () => fetchShellStatus(workspaceId!),
enabled: !!workspaceId,
refetchInterval: 5000,
});
}
async function postProcessAction( async function postProcessAction(
workspaceId: string, workspaceId: string,
action: "start" | "stop" | "restart", action: "start" | "stop" | "restart",
@@ -55,6 +86,19 @@ async function postProcessAction(
} }
} }
async function postShellAction(
workspaceId: string,
action: "start" | "stop" | "restart",
): Promise<void> {
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( function useProcessActionMutation(
action: "start" | "stop" | "restart", action: "start" | "stop" | "restart",
): UseMutationResult<void, Error, string> { ): UseMutationResult<void, Error, string> {
@@ -69,6 +113,20 @@ function useProcessActionMutation(
}); });
} }
function useShellActionMutation(
action: "start" | "stop" | "restart",
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => postShellAction(workspaceId, action),
onSuccess: (_, workspaceId) => {
queryClient.invalidateQueries({
queryKey: shellStatusQueryKey(workspaceId),
});
},
});
}
export function useProcessStart(): UseMutationResult<void, Error, string> { export function useProcessStart(): UseMutationResult<void, Error, string> {
return useProcessActionMutation("start"); return useProcessActionMutation("start");
} }
@@ -81,6 +139,18 @@ export function useProcessRestart(): UseMutationResult<void, Error, string> {
return useProcessActionMutation("restart"); return useProcessActionMutation("restart");
} }
export function useShellStart(): UseMutationResult<void, Error, string> {
return useShellActionMutation("start");
}
export function useShellStop(): UseMutationResult<void, Error, string> {
return useShellActionMutation("stop");
}
export function useShellRestart(): UseMutationResult<void, Error, string> {
return useShellActionMutation("restart");
}
export type ProcessWebSocketStatus = export type ProcessWebSocketStatus =
| "idle" | "idle"
| "connecting" | "connecting"
@@ -88,6 +158,13 @@ export type ProcessWebSocketStatus =
| "closed" | "closed"
| "error"; | "error";
export type ShellWebSocketStatus =
| "idle"
| "connecting"
| "open"
| "closed"
| "error";
export interface ProcessWebSocket { export interface ProcessWebSocket {
status: ProcessWebSocketStatus; status: ProcessWebSocketStatus;
send: (data: string) => void; send: (data: string) => void;
@@ -95,6 +172,13 @@ export interface ProcessWebSocket {
onData: (cb: (data: string) => void) => () => void; 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( export function useProcessWebSocket(
workspaceId: string | null, workspaceId: string | null,
enabled: boolean = true, enabled: boolean = true,
@@ -254,3 +338,163 @@ export function useProcessWebSocket(
return { status, send, close: closeSocket, onData }; return { status, send, close: closeSocket, onData };
} }
export function useShellWebSocket(
workspaceId: string | null,
enabled: boolean = true,
): ShellWebSocket {
const [status, setStatus] = useState<ShellWebSocketStatus>(() =>
workspaceId && enabled ? "connecting" : "idle",
);
const statusRef = useRef(status);
useEffect(() => {
statusRef.current = status;
}, [status]);
const socketRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<number | null>(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 };
}