feat(web): multi-terminal tabs per workspace
Frontend wiring for the new multi-shell backend. The bottom panel's
Terminal tab now has a tab strip: one tab per shell (showing
shellId[:8] + an X to close), plus a + button to spawn a new shell.
Tab state lives in workspace-ui-store (shellsByWorkspace +
activeShellIdByWorkspace). Auto-start creates the first shell if the
list is empty.
- web/src/lib/api/process.ts:
- useShellStart returns ShellInfo on 201
- useShellStop / useShellRestart take {shellId}
- useShellStatus(workspaceId, shellId) requires both
- useShellWebSocket(workspaceId, shellId, enabled) refactored:
shellId change closes the current socket intentionally and opens
a new one with ?shellId= appended
- useShellResize takes {shellId, cols, rows}
- new useShellList(workspaceId)
- web/src/lib/store/workspace-ui-store.ts: shellsByWorkspace +
activeShellIdByWorkspace maps + setters (addShellToWorkspace,
removeShellFromWorkspace, setActiveShell, setShellsForWorkspace)
- web/src/components/terminal/TerminalPanel.tsx: requires shellId
prop; clears/resets terminal on shellId change (in addition to
workspaceId); welcome banner shows
'[connected to <workspaceId>/<shortShellId>]'
- web/src/components/layout/WorkspaceShell.tsx: new TerminalTabs
component:
- lists shells as tabs, + to add, x to close
- useShellList hydrates the store on mount
- auto-start guard via useRef so we don't double-fire
- close-pending tracked per shellId via Set
- renders the active <TerminalPanel workspaceId shellId>
Diff: 4 files. pnpm lint + pnpm build clean.
Conversation: B975103F-4528-493E-8F8A-91D20506631D
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Bot, PanelLeftClose, PanelLeftOpen, Play, RotateCw, Square, Terminal } from "lucide-react";
|
||||
import { Bot, PanelLeftClose, PanelLeftOpen, Play, Plus, RotateCw, Square, Terminal, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
useProcessStart,
|
||||
useProcessStatus,
|
||||
useProcessStop,
|
||||
useShellList,
|
||||
useShellStart,
|
||||
useShellStop,
|
||||
} from "@/lib/api/process";
|
||||
|
||||
interface WorkspaceShellProps {
|
||||
@@ -59,7 +62,7 @@ function BottomTabs({ workspaceId }: { workspaceId: string }) {
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{bottomTab === "terminal" ? (
|
||||
<TerminalPanel workspaceId={workspaceId} />
|
||||
<TerminalTabs workspaceId={workspaceId} />
|
||||
) : (
|
||||
<AcpPanel workspaceId={workspaceId} />
|
||||
)}
|
||||
@@ -68,6 +71,148 @@ function BottomTabs({ workspaceId }: { workspaceId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalTabs({ workspaceId }: { workspaceId: string }) {
|
||||
const shells = useWorkspaceUiStore((s) => s.shellsByWorkspace[workspaceId] ?? []);
|
||||
const activeShellId = useWorkspaceUiStore((s) => s.activeShellIdByWorkspace[workspaceId]);
|
||||
const setShellsForWorkspace = useWorkspaceUiStore((s) => s.setShellsForWorkspace);
|
||||
const addShellToWorkspace = useWorkspaceUiStore((s) => s.addShellToWorkspace);
|
||||
const removeShellFromWorkspace = useWorkspaceUiStore((s) => s.removeShellFromWorkspace);
|
||||
const setActiveShell = useWorkspaceUiStore((s) => s.setActiveShell);
|
||||
|
||||
const listQuery = useShellList(workspaceId);
|
||||
const start = useShellStart(workspaceId);
|
||||
const stop = useShellStop(workspaceId);
|
||||
const [closingShellIds, setClosingShellIds] = useState<Set<string>>(new Set());
|
||||
const autoStartedRef = useRef<Record<string, boolean>>({});
|
||||
const lastIdsKeyRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
if (
|
||||
listQuery.isSuccess &&
|
||||
listQuery.data.shells.length === 0 &&
|
||||
!autoStartedRef.current[workspaceId]
|
||||
) {
|
||||
autoStartedRef.current[workspaceId] = true;
|
||||
start.mutate();
|
||||
}
|
||||
}, [workspaceId, listQuery.isSuccess, listQuery.data, start]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId || !listQuery.isSuccess) return;
|
||||
const ids = listQuery.data.shells.map((s) => s.shellId);
|
||||
const key = ids.join(",");
|
||||
if (lastIdsKeyRef.current !== key) {
|
||||
lastIdsKeyRef.current = key;
|
||||
setShellsForWorkspace(workspaceId, ids);
|
||||
}
|
||||
const currentActive = activeShellId;
|
||||
if (!currentActive || !ids.includes(currentActive)) {
|
||||
setActiveShell(workspaceId, ids[0] ?? "");
|
||||
}
|
||||
}, [
|
||||
workspaceId,
|
||||
listQuery.isSuccess,
|
||||
listQuery.data,
|
||||
activeShellId,
|
||||
setShellsForWorkspace,
|
||||
setActiveShell,
|
||||
]);
|
||||
|
||||
const handleAddShell = () => {
|
||||
if (start.isPending) return;
|
||||
start.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
addShellToWorkspace(workspaceId, data.shellId);
|
||||
setActiveShell(workspaceId, data.shellId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseShell = (shellId: string) => {
|
||||
if (closingShellIds.has(shellId)) return;
|
||||
setClosingShellIds((prev) => new Set(prev).add(shellId));
|
||||
removeShellFromWorkspace(workspaceId, shellId);
|
||||
stop.mutate({ shellId }, {
|
||||
onSettled: () =>
|
||||
setClosingShellIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(shellId);
|
||||
return next;
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||
<div className="flex h-7 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
{shells.map((shellId) => {
|
||||
const isActive = shellId === activeShellId;
|
||||
const isClosing = closingShellIds.has(shellId);
|
||||
return (
|
||||
<button
|
||||
key={shellId}
|
||||
type="button"
|
||||
onClick={() => setActiveShell(workspaceId, shellId)}
|
||||
className={cn(
|
||||
"group flex h-full max-w-[10rem] shrink-0 items-center gap-1.5 border-b-2 px-2 text-xs transition-colors",
|
||||
isActive
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{shellId.slice(0, 8)}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseShell(shellId);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCloseShell(shellId);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isClosing && "pointer-events-none opacity-50",
|
||||
)}
|
||||
aria-label={`Close shell ${shellId.slice(0, 8)}`}
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 shrink-0 p-0"
|
||||
onClick={handleAddShell}
|
||||
disabled={start.isPending}
|
||||
aria-label="New shell"
|
||||
>
|
||||
<Plus className="size-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{activeShellId ? (
|
||||
<TerminalPanel workspaceId={workspaceId} shellId={activeShellId} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
{start.isPending ? "starting shell…" : "no active shell"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
||||
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useShellStart,
|
||||
useShellRestart,
|
||||
useShellStatus,
|
||||
useShellResize,
|
||||
useShellWebSocket,
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
|
||||
interface TerminalPanelProps {
|
||||
workspaceId: string | null;
|
||||
shellId: string;
|
||||
}
|
||||
|
||||
export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -22,12 +23,12 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
const isDeadRef = useRef(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [isDead, setIsDead] = useState(false);
|
||||
const { data: shellStatus } = useShellStatus(workspaceId);
|
||||
const { data: shellStatus } = useShellStatus(workspaceId, shellId);
|
||||
const shellRunning = shellStatus?.running === true;
|
||||
const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning);
|
||||
const startShell = useShellStart();
|
||||
const { status, send, onData } = useShellWebSocket(workspaceId, shellId, shellRunning);
|
||||
const restart = useShellRestart(workspaceId);
|
||||
|
||||
const resize = useShellResize();
|
||||
const resize = useShellResize(workspaceId, shellId);
|
||||
|
||||
// Initialize xterm once and keep it alive across WS status changes.
|
||||
useEffect(() => {
|
||||
@@ -61,16 +62,16 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
|
||||
removeResize = terminal.onResize(({ cols, rows }) => {
|
||||
if (!workspaceId) return;
|
||||
removeResize = terminal.onResize(({ cols, rows }) => {
|
||||
if (!workspaceId || !shellId) return;
|
||||
if (cols <= 0 || rows <= 0) return;
|
||||
if (debounceRef !== undefined) {
|
||||
window.clearTimeout(debounceRef);
|
||||
}
|
||||
debounceRef = window.setTimeout(() => {
|
||||
debounceRef = undefined;
|
||||
if (!workspaceId) return;
|
||||
resize.mutate({ workspaceId, cols, rows });
|
||||
if (!workspaceId || !shellId) return;
|
||||
resize.mutate({ cols, rows });
|
||||
}, 100);
|
||||
}).dispose;
|
||||
|
||||
@@ -117,23 +118,23 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
removeResize?.();
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
terminal?.dispose();
|
||||
fitAddon?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [resize, workspaceId]);
|
||||
terminal?.dispose();
|
||||
fitAddon?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [resize, workspaceId, shellId]);
|
||||
|
||||
// Reset per-workspace state and wire the active socket to xterm.
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal || !workspaceId) return;
|
||||
// Reset per-workspace state and wire the active socket to xterm.
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal || !workspaceId || !shellId) return;
|
||||
|
||||
hasWelcomedRef.current = false;
|
||||
isDeadRef.current = false;
|
||||
setIsDead(false);
|
||||
// Clear the previous workspace's scrollback so the user does not see
|
||||
// shell output from another workspace in the new one.
|
||||
// Clear the previous shell's scrollback so the user does not see
|
||||
// output from another shell in the new one.
|
||||
terminal.clear();
|
||||
terminal.reset();
|
||||
|
||||
@@ -155,16 +156,16 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
removeInputHandler();
|
||||
unsubscribeData();
|
||||
};
|
||||
}, [workspaceId, send, onData]);
|
||||
}, [workspaceId, shellId, 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;
|
||||
// Print a one-time welcome banner the first time the socket opens.
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal || !workspaceId || !shellId || status !== "open") return;
|
||||
if (hasWelcomedRef.current) return;
|
||||
hasWelcomedRef.current = true;
|
||||
terminal.write(`\r\n[connected to ${workspaceId}]\r\n$ `);
|
||||
}, [status, workspaceId]);
|
||||
terminal.write(`\r\n[connected to ${workspaceId}/${shellId.slice(0, 8)}]\r\n$ `);
|
||||
}, [status, workspaceId, shellId]);
|
||||
|
||||
if (!workspaceId) {
|
||||
return (
|
||||
@@ -190,9 +191,9 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
? "connecting…"
|
||||
: null;
|
||||
|
||||
const hint = shellRunning
|
||||
? statusHint
|
||||
: "shell not running - start it to use the terminal";
|
||||
const hint = shellRunning
|
||||
? statusHint
|
||||
: "shell not running - restart it to use the terminal";
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col overflow-hidden bg-[#171717]">
|
||||
@@ -204,10 +205,10 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[11px]"
|
||||
onClick={() => workspaceId && startShell.mutate(workspaceId)}
|
||||
disabled={!workspaceId || startShell.isPending}
|
||||
onClick={() => workspaceId && restart.mutate({ shellId })}
|
||||
disabled={!workspaceId || restart.isPending}
|
||||
>
|
||||
{startShell.isPending ? "starting…" : "Start shell"}
|
||||
{restart.isPending ? "restarting…" : "Restart shell"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+212
-61
@@ -19,16 +19,42 @@ export interface ProcessStatus {
|
||||
|
||||
export interface ShellStatus {
|
||||
workspaceId: string;
|
||||
shellId: string;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ShellStartResponse {
|
||||
workspaceId: string;
|
||||
shellId: string;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface ShellInfo {
|
||||
workspaceId: string;
|
||||
shellId: string;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ShellListResponse {
|
||||
workspaceId: string;
|
||||
shells: ShellInfo[];
|
||||
}
|
||||
|
||||
function processStatusQueryKey(workspaceId: string) {
|
||||
return ["workspaces", workspaceId, "process", "status"] as const;
|
||||
}
|
||||
|
||||
function shellStatusQueryKey(workspaceId: string) {
|
||||
return ["workspaces", workspaceId, "shell", "status"] as const;
|
||||
function shellStatusQueryKey(workspaceId: string, shellId: string) {
|
||||
return ["workspaces", workspaceId, "shell", "status", shellId] as const;
|
||||
}
|
||||
|
||||
function shellListQueryKey(workspaceId: string) {
|
||||
return ["workspaces", workspaceId, "shell", "list"] as const;
|
||||
}
|
||||
|
||||
async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> {
|
||||
@@ -41,9 +67,12 @@ async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> {
|
||||
return (await res.json()) as ProcessStatus;
|
||||
}
|
||||
|
||||
async function fetchShellStatus(workspaceId: string): Promise<ShellStatus> {
|
||||
async function fetchShellStatus(
|
||||
workspaceId: string,
|
||||
shellId: string,
|
||||
): Promise<ShellStatus> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/status`,
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/status?shellId=${encodeURIComponent(shellId)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch shell status: ${res.status}`);
|
||||
@@ -51,6 +80,16 @@ async function fetchShellStatus(workspaceId: string): Promise<ShellStatus> {
|
||||
return (await res.json()) as ShellStatus;
|
||||
}
|
||||
|
||||
async function fetchShellList(workspaceId: string): Promise<ShellListResponse> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch shell list: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as ShellListResponse;
|
||||
}
|
||||
|
||||
export function useProcessStatus(
|
||||
workspaceId: string | null,
|
||||
): UseQueryResult<ProcessStatus, Error> {
|
||||
@@ -64,15 +103,73 @@ export function useProcessStatus(
|
||||
|
||||
export function useShellStatus(
|
||||
workspaceId: string | null,
|
||||
shellId: string | null,
|
||||
): UseQueryResult<ShellStatus, Error> {
|
||||
return useQuery({
|
||||
queryKey: shellStatusQueryKey(workspaceId ?? ""),
|
||||
queryFn: () => fetchShellStatus(workspaceId!),
|
||||
queryKey: shellStatusQueryKey(workspaceId ?? "", shellId ?? ""),
|
||||
queryFn: () => fetchShellStatus(workspaceId!, shellId!),
|
||||
enabled: !!workspaceId && !!shellId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useShellList(
|
||||
workspaceId: string | null,
|
||||
): UseQueryResult<ShellListResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: shellListQueryKey(workspaceId ?? ""),
|
||||
queryFn: () => fetchShellList(workspaceId!),
|
||||
enabled: !!workspaceId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
async function postShellStart(workspaceId: string): Promise<ShellInfo> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/start`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to start shell: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as ShellInfo;
|
||||
}
|
||||
|
||||
async function postShellStop(
|
||||
workspaceId: string,
|
||||
shellId: string,
|
||||
): Promise<void> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shellId }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to stop shell: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function postShellRestart(
|
||||
workspaceId: string,
|
||||
shellId: string,
|
||||
): Promise<ShellInfo> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/restart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shellId }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to restart shell: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as ShellInfo;
|
||||
}
|
||||
|
||||
async function postProcessAction(
|
||||
workspaceId: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
@@ -86,18 +183,6 @@ 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(
|
||||
action: "start" | "stop" | "restart",
|
||||
@@ -113,19 +198,6 @@ 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> {
|
||||
return useProcessActionMutation("start");
|
||||
@@ -139,40 +211,106 @@ export function useProcessRestart(): UseMutationResult<void, Error, string> {
|
||||
return useProcessActionMutation("restart");
|
||||
}
|
||||
|
||||
export function useShellStart(): UseMutationResult<void, Error, string> {
|
||||
return useShellActionMutation("start");
|
||||
export function useShellStart(
|
||||
workspaceId: string | null,
|
||||
): UseMutationResult<ShellInfo, Error, void> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!workspaceId) {
|
||||
throw new Error("workspace id required");
|
||||
}
|
||||
return postShellStart(workspaceId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: shellListQueryKey(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useShellStop(): UseMutationResult<void, Error, string> {
|
||||
return useShellActionMutation("stop");
|
||||
export function useShellStop(
|
||||
workspaceId: string | null,
|
||||
): UseMutationResult<void, Error, { shellId: string }> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ shellId }) => {
|
||||
if (!workspaceId) {
|
||||
throw new Error("workspace id required");
|
||||
}
|
||||
return postShellStop(workspaceId, shellId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: shellListQueryKey(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useShellRestart(): UseMutationResult<void, Error, string> {
|
||||
return useShellActionMutation("restart");
|
||||
export function useShellRestart(
|
||||
workspaceId: string | null,
|
||||
): UseMutationResult<ShellInfo, Error, { shellId: string }> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ shellId }) => {
|
||||
if (!workspaceId) {
|
||||
throw new Error("workspace id required");
|
||||
}
|
||||
return postShellRestart(workspaceId, shellId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (!workspaceId) return;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: shellListQueryKey(workspaceId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaces", workspaceId, "shell", "status"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShellResizeVariables {
|
||||
workspaceId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
async function postShellResize(
|
||||
workspaceId: string,
|
||||
shellId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Promise<void> {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/resize`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shellId, cols, rows }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to resize shell: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function useShellResize(): UseMutationResult<void, Error, ShellResizeVariables> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, cols, rows }) => {
|
||||
const res = await apiClient(
|
||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/resize`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to resize shell: ${res.status}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
export function useShellResize(
|
||||
workspaceId: string | null,
|
||||
shellId: string | null,
|
||||
): UseMutationResult<void, Error, { cols: number; rows: number }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ cols, rows }) => {
|
||||
if (!workspaceId || !shellId) {
|
||||
throw new Error("workspace id and shell id required");
|
||||
}
|
||||
return postShellResize(workspaceId, shellId, cols, rows);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ProcessWebSocketStatus =
|
||||
@@ -203,6 +341,7 @@ export interface ShellWebSocket {
|
||||
onData: (cb: (data: string) => void) => () => void;
|
||||
}
|
||||
|
||||
|
||||
export function useProcessWebSocket(
|
||||
workspaceId: string | null,
|
||||
enabled: boolean = true,
|
||||
@@ -365,10 +504,11 @@ export function useProcessWebSocket(
|
||||
|
||||
export function useShellWebSocket(
|
||||
workspaceId: string | null,
|
||||
shellId: string | null,
|
||||
enabled: boolean = true,
|
||||
): ShellWebSocket {
|
||||
const [status, setStatus] = useState<ShellWebSocketStatus>(() =>
|
||||
workspaceId && enabled ? "connecting" : "idle",
|
||||
workspaceId && shellId && enabled ? "connecting" : "idle",
|
||||
);
|
||||
const statusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
@@ -402,7 +542,7 @@ export function useShellWebSocket(
|
||||
}, [clearReconnectTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId || !enabled) {
|
||||
if (!workspaceId || !shellId || !enabled) {
|
||||
setStatus("idle");
|
||||
clearReconnectTimeout();
|
||||
const socket = socketRef.current;
|
||||
@@ -418,6 +558,16 @@ export function useShellWebSocket(
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentionally close any previous socket before opening one for the new shell.
|
||||
if (socketRef.current) {
|
||||
intentionalCloseRef.current = true;
|
||||
const socket = socketRef.current;
|
||||
socket.onclose = null;
|
||||
socket.close();
|
||||
socketRef.current = null;
|
||||
intentionalCloseRef.current = false;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
lastFrameWasExitRef.current = false;
|
||||
intentionalCloseRef.current = false;
|
||||
@@ -430,7 +580,8 @@ export function useShellWebSocket(
|
||||
window.location.host +
|
||||
"/api/workspaces/" +
|
||||
encodeURIComponent(workspaceId) +
|
||||
"/shell/ws";
|
||||
"/shell/ws?shellId=" +
|
||||
encodeURIComponent(shellId);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
socketRef.current = ws;
|
||||
@@ -500,7 +651,7 @@ export function useShellWebSocket(
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, enabled, clearReconnectTimeout]);
|
||||
}, [workspaceId, shellId, enabled, clearReconnectTimeout]);
|
||||
|
||||
const send = useCallback((data: string) => {
|
||||
const socket = socketRef.current;
|
||||
|
||||
@@ -8,11 +8,17 @@ interface WorkspaceUiState {
|
||||
sidebarCollapsed: boolean;
|
||||
currentWorkspaceId: string | null;
|
||||
bottomTab: BottomTab;
|
||||
shellsByWorkspace: Record<string, string[]>;
|
||||
activeShellIdByWorkspace: Record<string, string>;
|
||||
setActiveFilePath: (path: string) => void;
|
||||
toggleTerminal: () => void;
|
||||
toggleSidebar: () => void;
|
||||
setCurrentWorkspaceId: (id: string | null) => void;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
setShellsForWorkspace: (workspaceId: string, shellIds: string[]) => void;
|
||||
addShellToWorkspace: (workspaceId: string, shellId: string) => void;
|
||||
removeShellFromWorkspace: (workspaceId: string, shellId: string) => void;
|
||||
setActiveShell: (workspaceId: string, shellId: string) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
||||
@@ -21,9 +27,46 @@ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
currentWorkspaceId: null,
|
||||
bottomTab: "terminal",
|
||||
shellsByWorkspace: {},
|
||||
activeShellIdByWorkspace: {},
|
||||
setActiveFilePath: (path) => set({ activeFilePath: path }),
|
||||
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
|
||||
setBottomTab: (tab) => set({ bottomTab: tab }),
|
||||
setShellsForWorkspace: (workspaceId, shellIds) =>
|
||||
set((s) => ({
|
||||
shellsByWorkspace: { ...s.shellsByWorkspace, [workspaceId]: shellIds },
|
||||
})),
|
||||
addShellToWorkspace: (workspaceId, shellId) =>
|
||||
set((s) => {
|
||||
const ids = s.shellsByWorkspace[workspaceId] ?? [];
|
||||
if (ids.includes(shellId)) return {};
|
||||
return {
|
||||
shellsByWorkspace: {
|
||||
...s.shellsByWorkspace,
|
||||
[workspaceId]: [...ids, shellId],
|
||||
},
|
||||
};
|
||||
}),
|
||||
removeShellFromWorkspace: (workspaceId, shellId) =>
|
||||
set((s) => {
|
||||
const ids = s.shellsByWorkspace[workspaceId] ?? [];
|
||||
const next = ids.filter((id) => id !== shellId);
|
||||
const active = s.activeShellIdByWorkspace[workspaceId];
|
||||
return {
|
||||
shellsByWorkspace: { ...s.shellsByWorkspace, [workspaceId]: next },
|
||||
activeShellIdByWorkspace:
|
||||
active === shellId
|
||||
? { ...s.activeShellIdByWorkspace, [workspaceId]: next[0] ?? "" }
|
||||
: s.activeShellIdByWorkspace,
|
||||
};
|
||||
}),
|
||||
setActiveShell: (workspaceId, shellId) =>
|
||||
set((s) => ({
|
||||
activeShellIdByWorkspace: {
|
||||
...s.activeShellIdByWorkspace,
|
||||
[workspaceId]: shellId,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user