diff --git a/apps/desktop/plans/20260411-v2-preset-parity.md b/apps/desktop/plans/20260411-v2-preset-parity.md new file mode 100644 index 00000000000..e367c9331f1 --- /dev/null +++ b/apps/desktop/plans/20260411-v2-preset-parity.md @@ -0,0 +1,103 @@ +# V2 Preset Execution Mode Parity + +## Problem + +`V2PresetsBar.openPresetInNewTab()` ignores `executionMode` -- always creates 1 tab with 1 pane and joins all commands with `" && "`. V1 supports three execution modes that produce different layouts: + +- **`split-pane`**: N commands -> N panes in the active tab +- **`new-tab`**: N commands -> N separate tabs +- **`new-tab-split-pane`**: N commands -> 1 new tab with N split panes + +V2's pane store already has multi-pane `addTab()` (with balanced tree) and `addPane()`. The infrastructure exists; it's just not wired up in the preset path. + +Preset hotkeys (OPEN_PRESET_1-9) also show labels in the V2 UI but have no handlers. + +## Design Decisions + +### 1. Extract execution logic into a `useV2PresetExecution` hook + +Both bar clicks and hotkeys need the same execution function. Currently the logic is inline in `V2PresetsBar` as a `useCallback`. Extracting it: + +- Lets `useWorkspaceHotkeys` call the same function without duplicating the query or logic +- Keeps `V2PresetsBar` focused on rendering +- Makes the execution logic testable + +### 2. Reuse V1's `getPresetLaunchPlan()` + +37 lines of pure logic with no V1 store dependency. Takes `{ mode, target, commandCount, hasActiveTab }` and returns one of 5 launch plans. No reason to duplicate it. + +### 3. Both clicks and hotkeys follow the preset's `executionMode` + +No separate `target` override. The preset's `executionMode` determines the behavior: + +- `split-pane` -> adds panes to the active tab (falls back to new tab with splits if no active tab) +- `new-tab` -> creates separate tabs, one per command +- `new-tab-split-pane` -> creates one new tab with N split panes + +This means we always pass `target: "active-tab"` to `getPresetLaunchPlan` for `split-pane` mode presets, and the function handles the fallback internally via `hasActiveTab`. + +### 4. Rename `onOpenInNewTab` -> `onExecutePreset` + +Descriptive: the callback executes the preset according to its configured mode. + +## Implementation + +### New: `useV2PresetExecution` hook + +`v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts` + +- Accepts `store`, `workspaceId`, `projectId` +- Queries presets via `useLiveQuery` + `filterMatchingPresetsForProject` +- Exposes `executePreset(preset)` and `matchedPresets` +- Derives the `target` from the preset's `executionMode`: + - `split-pane` -> `target: "active-tab"` + - `new-tab` / `new-tab-split-pane` -> `target: "new-tab"` +- Maps `getPresetLaunchPlan()` result to V2 store calls: + +| Plan | Store Call | +|---|---| +| `new-tab-single` | `addTab({ panes: [1 pane] })` | +| `new-tab-multi-pane` | `addTab({ panes: [N panes] })` (auto balanced tree) | +| `new-tab-per-command` | `addTab()` x N, 1 pane each | +| `active-tab-single` | `addPane({ tabId, pane })`, fallback to new-tab | +| `active-tab-multi-pane` | `addPane()` x N, fallback to new-tab | + +Each command gets its own pane with `initialCommand` (no `&&` joining). + +### Modify: `V2PresetsBar.tsx` + +- Remove inline `openPresetInNewTab`, preset querying, `matchedPresets` derivation +- Receive `executePreset` and `matchedPresets` via props from `WorkspaceContent` (which calls `useV2PresetExecution`) +- Pass `executePreset` to `V2PresetBarItem` as `onExecutePreset` + +### Modify: `V2PresetBarItem.tsx` + +- Rename `onOpenInNewTab` prop to `onExecutePreset` +- Context menu label: "Open in new tab" -> "Run preset" + +### Modify: `useWorkspaceHotkeys.ts` + +- Accept `matchedPresets` and `executePreset` as params +- Add `useHotkey("OPEN_PRESET_N", () => executePreset(matchedPresets[N-1]))` x 9 + +### Modify: `page.tsx` + +- Call `useV2PresetExecution({ store, workspaceId, projectId })` in `WorkspaceContent` +- Pass results to `V2PresetsBar` and `useWorkspaceHotkeys` + +## File Summary + +| File | Action | +|---|---| +| `.../hooks/useV2PresetExecution/useV2PresetExecution.ts` | Create | +| `.../hooks/useV2PresetExecution/index.ts` | Create | +| `.../V2PresetsBar/V2PresetsBar.tsx` | Simplify, use hook | +| `.../V2PresetBarItem/V2PresetBarItem.tsx` | Rename prop + label | +| `.../useWorkspaceHotkeys/useWorkspaceHotkeys.ts` | Add preset hotkeys | +| `.../v2-workspace/$workspaceId/page.tsx` | Wire hook | +| `renderer/stores/tabs/preset-launch.ts` | Reuse as-is | + +## Verification + +1. `bun run typecheck && bun run lint:fix` +2. Manual: multi-command presets with each mode, hotkeys Ctrl+1-9, single/empty edge cases diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx index d2433f499b0..3f676523390 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx @@ -1,4 +1,3 @@ -import type { WorkspaceStore } from "@superset/panes"; import { AGENT_PRESET_COMMANDS, AGENT_PRESET_DESCRIPTIONS, @@ -13,7 +12,6 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniCog6Tooth, HiMiniCommandLine } from "react-icons/hi2"; @@ -27,15 +25,11 @@ import type { HotkeyId } from "renderer/hotkeys"; import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; -import { filterMatchingPresetsForProject } from "shared/preset-project-targeting"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneViewerData, TerminalPaneData } from "../../types"; import { V2PresetBarItem } from "./components/V2PresetBarItem"; interface V2PresetsBarProps { - workspaceId: string; - projectId: string; - store: StoreApi>; + matchedPresets: V2TerminalPresetRow[]; + executePreset: (preset: V2TerminalPresetRow) => void; } // Co-located to keep v2 self-contained. Mirrors the v1 array in @@ -88,28 +82,14 @@ function getPinnedPresetOrder( } export function V2PresetsBar({ - workspaceId, - projectId, - store, + matchedPresets, + executePreset, }: V2PresetsBarProps) { const navigate = useNavigate(); const isDark = useIsDarkTheme(); const collections = useCollections(); useMigrateV1PresetsToV2(); - const { data: allPresets = [] } = useLiveQuery( - (query) => - query - .from({ v2TerminalPresets: collections.v2TerminalPresets }) - .orderBy(({ v2TerminalPresets }) => v2TerminalPresets.tabOrder), - [collections], - ); - - const matchedPresets = useMemo( - () => filterMatchingPresetsForProject(allPresets, projectId), - [allPresets, projectId], - ); - const [localPinnedPresetIds, setLocalPinnedPresetIds] = useState( () => getPinnedPresetOrder(matchedPresets), ); @@ -201,27 +181,6 @@ export function V2PresetsBar({ return [...fromTemplates, ...customExisting]; }, [matchedPresets, presetsByName]); - const openPresetInNewTab = useCallback( - (preset: V2TerminalPresetRow) => { - if (!workspaceId) return; - const initialCommand = - preset.commands.length > 0 ? preset.commands.join(" && ") : undefined; - store.getState().addTab({ - titleOverride: preset.name || "Terminal", - panes: [ - { - kind: "terminal", - data: { - terminalId: crypto.randomUUID(), - initialCommand, - } as TerminalPaneData, - }, - ], - }); - }, - [workspaceId, store], - ); - const handleEditPreset = useCallback( (presetId: string) => { navigate({ @@ -395,7 +354,7 @@ export function V2PresetsBar({ pinnedIndex={pinnedIndex} hotkeyId={hotkeyId} isDark={isDark} - onOpenInNewTab={openPresetInNewTab} + onExecutePreset={executePreset} onEdit={(presetToEdit) => handleEditPreset(presetToEdit.id)} onLocalReorder={handleLocalPinnedReorder} onPersistReorder={handlePersistPinnedReorder} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx index ea9920f4d8c..231c37a49a9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx @@ -22,7 +22,7 @@ interface V2PresetBarItemProps { pinnedIndex: number; hotkeyId?: HotkeyId; isDark: boolean; - onOpenInNewTab: (preset: V2TerminalPresetRow) => void; + onExecutePreset: (preset: V2TerminalPresetRow) => void; onEdit: (preset: V2TerminalPresetRow) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; onPersistReorder: (presetId: string, targetPinnedIndex: number) => void; @@ -33,7 +33,7 @@ export function V2PresetBarItem({ pinnedIndex, hotkeyId, isDark, - onOpenInNewTab, + onExecutePreset, onEdit, onLocalReorder, onPersistReorder, @@ -90,7 +90,7 @@ export function V2PresetBarItem({ variant="ghost" size="sm" className="h-6 px-2 gap-1.5 text-xs shrink-0" - onClick={() => onOpenInNewTab(preset)} + onClick={() => onExecutePreset(preset)} > {icon ? ( @@ -109,8 +109,8 @@ export function V2PresetBarItem({ - onOpenInNewTab(preset)}> - Open in new tab + onExecutePreset(preset)}> + Run preset onEdit(preset)}> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts new file mode 100644 index 00000000000..1d931de3147 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts @@ -0,0 +1 @@ +export { useV2PresetExecution } from "./useV2PresetExecution"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts new file mode 100644 index 00000000000..df2c8cf220b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -0,0 +1,140 @@ +import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { getPresetLaunchPlan } from "renderer/stores/tabs/preset-launch"; +import { filterMatchingPresetsForProject } from "shared/preset-project-targeting"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; + +function makeTerminalPane(command?: string): CreatePaneInput { + return { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + initialCommand: command, + } as TerminalPaneData, + }; +} + +function resolveTarget(executionMode: V2TerminalPresetRow["executionMode"]) { + return executionMode === "split-pane" ? "active-tab" : "new-tab"; +} + +interface UseV2PresetExecutionArgs { + store: StoreApi>; + projectId: string; +} + +export function useV2PresetExecution({ + store, + projectId, +}: UseV2PresetExecutionArgs) { + const collections = useCollections(); + + const { data: allPresets = [] } = useLiveQuery( + (query) => + query + .from({ v2TerminalPresets: collections.v2TerminalPresets }) + .orderBy(({ v2TerminalPresets }) => v2TerminalPresets.tabOrder), + [collections], + ); + + const matchedPresets = useMemo( + () => filterMatchingPresetsForProject(allPresets, projectId), + [allPresets, projectId], + ); + + const executePreset = useCallback( + (preset: V2TerminalPresetRow) => { + const state = store.getState(); + const activeTabId = state.activeTabId; + const target = resolveTarget(preset.executionMode); + + const plan = getPresetLaunchPlan({ + mode: preset.executionMode, + target, + commandCount: preset.commands.length, + hasActiveTab: !!activeTabId, + }); + + switch (plan) { + case "new-tab-single": { + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: [makeTerminalPane(preset.commands[0])], + }); + break; + } + + case "new-tab-multi-pane": { + const panes = preset.commands.map((cmd) => makeTerminalPane(cmd)); + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: + panes.length > 0 + ? (panes as [ + CreatePaneInput, + ...CreatePaneInput[], + ]) + : [makeTerminalPane()], + }); + break; + } + + case "new-tab-per-command": { + for (const command of preset.commands) { + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: [makeTerminalPane(command)], + }); + } + break; + } + + case "active-tab-single": { + if (!activeTabId) { + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: [makeTerminalPane(preset.commands[0])], + }); + break; + } + state.addPane({ + tabId: activeTabId, + pane: makeTerminalPane(preset.commands[0]), + }); + break; + } + + case "active-tab-multi-pane": { + if (!activeTabId) { + const panes = preset.commands.map((cmd) => makeTerminalPane(cmd)); + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: + panes.length > 0 + ? (panes as [ + CreatePaneInput, + ...CreatePaneInput[], + ]) + : [makeTerminalPane()], + }); + break; + } + for (const command of preset.commands) { + state.addPane({ + tabId: activeTabId, + pane: makeTerminalPane(command), + }); + } + break; + } + } + }, + [store], + ); + + return { matchedPresets, executePreset }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 6c1c3468af3..ef5bf804a13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -2,6 +2,7 @@ import type { WorkspaceStore } from "@superset/panes"; import { useCallback } from "react"; import { useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import type { StoreApi } from "zustand"; import type { BrowserPaneData, @@ -13,9 +14,13 @@ import type { export function useWorkspaceHotkeys({ store, workspaceId, + matchedPresets, + executePreset, }: { store: StoreApi>; workspaceId: string; + matchedPresets: V2TerminalPresetRow[]; + executePreset: (preset: V2TerminalPresetRow) => void; }) { const collections = useCollections(); @@ -236,4 +241,24 @@ export function useWorkspaceHotkeys({ if (!tab) return; state.equalizeTab({ tabId: tab.id }); }); + + // --- Preset hotkeys --- + + const openPresetByIndex = useCallback( + (index: number) => { + const preset = matchedPresets[index]; + if (preset) executePreset(preset); + }, + [matchedPresets, executePreset], + ); + + useHotkey("OPEN_PRESET_1", () => openPresetByIndex(0)); + useHotkey("OPEN_PRESET_2", () => openPresetByIndex(1)); + useHotkey("OPEN_PRESET_3", () => openPresetByIndex(2)); + useHotkey("OPEN_PRESET_4", () => openPresetByIndex(3)); + useHotkey("OPEN_PRESET_5", () => openPresetByIndex(4)); + useHotkey("OPEN_PRESET_6", () => openPresetByIndex(5)); + useHotkey("OPEN_PRESET_7", () => openPresetByIndex(6)); + useHotkey("OPEN_PRESET_8", () => openPresetByIndex(7)); + useHotkey("OPEN_PRESET_9", () => openPresetByIndex(8)); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 023f76587ab..af078ebd2be 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -26,6 +26,7 @@ import { getBrowserTabTitle, renderBrowserTabIcon, } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; import type { @@ -85,6 +86,10 @@ function WorkspaceContent({ projectId, workspaceId, }); + const { matchedPresets, executePreset } = useV2PresetExecution({ + store, + projectId, + }); const paneRegistry = usePaneRegistry(workspaceId); const defaultContextMenuActions = useDefaultContextMenuActions(); @@ -199,7 +204,7 @@ function WorkspaceContent({ const sidebarOpen = localWorkspaceState?.rightSidebarOpen ?? false; - useWorkspaceHotkeys({ store, workspaceId }); + useWorkspaceHotkeys({ store, workspaceId, matchedPresets, executePreset }); useHotkey("QUICK_OPEN", handleQuickOpen); return ( @@ -218,9 +223,8 @@ function WorkspaceContent({ renderTabIcon={renderBrowserTabIcon} renderBelowTabBar={() => ( )} renderAddTabMenu={() => (