From 7b40b23d33478aa4b82cfc5877b1e2b1d59754da Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:48:12 +0800 Subject: [PATCH] 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 /]' - 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 Diff: 4 files. pnpm lint + pnpm build clean. Conversation: B975103F-4528-493E-8F8A-91D20506631D --- web/src/components/layout/WorkspaceShell.tsx | 151 +++++++++- web/src/components/terminal/TerminalPanel.tsx | 71 ++--- web/src/lib/api/process.ts | 273 ++++++++++++++---- web/src/lib/store/workspace-ui-store.ts | 43 +++ 4 files changed, 439 insertions(+), 99 deletions(-) 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, + }, + })), }));