diff --git a/web/src/components/layout/WorkspaceShell.tsx b/web/src/components/layout/WorkspaceShell.tsx
index bc7cbe7..32e140e 100644
--- a/web/src/components/layout/WorkspaceShell.tsx
+++ b/web/src/components/layout/WorkspaceShell.tsx
@@ -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 }) {
{bottomTab === "terminal" ? (
-
+
) : (
)}
@@ -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
>(new Set());
+ const autoStartedRef = useRef>({});
+ const lastIdsKeyRef = useRef("");
+
+ 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 (
+
+
+
+ {shells.map((shellId) => {
+ const isActive = shellId === activeShellId;
+ const isClosing = closingShellIds.has(shellId);
+ return (
+
+ );
+ })}
+
+
+
+
+ {activeShellId ? (
+
+ ) : (
+
+ {start.isPending ? "starting shell…" : "no active shell"}
+
+ )}
+
+
+ );
+}
+
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
diff --git a/web/src/components/terminal/TerminalPanel.tsx b/web/src/components/terminal/TerminalPanel.tsx
index 9e1fe97..1f413d0 100644
--- a/web/src/components/terminal/TerminalPanel.tsx
+++ b/web/src/components/terminal/TerminalPanel.tsx
@@ -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(null);
const terminalRef = useRef(null);
const fitAddonRef = useRef(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 (
@@ -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"}
)}
diff --git a/web/src/lib/api/process.ts b/web/src/lib/api/process.ts
index 3c00baf..21cd750 100644
--- a/web/src/lib/api/process.ts
+++ b/web/src/lib/api/process.ts
@@ -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 {
@@ -41,9 +67,12 @@ async function fetchProcessStatus(workspaceId: string): Promise {
return (await res.json()) as ProcessStatus;
}
-async function fetchShellStatus(workspaceId: string): Promise {
+async function fetchShellStatus(
+ workspaceId: string,
+ shellId: string,
+): Promise {
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 {
return (await res.json()) as ShellStatus;
}
+async function fetchShellList(workspaceId: string): Promise {
+ 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 {
@@ -64,15 +103,73 @@ export function useProcessStatus(
export function useShellStatus(
workspaceId: string | null,
+ shellId: string | null,
): UseQueryResult {
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 {
+ return useQuery({
+ queryKey: shellListQueryKey(workspaceId ?? ""),
+ queryFn: () => fetchShellList(workspaceId!),
enabled: !!workspaceId,
refetchInterval: 5000,
});
}
+async function postShellStart(workspaceId: string): Promise {
+ 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 {
+ 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 {
+ 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 {
- 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 {
- 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");
@@ -139,40 +211,106 @@ export function useProcessRestart(): UseMutationResult {
return useProcessActionMutation("restart");
}
-export function useShellStart(): UseMutationResult {
- return useShellActionMutation("start");
+export function useShellStart(
+ workspaceId: string | null,
+): UseMutationResult {
+ 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 {
- return useShellActionMutation("stop");
+export function useShellStop(
+ workspaceId: string | null,
+): UseMutationResult {
+ 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 {
- return useShellActionMutation("restart");
+export function useShellRestart(
+ workspaceId: string | null,
+): UseMutationResult {
+ 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 {
+ 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 {
- 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 {
+ 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(() =>
- 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;
diff --git a/web/src/lib/store/workspace-ui-store.ts b/web/src/lib/store/workspace-ui-store.ts
index 68bfa07..d386d57 100644
--- a/web/src/lib/store/workspace-ui-store.ts
+++ b/web/src/lib/store/workspace-ui-store.ts
@@ -8,11 +8,17 @@ interface WorkspaceUiState {
sidebarCollapsed: boolean;
currentWorkspaceId: string | null;
bottomTab: BottomTab;
+ shellsByWorkspace: Record;
+ activeShellIdByWorkspace: Record;
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((set) => ({
@@ -21,9 +27,46 @@ export const useWorkspaceUiStore = create((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,
+ },
+ })),
}));