diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts
index cd73a7b3bd9..cc4d47661bd 100644
--- a/apps/desktop/src/lib/trpc/routers/settings/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts
@@ -1,4 +1,5 @@
import {
+ EXECUTION_MODES,
settings,
TERMINAL_LINK_BEHAVIORS,
type TerminalPreset,
@@ -43,6 +44,7 @@ export const createSettingsRouter = () => {
description: z.string().optional(),
cwd: z.string(),
commands: z.array(z.string()),
+ executionMode: z.enum(EXECUTION_MODES).optional(),
}),
)
.mutation(({ input }) => {
@@ -76,6 +78,7 @@ export const createSettingsRouter = () => {
description: z.string().optional(),
cwd: z.string().optional(),
commands: z.array(z.string()).optional(),
+ executionMode: z.enum(EXECUTION_MODES).optional(),
}),
}),
)
@@ -97,6 +100,8 @@ export const createSettingsRouter = () => {
if (input.patch.cwd !== undefined) preset.cwd = input.patch.cwd;
if (input.patch.commands !== undefined)
preset.commands = input.patch.commands;
+ if (input.patch.executionMode !== undefined)
+ preset.executionMode = input.patch.executionMode;
localDb
.insert(settings)
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx
index 0413a486cc8..2279cdb9478 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx
@@ -1,4 +1,8 @@
-import type { TerminalLinkBehavior, TerminalPreset } from "@superset/local-db";
+import type {
+ ExecutionMode,
+ TerminalLinkBehavior,
+ TerminalPreset,
+} from "@superset/local-db";
import {
AlertDialog,
AlertDialogContent,
@@ -20,7 +24,11 @@ import { toast } from "@superset/ui/sonner";
import { Switch } from "@superset/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { useEffect, useMemo, useRef, useState } from "react";
-import { HiOutlineCheck, HiOutlinePlus } from "react-icons/hi2";
+import {
+ HiOutlineCheck,
+ HiOutlinePlus,
+ HiOutlineQuestionMarkCircle,
+} from "react-icons/hi2";
import {
getPresetIcon,
useIsDarkTheme,
@@ -219,6 +227,20 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {
});
};
+ const handleExecutionModeChange = (rowIndex: number, mode: ExecutionMode) => {
+ const preset = localPresets[rowIndex];
+ if (!preset) return;
+
+ setLocalPresets((prev) =>
+ prev.map((p, i) => (i === rowIndex ? { ...p, executionMode: mode } : p)),
+ );
+
+ updatePreset.mutate({
+ id: preset.id,
+ patch: { executionMode: mode },
+ });
+ };
+
const handleAddRow = () => {
createPreset.mutate({
name: "",
@@ -423,7 +445,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {
};
return (
-
+
Terminal
@@ -508,6 +530,25 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {
{column.label}
))}
+
+
+
+ Mode
+
+
+
+
+ Execution Mode
+
+ Sequential: Commands run one after
+ another in a single terminal (joined with &&)
+
+
+ Parallel: Each command runs in its own
+ split pane within a single tab
+
+
+
Actions
@@ -532,6 +573,7 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {
onBlur={handleCellBlur}
onCommandsChange={handleCommandsChange}
onCommandsBlur={handleCommandsBlur}
+ onExecutionModeChange={handleExecutionModeChange}
onDelete={handleDeleteRow}
onSetDefault={handleSetDefault}
/>
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx
index 5ce8e1fd86a..d2a3b20c923 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx
@@ -1,5 +1,13 @@
+import { EXECUTION_MODES, type ExecutionMode } from "@superset/local-db";
import { Button } from "@superset/ui/button";
import { Input } from "@superset/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@superset/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { HiOutlineStar, HiStar } from "react-icons/hi2";
import { LuTrash } from "react-icons/lu";
@@ -63,6 +71,7 @@ interface PresetRowProps {
onBlur: (rowIndex: number, column: PresetColumnKey) => void;
onCommandsChange: (rowIndex: number, commands: string[]) => void;
onCommandsBlur: (rowIndex: number) => void;
+ onExecutionModeChange: (rowIndex: number, mode: ExecutionMode) => void;
onDelete: (rowIndex: number) => void;
onSetDefault: (presetId: string | null) => void;
}
@@ -75,6 +84,7 @@ export function PresetRow({
onBlur,
onCommandsChange,
onCommandsBlur,
+ onExecutionModeChange,
onDelete,
onSetDefault,
}: PresetRowProps) {
@@ -102,6 +112,24 @@ export function PresetRow({
/>
))}
+
+
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx
index d750c7fa23f..83a48876cff 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx
@@ -88,13 +88,25 @@ export function GroupStrip() {
addTab(activeWorkspaceId);
};
+ const addTabWithMultiplePanes = useTabsStore(
+ (s) => s.addTabWithMultiplePanes,
+ );
+
const handleSelectPreset = (preset: TerminalPreset) => {
if (!activeWorkspaceId) return;
- const { tabId } = addTab(activeWorkspaceId, {
- initialCommands: preset.commands,
- initialCwd: preset.cwd || undefined,
- });
+ const isParallel =
+ preset.executionMode === "parallel" && preset.commands.length > 1;
+
+ const { tabId } = isParallel
+ ? addTabWithMultiplePanes(activeWorkspaceId, {
+ commands: preset.commands,
+ initialCwd: preset.cwd || undefined,
+ })
+ : addTab(activeWorkspaceId, {
+ initialCommands: preset.commands,
+ initialCwd: preset.cwd || undefined,
+ });
if (preset.name) {
renameTab(tabId, preset.name);
diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts
index bdb8ca25e62..997c73179e6 100644
--- a/apps/desktop/src/renderer/stores/tabs/store.ts
+++ b/apps/desktop/src/renderer/stores/tabs/store.ts
@@ -4,13 +4,21 @@ import { trpcTabsStorage } from "renderer/lib/trpc-storage";
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane";
-import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types";
+import type {
+ AddFileViewerPaneOptions,
+ AddTabWithMultiplePanesOptions,
+ TabsState,
+ TabsStore,
+} from "./types";
import {
+ buildMultiPaneLayout,
type CreatePaneOptions,
createFileViewerPane,
createPane,
createTabWithPane,
extractPaneIdsFromLayout,
+ generateId,
+ generateTabName,
getAdjacentPaneId,
getFirstPaneId,
getPaneIdsForTab,
@@ -121,6 +129,68 @@ export const useTabsStore = create()(
return { tabId: tab.id, paneId: pane.id };
},
+ addTabWithMultiplePanes: (
+ workspaceId: string,
+ options: AddTabWithMultiplePanesOptions,
+ ) => {
+ const state = get();
+ const tabId = generateId("tab");
+ const panes: ReturnType[] = options.commands.map(
+ (command) =>
+ createPane(tabId, "terminal", {
+ initialCommands: [command],
+ initialCwd: options.initialCwd,
+ }),
+ );
+
+ const paneIds = panes.map((p) => p.id);
+ const layout = buildMultiPaneLayout(paneIds);
+ const workspaceTabs = state.tabs.filter(
+ (t) => t.workspaceId === workspaceId,
+ );
+
+ const tab = {
+ id: tabId,
+ name: generateTabName(workspaceTabs),
+ workspaceId,
+ layout,
+ createdAt: Date.now(),
+ };
+
+ const panesRecord: Record = {};
+ for (const pane of panes) {
+ panesRecord[pane.id] = pane;
+ }
+
+ const currentActiveId = state.activeTabIds[workspaceId];
+ const historyStack = state.tabHistoryStacks[workspaceId] || [];
+ const newHistoryStack = currentActiveId
+ ? [
+ currentActiveId,
+ ...historyStack.filter((id) => id !== currentActiveId),
+ ]
+ : historyStack;
+
+ set({
+ tabs: [...state.tabs, tab],
+ panes: { ...state.panes, ...panesRecord },
+ activeTabIds: {
+ ...state.activeTabIds,
+ [workspaceId]: tab.id,
+ },
+ focusedPaneIds: {
+ ...state.focusedPaneIds,
+ [tab.id]: paneIds[0],
+ },
+ tabHistoryStacks: {
+ ...state.tabHistoryStacks,
+ [workspaceId]: newHistoryStack,
+ },
+ });
+
+ return { tabId: tab.id, paneIds };
+ },
+
removeTab: (tabId) => {
const state = get();
const tabToRemove = state.tabs.find((t) => t.id === tabId);
diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts
index 628f2a4bdfb..1b7d332e3a6 100644
--- a/apps/desktop/src/renderer/stores/tabs/types.ts
+++ b/apps/desktop/src/renderer/stores/tabs/types.ts
@@ -35,6 +35,11 @@ export interface AddTabOptions {
initialCwd?: string;
}
+export interface AddTabWithMultiplePanesOptions {
+ commands: string[];
+ initialCwd?: string;
+}
+
/**
* Options for opening a file in a file-viewer pane
*/
@@ -60,6 +65,10 @@ export interface TabsStore extends TabsState {
workspaceId: string,
options?: AddTabOptions,
) => { tabId: string; paneId: string };
+ addTabWithMultiplePanes: (
+ workspaceId: string,
+ options: AddTabWithMultiplePanesOptions,
+ ) => { tabId: string; paneIds: string[] };
removeTab: (tabId: string) => void;
renameTab: (tabId: string, newName: string) => void;
setTabAutoTitle: (tabId: string, title: string) => void;
diff --git a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts
index 4d102105fa4..d27acf01b63 100644
--- a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts
+++ b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts
@@ -13,13 +13,15 @@ export function useTabsWithPresets() {
const { defaultPreset } = usePresets();
const storeAddTab = useTabsStore((s) => s.addTab);
+ const storeAddTabWithMultiplePanes = useTabsStore(
+ (s) => s.addTabWithMultiplePanes,
+ );
const storeAddPane = useTabsStore((s) => s.addPane);
const storeSplitPaneVertical = useTabsStore((s) => s.splitPaneVertical);
const storeSplitPaneHorizontal = useTabsStore((s) => s.splitPaneHorizontal);
const storeSplitPaneAuto = useTabsStore((s) => s.splitPaneAuto);
const renameTab = useTabsStore((s) => s.renameTab);
- // Get preset options if a default preset is set
const defaultPresetOptions: AddTabOptions | undefined = useMemo(() => {
if (!defaultPreset) return undefined;
return {
@@ -28,24 +30,50 @@ export function useTabsWithPresets() {
};
}, [defaultPreset]);
- // Wrapped addTab that applies default preset
+ const shouldUseParallelMode = useMemo(() => {
+ return (
+ defaultPreset?.executionMode === "parallel" &&
+ defaultPreset.commands.length > 1
+ );
+ }, [defaultPreset]);
+
const addTab = useCallback(
(workspaceId: string, options?: AddTabOptions) => {
- // If explicit options are provided, use them; otherwise use default preset
- const effectiveOptions = options ?? defaultPresetOptions;
- const result = storeAddTab(workspaceId, effectiveOptions);
+ if (options) {
+ return storeAddTab(workspaceId, options);
+ }
+
+ if (shouldUseParallelMode && defaultPreset) {
+ const { tabId, paneIds } = storeAddTabWithMultiplePanes(workspaceId, {
+ commands: defaultPreset.commands,
+ initialCwd: defaultPreset.cwd || undefined,
+ });
+
+ if (defaultPreset.name) {
+ renameTab(tabId, defaultPreset.name);
+ }
+
+ return { tabId, paneId: paneIds[0] };
+ }
+
+ const result = storeAddTab(workspaceId, defaultPresetOptions);
- // If using default preset and it has a name, rename the tab
- if (!options && defaultPreset?.name) {
+ if (defaultPreset?.name) {
renameTab(result.tabId, defaultPreset.name);
}
return result;
},
- [storeAddTab, defaultPresetOptions, defaultPreset, renameTab],
+ [
+ storeAddTab,
+ storeAddTabWithMultiplePanes,
+ defaultPresetOptions,
+ defaultPreset,
+ shouldUseParallelMode,
+ renameTab,
+ ],
);
- // Wrapped addPane that applies default preset
const addPane = useCallback(
(tabId: string, options?: AddTabOptions) => {
const effectiveOptions = options ?? defaultPresetOptions;
@@ -54,7 +82,6 @@ export function useTabsWithPresets() {
[storeAddPane, defaultPresetOptions],
);
- // Wrapped splitPaneVertical that applies default preset
const splitPaneVertical = useCallback(
(
tabId: string,
@@ -73,7 +100,6 @@ export function useTabsWithPresets() {
[storeSplitPaneVertical, defaultPresetOptions],
);
- // Wrapped splitPaneHorizontal that applies default preset
const splitPaneHorizontal = useCallback(
(
tabId: string,
@@ -92,7 +118,6 @@ export function useTabsWithPresets() {
[storeSplitPaneHorizontal, defaultPresetOptions],
);
- // Wrapped splitPaneAuto that applies default preset
const splitPaneAuto = useCallback(
(
tabId: string,
diff --git a/apps/desktop/src/renderer/stores/tabs/utils.test.ts b/apps/desktop/src/renderer/stores/tabs/utils.test.ts
index 29b308d5010..240e150b081 100644
--- a/apps/desktop/src/renderer/stores/tabs/utils.test.ts
+++ b/apps/desktop/src/renderer/stores/tabs/utils.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import type { MosaicNode } from "react-mosaic-component";
import type { Tab } from "./types";
import {
+ buildMultiPaneLayout,
findPanePath,
getAdjacentPaneId,
resolveActiveTabIdForWorkspace,
@@ -290,3 +291,112 @@ describe("resolveActiveTabIdForWorkspace", () => {
).toBeNull();
});
});
+
+describe("buildMultiPaneLayout", () => {
+ it("throws error for empty pane array", () => {
+ expect(() => buildMultiPaneLayout([])).toThrow(
+ "Cannot build layout with zero panes",
+ );
+ });
+
+ it("returns leaf node for single pane", () => {
+ const result = buildMultiPaneLayout(["pane-1"]);
+ expect(result).toBe("pane-1");
+ });
+
+ it("returns horizontal split for two panes", () => {
+ const result = buildMultiPaneLayout(["pane-1", "pane-2"]);
+ expect(result).toEqual({
+ direction: "row",
+ first: "pane-1",
+ second: "pane-2",
+ splitPercentage: 50,
+ });
+ });
+
+ it("returns balanced grid for three panes", () => {
+ const result = buildMultiPaneLayout(["pane-1", "pane-2", "pane-3"]);
+ expect(result).toEqual({
+ direction: "column",
+ first: {
+ direction: "row",
+ first: "pane-1",
+ second: "pane-2",
+ splitPercentage: 50,
+ },
+ second: "pane-3",
+ splitPercentage: 50,
+ });
+ });
+
+ it("returns 2x2 grid for four panes", () => {
+ const result = buildMultiPaneLayout([
+ "pane-1",
+ "pane-2",
+ "pane-3",
+ "pane-4",
+ ]);
+ expect(result).toEqual({
+ direction: "column",
+ first: {
+ direction: "row",
+ first: "pane-1",
+ second: "pane-2",
+ splitPercentage: 50,
+ },
+ second: {
+ direction: "row",
+ first: "pane-3",
+ second: "pane-4",
+ splitPercentage: 50,
+ },
+ splitPercentage: 50,
+ });
+ });
+
+ it("returns balanced nested layout for five panes", () => {
+ const result = buildMultiPaneLayout([
+ "pane-1",
+ "pane-2",
+ "pane-3",
+ "pane-4",
+ "pane-5",
+ ]);
+ expect(result).toEqual({
+ direction: "column",
+ first: {
+ direction: "row",
+ first: {
+ direction: "row",
+ first: "pane-1",
+ second: "pane-2",
+ splitPercentage: 50,
+ },
+ second: "pane-3",
+ splitPercentage: 50,
+ },
+ second: {
+ direction: "row",
+ first: "pane-4",
+ second: "pane-5",
+ splitPercentage: 50,
+ },
+ splitPercentage: 50,
+ });
+ });
+
+ it("returns row-first layout when direction is row", () => {
+ const result = buildMultiPaneLayout(["pane-1", "pane-2", "pane-3"], "row");
+ expect(result).toEqual({
+ direction: "row",
+ first: {
+ direction: "row",
+ first: "pane-1",
+ second: "pane-2",
+ splitPercentage: 50,
+ },
+ second: "pane-3",
+ splitPercentage: 50,
+ });
+ });
+});
diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts
index 4706642f0aa..a728e1f4c55 100644
--- a/apps/desktop/src/renderer/stores/tabs/utils.ts
+++ b/apps/desktop/src/renderer/stores/tabs/utils.ts
@@ -439,6 +439,42 @@ export const addPaneToLayout = (
splitPercentage: 50,
});
+/**
+ * Builds a balanced multi-pane Mosaic layout using recursive binary splits.
+ * For 3+ panes, alternates between column and row splits to create a grid.
+ */
+export const buildMultiPaneLayout = (
+ paneIds: string[],
+ direction: "row" | "column" = "column",
+): MosaicNode => {
+ if (paneIds.length === 0) {
+ throw new Error("Cannot build layout with zero panes");
+ }
+
+ if (paneIds.length === 1) {
+ return paneIds[0];
+ }
+
+ if (paneIds.length === 2) {
+ return {
+ direction: "row",
+ first: paneIds[0],
+ second: paneIds[1],
+ splitPercentage: 50,
+ };
+ }
+
+ const mid = Math.ceil(paneIds.length / 2);
+ const nextDirection = direction === "column" ? "row" : "column";
+
+ return {
+ direction,
+ first: buildMultiPaneLayout(paneIds.slice(0, mid), nextDirection),
+ second: buildMultiPaneLayout(paneIds.slice(mid), nextDirection),
+ splitPercentage: 50,
+ };
+};
+
/**
* Updates the history stack when switching to a new active tab
* Adds the current active to history and removes the new active from history
diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts
index 1aa69d75af2..6c0374c4b29 100644
--- a/packages/local-db/src/schema/zod.ts
+++ b/packages/local-db/src/schema/zod.ts
@@ -47,6 +47,10 @@ export const gitHubStatusSchema = z.object({
export type GitHubStatus = z.infer;
+export const EXECUTION_MODES = ["sequential", "parallel"] as const;
+
+export type ExecutionMode = (typeof EXECUTION_MODES)[number];
+
/**
* Terminal preset
*/
@@ -57,6 +61,7 @@ export const terminalPresetSchema = z.object({
cwd: z.string(),
commands: z.array(z.string()),
isDefault: z.boolean().optional(),
+ executionMode: z.enum(EXECUTION_MODES).optional(),
});
export type TerminalPreset = z.infer;