From 6208bb8a096ae1989d6e2b9b83ae89f4688250b9 Mon Sep 17 00:00:00 2001 From: Chase McDougall Date: Tue, 20 Jan 2026 12:25:37 -0500 Subject: [PATCH 1/7] feat(desktop): add parallel execution mode for terminal presets Add an executionMode field to terminal presets allowing commands to run in separate split panes (parallel) instead of sequentially in one terminal. - Add EXECUTION_MODES constant and executionMode field to preset schema - Add buildMultiPaneLayout utility for balanced multi-pane layouts - Add addTabWithMultiplePanes store action - Update GroupStrip to handle parallel preset selection - Add execution mode dropdown to preset settings UI - Update tRPC mutations to support executionMode --- .../src/lib/trpc/routers/settings/index.ts | 5 ++ .../TerminalSettings/TerminalSettings.tsx | 26 +++++- .../components/PresetRow/PresetRow.tsx | 26 ++++++ .../TabsContent/GroupStrip/GroupStrip.tsx | 29 +++++-- .../desktop/src/renderer/stores/tabs/store.ts | 79 ++++++++++++++++++- .../desktop/src/renderer/stores/tabs/types.ts | 14 ++++ .../desktop/src/renderer/stores/tabs/utils.ts | 57 +++++++++++++ packages/local-db/src/schema/zod.ts | 8 ++ 8 files changed, 236 insertions(+), 8 deletions(-) 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..d5248295340 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, @@ -219,6 +223,22 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { }); }; + const handleExecutionModeChange = (rowIndex: number, mode: ExecutionMode) => { + const preset = localPresets[rowIndex]; + if (!preset) return; + + // Update local state + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, executionMode: mode } : p)), + ); + + // Persist to server + updatePreset.mutate({ + id: preset.id, + patch: { executionMode: mode }, + }); + }; + const handleAddRow = () => { createPreset.mutate({ name: "", @@ -508,6 +528,9 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { {column.label} ))} +
+ Mode +
Actions
@@ -532,6 +555,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..ead34753f47 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 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,22 @@ 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..7740caf01e6 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,16 +88,33 @@ 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; - if (preset.name) { - renameTab(tabId, preset.name); + if (isParallel) { + const { tabId } = addTabWithMultiplePanes(activeWorkspaceId, { + commands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + if (preset.name) { + renameTab(tabId, preset.name); + } + } else { + // Existing sequential behavior + const { tabId } = addTab(activeWorkspaceId, { + initialCommands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + if (preset.name) { + renameTab(tabId, preset.name); + } } setDropdownOpen(false); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index bdb8ca25e62..f120b830d1d 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,75 @@ export const useTabsStore = create()( return { tabId: tab.id, paneId: pane.id }; }, + addTabWithMultiplePanes: ( + workspaceId: string, + options: AddTabWithMultiplePanesOptions, + ) => { + const state = get(); + + // Create one pane per command + 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); + + // Build balanced multi-pane layout + const layout = buildMultiPaneLayout(paneIds); + + // Filter to same workspace for tab naming + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + + const tab = { + id: tabId, + name: generateTabName(workspaceTabs), + workspaceId, + layout, + createdAt: Date.now(), + }; + + // Build panes record + 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], // Focus first pane + }, + 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..53a0e32cd1f 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -35,6 +35,16 @@ export interface AddTabOptions { initialCwd?: string; } +/** + * Options for creating a tab with multiple panes (parallel execution mode) + */ +export interface AddTabWithMultiplePanesOptions { + /** Array of commands, one per pane */ + commands: string[]; + /** Optional working directory for all panes */ + initialCwd?: string; +} + /** * Options for opening a file in a file-viewer pane */ @@ -60,6 +70,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/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 4706642f0aa..bd159a3fdc2 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -439,6 +439,63 @@ export const addPaneToLayout = ( splitPercentage: 50, }); +/** + * Builds a balanced multi-pane Mosaic layout from an array of pane IDs. + * + * Layout strategy: + * - 1 pane: Returns the single pane ID (leaf node) + * - 2 panes: Horizontal split (side-by-side) + * - 3+ panes: Recursive binary splits creating a grid layout + * - Alternates between column (vertical) and row (horizontal) splits + * - Splits the array roughly in half at each level + * + * Example for 4 panes: + * ``` + * ┌───────┬───────┐ + * │ A │ B │ (row split) + * ├───────┼───────┤ + * │ C │ D │ (row split) + * └───────┴───────┘ + * (column split between top and bottom rows) + * ``` + * + * @param paneIds - Array of pane IDs to arrange in the layout + * @param direction - The split direction for this level ("row" = horizontal, "column" = vertical) + * @returns A MosaicNode tree representing the balanced layout + */ +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, + }; + } + + // Split array in half and recurse, alternating direction + 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..10f5cffb2cf 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -47,6 +47,13 @@ export const gitHubStatusSchema = z.object({ export type GitHubStatus = z.infer; +/** + * Execution mode for terminal presets + */ +export const EXECUTION_MODES = ["sequential", "parallel"] as const; + +export type ExecutionMode = (typeof EXECUTION_MODES)[number]; + /** * Terminal preset */ @@ -57,6 +64,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; From fa89b3e1a23dcf945f0bf84ac1792e834130e2d3 Mon Sep 17 00:00:00 2001 From: Chase McDougall Date: Tue, 20 Jan 2026 12:39:40 -0500 Subject: [PATCH 2/7] fix(desktop): make execution mode dropdown consistent width Add w-full to SelectTrigger so the mode dropdown fills its container instead of auto-sizing to content, ensuring Sequential and Parallel have the same width. --- .../TerminalSettings/components/PresetRow/PresetRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ead34753f47..13a67c21be9 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 @@ -119,7 +119,7 @@ export function PresetRow({ onExecutionModeChange(rowIndex, value as ExecutionMode) } > - + From ca9af8db0344b3ea6f7c3d86a2cf3f596b9cd6ec Mon Sep 17 00:00:00 2001 From: Chase McDougall Date: Tue, 20 Jan 2026 12:53:41 -0500 Subject: [PATCH 3/7] fix(desktop): address code review feedback for parallel execution mode - Add EXECUTION_MODES validation in PresetRow to guard type assertion - Refactor duplicated tab renaming logic in GroupStrip - Add unit tests for buildMultiPaneLayout utility - Add tooltip to Mode column header explaining execution modes --- .../TerminalSettings/TerminalSettings.tsx | 21 +++- .../components/PresetRow/PresetRow.tsx | 10 +- .../TabsContent/GroupStrip/GroupStrip.tsx | 29 ++--- .../src/renderer/stores/tabs/utils.test.ts | 113 ++++++++++++++++++ 4 files changed, 149 insertions(+), 24 deletions(-) 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 d5248295340..2dddeb2238d 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 @@ -528,9 +528,24 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { {column.label}
))} -
- Mode -
+ + +
+ 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
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 13a67c21be9..cde71cb0c5a 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,4 +1,4 @@ -import type { ExecutionMode } from "@superset/local-db"; +import { EXECUTION_MODES, type ExecutionMode } from "@superset/local-db"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { @@ -115,9 +115,11 @@ export function PresetRow({
{