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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
useProcessStart,
|
useProcessStart,
|
||||||
useProcessStatus,
|
useProcessStatus,
|
||||||
useProcessStop,
|
useProcessStop,
|
||||||
|
useShellList,
|
||||||
|
useShellStart,
|
||||||
|
useShellStop,
|
||||||
} from "@/lib/api/process";
|
} from "@/lib/api/process";
|
||||||
|
|
||||||
interface WorkspaceShellProps {
|
interface WorkspaceShellProps {
|
||||||
@@ -59,7 +62,7 @@ function BottomTabs({ workspaceId }: { workspaceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
{bottomTab === "terminal" ? (
|
{bottomTab === "terminal" ? (
|
||||||
<TerminalPanel workspaceId={workspaceId} />
|
<TerminalTabs workspaceId={workspaceId} />
|
||||||
) : (
|
) : (
|
||||||
<AcpPanel 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) {
|
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
|
||||||
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
|
||||||
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
useShellStart,
|
useShellRestart,
|
||||||
useShellStatus,
|
useShellStatus,
|
||||||
useShellResize,
|
useShellResize,
|
||||||
useShellWebSocket,
|
useShellWebSocket,
|
||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
|
|
||||||
interface TerminalPanelProps {
|
interface TerminalPanelProps {
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
|
shellId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
export function TerminalPanel({ workspaceId, shellId }: TerminalPanelProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const terminalRef = useRef<Terminal | null>(null);
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
@@ -22,12 +23,12 @@ 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: shellStatus } = useShellStatus(workspaceId);
|
const { data: shellStatus } = useShellStatus(workspaceId, shellId);
|
||||||
const shellRunning = shellStatus?.running === true;
|
const shellRunning = shellStatus?.running === true;
|
||||||
const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning);
|
const { status, send, onData } = useShellWebSocket(workspaceId, shellId, shellRunning);
|
||||||
const startShell = useShellStart();
|
const restart = useShellRestart(workspaceId);
|
||||||
|
|
||||||
const resize = useShellResize();
|
const resize = useShellResize(workspaceId, shellId);
|
||||||
|
|
||||||
// Initialize xterm once and keep it alive across WS status changes.
|
// Initialize xterm once and keep it alive across WS status changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,15 +63,15 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
|
|
||||||
removeResize = terminal.onResize(({ cols, rows }) => {
|
removeResize = terminal.onResize(({ cols, rows }) => {
|
||||||
if (!workspaceId) return;
|
if (!workspaceId || !shellId) return;
|
||||||
if (cols <= 0 || rows <= 0) return;
|
if (cols <= 0 || rows <= 0) return;
|
||||||
if (debounceRef !== undefined) {
|
if (debounceRef !== undefined) {
|
||||||
window.clearTimeout(debounceRef);
|
window.clearTimeout(debounceRef);
|
||||||
}
|
}
|
||||||
debounceRef = window.setTimeout(() => {
|
debounceRef = window.setTimeout(() => {
|
||||||
debounceRef = undefined;
|
debounceRef = undefined;
|
||||||
if (!workspaceId) return;
|
if (!workspaceId || !shellId) return;
|
||||||
resize.mutate({ workspaceId, cols, rows });
|
resize.mutate({ cols, rows });
|
||||||
}, 100);
|
}, 100);
|
||||||
}).dispose;
|
}).dispose;
|
||||||
|
|
||||||
@@ -122,18 +123,18 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
};
|
};
|
||||||
}, [resize, workspaceId]);
|
}, [resize, workspaceId, shellId]);
|
||||||
|
|
||||||
// Reset per-workspace state and wire the active socket to xterm.
|
// Reset per-workspace state and wire the active socket to xterm.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
if (!terminal || !workspaceId) return;
|
if (!terminal || !workspaceId || !shellId) return;
|
||||||
|
|
||||||
hasWelcomedRef.current = false;
|
hasWelcomedRef.current = false;
|
||||||
isDeadRef.current = false;
|
isDeadRef.current = false;
|
||||||
setIsDead(false);
|
setIsDead(false);
|
||||||
// Clear the previous workspace's scrollback so the user does not see
|
// Clear the previous shell's scrollback so the user does not see
|
||||||
// shell output from another workspace in the new one.
|
// output from another shell in the new one.
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
|
|
||||||
@@ -155,16 +156,16 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|||||||
removeInputHandler();
|
removeInputHandler();
|
||||||
unsubscribeData();
|
unsubscribeData();
|
||||||
};
|
};
|
||||||
}, [workspaceId, send, onData]);
|
}, [workspaceId, shellId, send, onData]);
|
||||||
|
|
||||||
// Print a one-time welcome banner the first time the socket opens.
|
// Print a one-time welcome banner the first time the socket opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
if (!terminal || !workspaceId || status !== "open") return;
|
if (!terminal || !workspaceId || !shellId || status !== "open") return;
|
||||||
if (hasWelcomedRef.current) return;
|
if (hasWelcomedRef.current) return;
|
||||||
hasWelcomedRef.current = true;
|
hasWelcomedRef.current = true;
|
||||||
terminal.write(`\r\n[connected to ${workspaceId}]\r\n$ `);
|
terminal.write(`\r\n[connected to ${workspaceId}/${shellId.slice(0, 8)}]\r\n$ `);
|
||||||
}, [status, workspaceId]);
|
}, [status, workspaceId, shellId]);
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return (
|
return (
|
||||||
@@ -192,7 +193,7 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|||||||
|
|
||||||
const hint = shellRunning
|
const hint = shellRunning
|
||||||
? statusHint
|
? statusHint
|
||||||
: "shell not running - start it to use the terminal";
|
: "shell not running - restart 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]">
|
||||||
@@ -204,10 +205,10 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 px-1.5 text-[11px]"
|
className="h-5 px-1.5 text-[11px]"
|
||||||
onClick={() => workspaceId && startShell.mutate(workspaceId)}
|
onClick={() => workspaceId && restart.mutate({ shellId })}
|
||||||
disabled={!workspaceId || startShell.isPending}
|
disabled={!workspaceId || restart.isPending}
|
||||||
>
|
>
|
||||||
{startShell.isPending ? "starting…" : "Start shell"}
|
{restart.isPending ? "restarting…" : "Restart shell"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+207
-56
@@ -19,16 +19,42 @@ export interface ProcessStatus {
|
|||||||
|
|
||||||
export interface ShellStatus {
|
export interface ShellStatus {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
shellId: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
pid?: number;
|
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) {
|
function processStatusQueryKey(workspaceId: string) {
|
||||||
return ["workspaces", workspaceId, "process", "status"] as const;
|
return ["workspaces", workspaceId, "process", "status"] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shellStatusQueryKey(workspaceId: string) {
|
function shellStatusQueryKey(workspaceId: string, shellId: string) {
|
||||||
return ["workspaces", workspaceId, "shell", "status"] as const;
|
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> {
|
async function fetchProcessStatus(workspaceId: string): Promise<ProcessStatus> {
|
||||||
@@ -41,9 +67,12 @@ 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> {
|
async function fetchShellStatus(
|
||||||
|
workspaceId: string,
|
||||||
|
shellId: string,
|
||||||
|
): Promise<ShellStatus> {
|
||||||
const res = await apiClient(
|
const res = await apiClient(
|
||||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/status`,
|
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/status?shellId=${encodeURIComponent(shellId)}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch shell status: ${res.status}`);
|
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;
|
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(
|
export function useProcessStatus(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
): UseQueryResult<ProcessStatus, Error> {
|
): UseQueryResult<ProcessStatus, Error> {
|
||||||
@@ -64,15 +103,73 @@ export function useProcessStatus(
|
|||||||
|
|
||||||
export function useShellStatus(
|
export function useShellStatus(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
|
shellId: string | null,
|
||||||
): UseQueryResult<ShellStatus, Error> {
|
): UseQueryResult<ShellStatus, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: shellStatusQueryKey(workspaceId ?? ""),
|
queryKey: shellStatusQueryKey(workspaceId ?? "", shellId ?? ""),
|
||||||
queryFn: () => fetchShellStatus(workspaceId!),
|
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,
|
enabled: !!workspaceId,
|
||||||
refetchInterval: 5000,
|
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(
|
async function postProcessAction(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
action: "start" | "stop" | "restart",
|
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(
|
function useProcessActionMutation(
|
||||||
action: "start" | "stop" | "restart",
|
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> {
|
export function useProcessStart(): UseMutationResult<void, Error, string> {
|
||||||
return useProcessActionMutation("start");
|
return useProcessActionMutation("start");
|
||||||
@@ -139,38 +211,104 @@ export function useProcessRestart(): UseMutationResult<void, Error, string> {
|
|||||||
return useProcessActionMutation("restart");
|
return useProcessActionMutation("restart");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShellStart(): UseMutationResult<void, Error, string> {
|
export function useShellStart(
|
||||||
return useShellActionMutation("start");
|
workspaceId: string | null,
|
||||||
}
|
): UseMutationResult<ShellInfo, Error, void> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
export function useShellStop(): UseMutationResult<void, Error, string> {
|
|
||||||
return useShellActionMutation("stop");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShellRestart(): UseMutationResult<void, Error, string> {
|
|
||||||
return useShellActionMutation("restart");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellResizeVariables {
|
|
||||||
workspaceId: string;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShellResize(): UseMutationResult<void, Error, ShellResizeVariables> {
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ workspaceId, cols, rows }) => {
|
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(
|
||||||
|
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(
|
||||||
|
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"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postShellResize(
|
||||||
|
workspaceId: string,
|
||||||
|
shellId: string,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
): Promise<void> {
|
||||||
const res = await apiClient(
|
const res = await apiClient(
|
||||||
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/resize`,
|
`/api/workspaces/${encodeURIComponent(workspaceId)}/shell/resize`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ cols, rows }),
|
body: JSON.stringify({ shellId, cols, rows }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to resize shell: ${res.status}`);
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,6 +341,7 @@ export interface ShellWebSocket {
|
|||||||
onData: (cb: (data: string) => void) => () => 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,
|
||||||
@@ -365,10 +504,11 @@ export function useProcessWebSocket(
|
|||||||
|
|
||||||
export function useShellWebSocket(
|
export function useShellWebSocket(
|
||||||
workspaceId: string | null,
|
workspaceId: string | null,
|
||||||
|
shellId: string | null,
|
||||||
enabled: boolean = true,
|
enabled: boolean = true,
|
||||||
): ShellWebSocket {
|
): ShellWebSocket {
|
||||||
const [status, setStatus] = useState<ShellWebSocketStatus>(() =>
|
const [status, setStatus] = useState<ShellWebSocketStatus>(() =>
|
||||||
workspaceId && enabled ? "connecting" : "idle",
|
workspaceId && shellId && enabled ? "connecting" : "idle",
|
||||||
);
|
);
|
||||||
const statusRef = useRef(status);
|
const statusRef = useRef(status);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -402,7 +542,7 @@ export function useShellWebSocket(
|
|||||||
}, [clearReconnectTimeout]);
|
}, [clearReconnectTimeout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId || !enabled) {
|
if (!workspaceId || !shellId || !enabled) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
clearReconnectTimeout();
|
clearReconnectTimeout();
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
@@ -418,6 +558,16 @@ export function useShellWebSocket(
|
|||||||
return;
|
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;
|
reconnectAttemptsRef.current = 0;
|
||||||
lastFrameWasExitRef.current = false;
|
lastFrameWasExitRef.current = false;
|
||||||
intentionalCloseRef.current = false;
|
intentionalCloseRef.current = false;
|
||||||
@@ -430,7 +580,8 @@ export function useShellWebSocket(
|
|||||||
window.location.host +
|
window.location.host +
|
||||||
"/api/workspaces/" +
|
"/api/workspaces/" +
|
||||||
encodeURIComponent(workspaceId) +
|
encodeURIComponent(workspaceId) +
|
||||||
"/shell/ws";
|
"/shell/ws?shellId=" +
|
||||||
|
encodeURIComponent(shellId);
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
socketRef.current = ws;
|
socketRef.current = ws;
|
||||||
@@ -500,7 +651,7 @@ export function useShellWebSocket(
|
|||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [workspaceId, enabled, clearReconnectTimeout]);
|
}, [workspaceId, shellId, enabled, clearReconnectTimeout]);
|
||||||
|
|
||||||
const send = useCallback((data: string) => {
|
const send = useCallback((data: string) => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ interface WorkspaceUiState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
currentWorkspaceId: string | null;
|
currentWorkspaceId: string | null;
|
||||||
bottomTab: BottomTab;
|
bottomTab: BottomTab;
|
||||||
|
shellsByWorkspace: Record<string, string[]>;
|
||||||
|
activeShellIdByWorkspace: Record<string, string>;
|
||||||
setActiveFilePath: (path: string) => void;
|
setActiveFilePath: (path: string) => void;
|
||||||
toggleTerminal: () => void;
|
toggleTerminal: () => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setCurrentWorkspaceId: (id: string | null) => void;
|
setCurrentWorkspaceId: (id: string | null) => void;
|
||||||
setBottomTab: (tab: BottomTab) => 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) => ({
|
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
||||||
@@ -21,9 +27,46 @@ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
|
|||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
currentWorkspaceId: null,
|
currentWorkspaceId: null,
|
||||||
bottomTab: "terminal",
|
bottomTab: "terminal",
|
||||||
|
shellsByWorkspace: {},
|
||||||
|
activeShellIdByWorkspace: {},
|
||||||
setActiveFilePath: (path) => set({ activeFilePath: path }),
|
setActiveFilePath: (path) => set({ activeFilePath: path }),
|
||||||
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
|
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
|
||||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||||
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
|
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
|
||||||
setBottomTab: (tab) => set({ bottomTab: tab }),
|
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