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:
tao.chen
2026-07-06 15:48:12 +08:00
parent 13b0c4e53f
commit 7b40b23d33
4 changed files with 439 additions and 99 deletions
+148 -3
View File
@@ -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);
+36 -35
View File
@@ -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
View File
@@ -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;
+43
View File
@@ -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,
},
})),
}));