Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/desktop/plans/20260411-v2-preset-parity.md
Original file line number Diff line number Diff line change
@@ -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`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 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
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { WorkspaceStore } from "@superset/panes";
import {
AGENT_PRESET_COMMANDS,
AGENT_PRESET_DESCRIPTIONS,
Expand All @@ -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";
Expand All @@ -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<WorkspaceStore<PaneViewerData>>;
matchedPresets: V2TerminalPresetRow[];
executePreset: (preset: V2TerminalPresetRow) => void;
}

// Co-located to keep v2 self-contained. Mirrors the v1 array in
Expand Down Expand Up @@ -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<string[]>(
() => getPinnedPresetOrder(matchedPresets),
);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,7 +33,7 @@ export function V2PresetBarItem({
pinnedIndex,
hotkeyId,
isDark,
onOpenInNewTab,
onExecutePreset,
onEdit,
onLocalReorder,
onPersistReorder,
Expand Down Expand Up @@ -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 ? (
<img src={icon} alt="" className="size-3.5 object-contain" />
Expand All @@ -109,8 +109,8 @@ export function V2PresetBarItem({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onOpenInNewTab(preset)}>
Open in new tab
<ContextMenuItem onSelect={() => onExecutePreset(preset)}>
Run preset
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onEdit(preset)}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useV2PresetExecution } from "./useV2PresetExecution";
Original file line number Diff line number Diff line change
@@ -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<PaneViewerData> {
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<WorkspaceStore<PaneViewerData>>;
projectId: string;
}

export function useV2PresetExecution({
store,
projectId,
}: UseV2PresetExecutionArgs) {
Comment thread
Kitenite marked this conversation as resolved.
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<PaneViewerData>,
...CreatePaneInput<PaneViewerData>[],
])
: [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<PaneViewerData>,
...CreatePaneInput<PaneViewerData>[],
])
: [makeTerminalPane()],
});
break;
}
for (const command of preset.commands) {
state.addPane({
tabId: activeTabId,
pane: makeTerminalPane(command),
});
}
break;
}
Comment thread
Kitenite marked this conversation as resolved.
}
},
[store],
);

return { matchedPresets, executePreset };
}
Loading
Loading