diff --git a/apps/desktop/plans/20260505-agent-preset-import-learnings.md b/apps/desktop/plans/20260505-agent-preset-import-learnings.md new file mode 100644 index 00000000000..39381fc5ed1 --- /dev/null +++ b/apps/desktop/plans/20260505-agent-preset-import-learnings.md @@ -0,0 +1,225 @@ +# Agent preset import — learnings (v2-only) + +Branch: `agent-presets-import`. Captures what worked, what didn't, and the +landmines so the next attempt is faster. **Scope: v2 only — v1 stays +untouched.** + +## Goal (one line) + +Let users import their enabled agents (from `/settings/agents`) as terminal +presets, and have edits to the agent's command in `/settings/agents` +propagate to the preset everywhere it's used. + +## Final architecture (lean, "live link with snapshot fallback") + +One optional field on the v2 preset: + +```ts +agentId?: string; +``` + +If set, the preset is live-linked to that agent definition. The launcher +and the editor dialog look the agent up in `getAgentPresets` and use its +current `command`. The stored `commands` array is kept as a snapshot +fallback for when the agent is missing or disabled. + +**Crucially**: do **not** add a `kind: "commands" | "agent"` discriminator, +do **not** extract a `@superset/shared/agent-preset-resolution` package, do +**not** ship a separate resolver test file. `agentId?` is sufficient — the +inline lookup is two lines. + +## Why this shape + +Tried first: snapshot-only on pill click (just copy the command into the +preset, no link). User correctly pushed back: editing the agent in +`/settings/agents` should update existing presets. Live link wins on +correctness. + +Tried second: discriminated union with `kind`, dedicated resolver package, +banner with deep-link to a specific agent. User correctly pushed back: +over-engineered. The `kind` discriminator is redundant once `agentId?` +exists; a deep-link to the agent settings page requires the agents page to +support a search param, which it doesn't (yet). + +## Files to touch + +### v2 schema + +- `apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts` + — `v2TerminalPresetSchema` gets `agentId: z.string().optional()`. That's + it. + +### v2 default seeding (open design choice) + +`v2TerminalPresets` is a `localStorageCollectionOptions` collection (per-org +key `v2-terminal-presets-{organizationId}`). It populates from the v1→v2 +migration shim. Since v1 isn't being changed, v1 defaults stay at the 5 +default-tagged agents with no `agentId`. v2 needs to end up with all 10 +builtins linked. + +Pick one: + +1. **Augment the migration shim** — + `apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts` + copies v1 verbatim, then for any builtin terminal agent (`AGENT_TYPES`) + not already present by name, append a v2-only row with `agentId`, + `name=AGENT_LABELS[id]`, `description=AGENT_PRESET_DESCRIPTIONS[id]`, + `commands=[AGENT_PRESET_COMMANDS[id][0]]`. Single one-time write per org + (the migration marker already gates it). +2. **Separate v2 seeder** — a small hook that runs alongside (or after) the + migration and inserts missing builtin agent-linked rows. Same idea, just + factored out. + +Either way, do **not** modify v1's `DEFAULT_PRESETS` or v1's +`createTerminalPreset` schema. + +### Settings search / visibility + +- `apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts` + — `TERMINAL_QUICK_ADD` is currently variant-tagged `"v1"`, which makes + `getVisibleItemsForSection` strip it in v2 mode (the dropdown never + renders). Change to `"shared"` (or `"v2"`). + +### UI — settings page (v2 surface) + +- `apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx` + — page width `max-w-4xl` → `max-w-6xl`. +- `.../components/PresetsSection/components/PresetsTable/PresetsTable.tsx` + — drop `max-h-[420px]` so the list expands to natural height. +- `.../components/PresetRow/PresetRow.tsx` — add an icon column. Resolve + via `getPresetIcon(preset.name, isDark)` first, fall back to + `getPresetIcon(preset.agentId, isDark)`, then ``. + (PresetRow is shared; in v1 mode `preset.agentId` is undefined so it + cleanly falls through to the name lookup.) +- `.../components/V2PresetsSection/V2PresetsSection.tsx`: + - Pull agents via `electronTrpc.settings.getAgentPresets.useQuery()` + (this hook works in `/settings/*` because it is **not** inside + `WorkspaceTrpcProvider`). + - Build `quickAddAgents` from agents where `kind === "terminal"` and + `enabled` and `command.trim()` is non-empty. + - Dedupe quick-add by `agentId` (not by name) so deleting a preset frees + the pill again. + - Pass the dropdown into the section header next to "Add preset". + - Pass `agents` prop to `PresetEditorDialog`. +- `.../components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx` + — render as a `DropdownMenu` triggered by a single button labeled + "Import agent". Each menu item shows icon + label + description, and is + disabled with a check mark when already added. +- `.../components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx` + — when `preset.agentId` is set: + - Replace the editable `name`/`description`/`commands` rows with a + banner ("Linked to {agent.label}. Edit the command in Agents settings + → Open" linking to `/settings/agents`) plus a read-only commands view + showing the live command. + - Keep `cwd`, `projectIds`, `executionMode`, autoApply rows editable. + - Fall back to `preset.name` / `preset.commands` when the live agent is + missing or disabled. + + This component is shared with v1, but v1 callers don't pass `agents` and + no v1 row has `agentId`, so the new branch is dormant in v1. + +### v2 launcher (the tRPC trap lives here) + +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts` + - Build an `agentCommandsById: Map` from `agents` (only + `kind === "terminal" && enabled` with non-empty `command`). + - At launch, resolve commands as `preset.agentId ? [agentCommandsById.get(id) ?? preset.commands[0]] : preset.commands`. + - Then call `state.addTab(...)` / `state.addPane(...)` with + `makeTerminalPane(terminalId, presetName, command)` — `initialCommand` + rides on pane data. **Single subscriber:** `TerminalPane` is the only + place that calls `terminal.createSession`. Do NOT pre-create from + `executePreset`. Do NOT fire-and-forget. Do NOT introduce a parallel + mutation. Same path as `useWorkspacePaneOpeners.addTerminalTab`, just + with a command attached. + +## The tRPC routing trap (read this first) + +`useV2PresetExecution` runs **inside `WorkspaceTrpcProvider`**, which routes +tRPC calls to the workspace HTTP server. The settings router lives on the +main-process electron client. So this: + +```ts +// BROKEN inside WorkspaceTrpcProvider — silent 404 +electronTrpc.settings.getAgentPresets.useQuery(); +``` + +resolves to `[]` forever and the live link silently fails. Use the vanilla +client via `@tanstack/react-query`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +const { data: agents = [] } = useQuery({ + queryKey: ["v2-preset-execution", "agent-presets"], + queryFn: () => electronTrpcClient.settings.getAgentPresets.query(), + staleTime: 30_000, +}); +``` + +This is the same pattern `useMigrateV1PresetsToV2` uses, with a comment +documenting the same trap. The settings page (`/settings/*`) is outside +`WorkspaceTrpcProvider`, so the React-hook form works there fine. + +## Single-subscriber rule for terminal launches + +Server (`packages/host-service/src/terminal/terminal.ts`) IS idempotent by +`terminalId` (lines 599-603) and `queueInitialCommand` is single-fire (line +474). So calling `createSession` twice with the same `terminalId` doesn't +double-execute. **But:** doing so is still confusing, hard to reason about, +and creates parallel work. Don't. + +What I tried that the user rejected: + +1. Pre-create the session from `executePreset` (await + addTab) — adds an + async hop and a second subscriber. +2. Fire-and-forget `createSession` from `executePreset` alongside + `TerminalPane`'s mount-time call — two subscribers race, even when the + server dedupes. + +What works: put `initialCommand` on pane data, let `TerminalPane`'s +`useRef(paneData.initialCommand)` + `useEffect`-driven `createSession` +handle it. Same code path as a regular tab open. + +## Things that look like bugs but aren't + +- "Cannot run preset / Linked agent is disabled or missing" toast firing + while the agent is enabled — caused by the tRPC routing trap above. Fix + the routing, the toast goes quiet. If you want a toast at all, trigger + it only when `commands.length === 0` (no live agent + empty snapshot). + Don't claim "agent is disabled" — you usually just haven't loaded the + agents query yet. +- v2 default presets without `agentId` — comes from v1's default seed + flowing through the migration shim. Handled by the v2 seeder; not a v1 + bug. + +## Order of operations for the rewrite + +1. v2 schema: add `agentId?` to `v2TerminalPresetSchema`. +2. Settings search: flip `TERMINAL_QUICK_ADD` variant from `"v1"` to + `"shared"`. +3. v2 default seeding: pick one of the two options above and implement. +4. `QuickAddPresets`: dropdown that takes `QuickAddAgentPill[]` with + `agentId`, `label`, `description`, `commands`. +5. `V2PresetsSection`: agents query (electron React hook is fine here), + `quickAddAgents`, dedupe by `agentId`, place dropdown next to "Add + preset", pass `agents` to dialog. +6. `PresetEditorDialog`: `agents?` prop, live lookup when `preset.agentId` + is set, banner + read-only commands branch. +7. `PresetRow`: icon column. +8. `PresetsTable`: drop max-h. +9. `TerminalSettings`: `max-w-6xl`. +10. `useV2PresetExecution`: vanilla `electronTrpcClient` via `useQuery`, + `agentCommandsById` map, resolve at launch, pass `initialCommand` on + pane data, **single subscriber**. + +## Out of scope / explicitly deferred + +- Anything in v1 — schema, router, defaults, launcher, settings UI all + stay as-is. +- Deep-linking the "Open" button to a specific agent on `/settings/agents` + (route doesn't accept an agent search param yet). +- A "broken link" badge on the preset row when the live agent is missing + (the dialog banner covers it). +- Healing existing user rows that already have empty `commands` from an + earlier broken iteration — clearing localStorage works. 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 index 55d59b84e3a..5e6acb75c6a 100644 --- 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 @@ -2,9 +2,11 @@ import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useMemo } from "react"; +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { getPresetLaunchPlan } from "renderer/stores/tabs/preset-launch"; import { filterMatchingPresetsForProject } from "shared/preset-project-targeting"; import type { StoreApi } from "zustand/vanilla"; @@ -43,21 +45,50 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { [collections], ); + // Read v2 agent configs from the host service — same data source as the + // /settings/agents page, so user edits there propagate here. The hook is + // already invalidated by mutations in the agents settings page. + const { activeHostUrl } = useLocalHostService(); + const { data: agents = [] } = useV2AgentConfigs(activeHostUrl); + + // Map presetId → command (first match wins if the user has multiple + // host configs for the same preset). + const agentCommandsById = useMemo(() => { + const map = new Map(); + for (const agent of agents) { + if (agent.command.trim().length === 0) continue; + if (map.has(agent.presetId)) continue; + map.set(agent.presetId, agent.command); + } + return map; + }, [agents]); + const matchedPresets = useMemo( () => filterMatchingPresetsForProject(allPresets, projectId), [allPresets, projectId], ); + const resolvePresetCommands = useCallback( + (preset: V2TerminalPresetRow): string[] => { + if (!preset.agentId) return preset.commands; + const live = agentCommandsById.get(preset.agentId); + if (live) return [live]; + return preset.commands; + }, + [agentCommandsById], + ); + const executePreset = useCallback( (preset: V2TerminalPresetRow) => { const state = store.getState(); const activeTabId = state.activeTabId; const target = resolveTarget(preset.executionMode); + const commands = resolvePresetCommands(preset); const plan = getPresetLaunchPlan({ mode: preset.executionMode, target, - commandCount: preset.commands.length, + commandCount: commands.length, hasActiveTab: !!activeTabId, }); @@ -67,18 +98,14 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { const id = crypto.randomUUID(); state.addTab({ panes: [ - makeTerminalPane( - id, - preset.name || undefined, - preset.commands[0], - ), + makeTerminalPane(id, preset.name || undefined, commands[0]), ], }); break; } case "new-tab-multi-pane": { - const panes = preset.commands.map((command) => + const panes = commands.map((command) => makeTerminalPane( crypto.randomUUID(), preset.name || undefined, @@ -103,7 +130,7 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { } case "new-tab-per-command": { - for (const command of preset.commands) { + for (const command of commands) { state.addTab({ panes: [ makeTerminalPane( @@ -122,7 +149,7 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { const pane = makeTerminalPane( id, preset.name || undefined, - preset.commands[0], + commands[0], ); if (!activeTabId) { state.addTab({ @@ -138,7 +165,7 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { } case "active-tab-multi-pane": { - const panes = preset.commands.map((command) => + const panes = commands.map((command) => makeTerminalPane( crypto.randomUUID(), preset.name || undefined, @@ -181,7 +208,7 @@ export function useV2PresetExecution({ store }: UseV2PresetExecutionArgs) { }); } }, - [store], + [store, resolvePresetCommands], ); return { matchedPresets, executePreset }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts index 81eb7e7905f..f6b952bb221 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts @@ -1,8 +1,16 @@ +import { + AGENT_LABELS, + AGENT_PRESET_COMMANDS, + AGENT_PRESET_DESCRIPTIONS, + AGENT_TYPES, + type AgentType, +} from "@superset/shared/agent-command"; import { useEffect, useRef } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { MOCK_ORG_ID } from "shared/constants"; function getMigrationMarkerKey(organizationId: string): string { @@ -45,10 +53,20 @@ export function useMigrateV1PresetsToV2() { await electronTrpcClient.settings.getTerminalPresets.query(); const now = new Date(); - collections.v2TerminalPresets.insert( - v1Presets.map((v1Preset, index) => ({ + // v1 default presets are named after the agent id (lowercase + // "claude", "codex", …). Link those rows to the matching builtin + // instead of dropping them in unlinked, so the v2 user gets one + // row per builtin (not the v1 row + a separately-seeded v2 row). + const builtinIds = new Set(AGENT_TYPES); + const linkedAgentIds = new Set(); + const rows: V2TerminalPresetRow[] = v1Presets.map((v1Preset, index) => { + const linkedAgentId = builtinIds.has(v1Preset.name) + ? (v1Preset.name as AgentType) + : undefined; + if (linkedAgentId) linkedAgentIds.add(linkedAgentId); + return { id: crypto.randomUUID(), - name: v1Preset.name, + name: linkedAgentId ? AGENT_LABELS[linkedAgentId] : v1Preset.name, description: v1Preset.description, cwd: v1Preset.cwd, commands: v1Preset.commands, @@ -59,8 +77,30 @@ export function useMigrateV1PresetsToV2() { executionMode: v1Preset.executionMode ?? "new-tab", tabOrder: index, createdAt: now, - })), - ); + agentId: linkedAgentId, + }; + }); + + // Seed any remaining builtins that weren't already represented in + // v1 — v1 only ships defaults for a subset of agents. + let nextOrder = rows.length; + for (const agentId of AGENT_TYPES) { + if (linkedAgentIds.has(agentId)) continue; + rows.push({ + id: crypto.randomUUID(), + name: AGENT_LABELS[agentId], + description: AGENT_PRESET_DESCRIPTIONS[agentId], + cwd: "", + commands: [AGENT_PRESET_COMMANDS[agentId][0] ?? ""], + projectIds: null, + executionMode: "new-tab", + tabOrder: nextOrder++, + createdAt: now, + agentId, + }); + } + + collections.v2TerminalPresets.insert(rows); localStorage.setItem(markerKey, "1"); } catch { diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 6cc1fdc28ae..30959a6d0da 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -103,6 +103,11 @@ export const v2TerminalPresetSchema = z.object({ executionMode: v2ExecutionModeSchema.default("new-tab"), tabOrder: z.number().int().default(0), createdAt: persistedDateSchema, + // When set, the preset is live-linked to a builtin/custom agent definition. + // The launcher and editor look up the agent's current command via + // settings.getAgentPresets; the stored `commands` array is a snapshot + // fallback for when the agent is missing or disabled. + agentId: z.string().optional(), }); export type DashboardSidebarProjectRow = z.infer< diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/AgentsSettings.tsx index 7fec2975f84..39234033c70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/AgentsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/AgentsSettings.tsx @@ -10,12 +10,17 @@ import { AgentCard } from "./components/AgentCard"; interface AgentsSettingsProps { visibleItems?: SettingItemId[] | null; + /** Builtin preset id to pre-select in v2 (`?agent=claude`). Ignored in v1. */ + initialAgentPresetId?: string | null; } -export function AgentsSettings({ visibleItems }: AgentsSettingsProps) { +export function AgentsSettings({ + visibleItems, + initialAgentPresetId, +}: AgentsSettingsProps) { const { isV2CloudEnabled } = useIsV2CloudEnabled(); if (isV2CloudEnabled) { - return ; + return ; } return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx index 9100aead644..405270cc8e4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx @@ -6,7 +6,7 @@ import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bot } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { V2_AGENT_CONFIGS_QUERY_KEY as QUERY_KEY, useV2AgentConfigs, @@ -16,7 +16,17 @@ import { useLocalHostService } from "renderer/routes/_authenticated/providers/Lo import { AgentDetail } from "./components/AgentDetail"; import { AgentsSettingsSidebar } from "./components/AgentsSettingsSidebar"; -export function V2AgentsSettings() { +interface V2AgentsSettingsProps { + /** + * Builtin preset id to pre-select on mount (e.g. "claude"). Resolved + * against `HostAgentConfigDto.presetId`. Consumed once per visit. + */ + initialAgentPresetId?: string | null; +} + +export function V2AgentsSettings({ + initialAgentPresetId, +}: V2AgentsSettingsProps = {}) { const { activeHostUrl } = useLocalHostService(); const queryClient = useQueryClient(); @@ -111,17 +121,28 @@ export function V2AgentsSettings() { ); const [selectedAgentId, setSelectedAgentId] = useState(null); + const consumedInitialPresetIdRef = useRef(false); // Auto-select first agent when none selected, and clear selection when the - // selected agent disappears. + // selected agent disappears. If `initialAgentPresetId` is provided (deep + // link from a preset's "Open" button), prefer the matching config the + // first time configs become available. useEffect(() => { if (configs.length === 0) { if (selectedAgentId !== null) setSelectedAgentId(null); return; } + if (initialAgentPresetId && !consumedInitialPresetIdRef.current) { + const match = configs.find((c) => c.presetId === initialAgentPresetId); + if (match) { + consumedInitialPresetIdRef.current = true; + setSelectedAgentId(match.id); + return; + } + } const stillExists = configs.some((c) => c.id === selectedAgentId); if (!stillExists) setSelectedAgentId(configs[0].id); - }, [configs, selectedAgentId]); + }, [configs, selectedAgentId, initialAgentPresetId]); const selectedAgent = configs.find((c) => c.id === selectedAgentId) ?? null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/page.tsx index ba9ac02559d..75df8c1f2ff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/page.tsx @@ -4,12 +4,24 @@ import { useSettingsSearchQuery } from "renderer/stores/settings-state"; import { getMatchingItemsForSection } from "../utils/settings-search"; import { AgentsSettings } from "./components/AgentsSettings"; +export type AgentsSettingsSearch = { + /** + * Builtin agent preset id (e.g. "claude", "codex"). When set, the v2 + * agents page selects the matching host config on mount. v1 ignores it. + */ + agent?: string; +}; + export const Route = createFileRoute("/_authenticated/settings/agents/")({ component: AgentsSettingsPage, + validateSearch: (search: Record): AgentsSettingsSearch => ({ + agent: typeof search.agent === "string" ? search.agent : undefined, + }), }); function AgentsSettingsPage() { const searchQuery = useSettingsSearchQuery(); + const { agent } = Route.useSearch(); const visibleItems = useMemo(() => { if (!searchQuery) return null; @@ -18,5 +30,10 @@ function AgentsSettingsPage() { ); }, [searchQuery]); - return ; + return ( + + ); } 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 e8dbda8f175..6909f87e70b 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 @@ -66,7 +66,7 @@ export function TerminalSettings({ ); return ( -
+

Terminal

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 832e6675fc6..4de1833f555 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 @@ -4,13 +4,22 @@ import { cn } from "@superset/ui/utils"; import { Eye, EyeOff } from "lucide-react"; import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; +import { HiMiniCommandLine } from "react-icons/hi2"; import { LuGripVertical } from "react-icons/lu"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; import type { TerminalPreset } from "renderer/routes/_authenticated/settings/presets/types"; import { getPresetProjectTargetLabel, type PresetProjectOption, } from "../PresetsSection/preset-project-options"; +interface PresetWithAgent extends TerminalPreset { + agentId?: string; +} + const PRESET_TYPE = "TERMINAL_PRESET"; interface PresetRowProps { @@ -66,6 +75,15 @@ export function PresetRow({ drag(dragHandleRef); }, [preview, drop, drag]); + const isDark = useIsDarkTheme(); + const presetAgentId = (preset as PresetWithAgent).agentId; + // Try the preset's display name first (covers v1 builtins named after the + // agent and any user preset named "Claude"). Fall back to the linked + // agent id for v2 presets imported via the Import-agent dropdown. + const presetIcon = + getPresetIcon(preset.name, isDark) ?? + (presetAgentId ? getPresetIcon(presetAgentId, isDark) : undefined); + const isWorkspaceCreation = !!preset.applyOnWorkspaceCreated; const isNewTab = !!preset.applyOnNewTab; const isVisibleInBar = preset.pinnedToBar !== false; @@ -113,6 +131,14 @@ export function PresetRow({ isDragging && "opacity-30", )} > +

+ {presetIcon ? ( + + ) : ( + + )} +
+
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx index 169865e8dc8..d4a7d1f241a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx @@ -13,12 +13,11 @@ import { usePresets } from "renderer/react-query/presets"; import type { PresetColumnKey } from "renderer/routes/_authenticated/settings/presets/types"; import { PresetEditorDialog } from "./components/PresetEditorDialog"; import { PresetsTable } from "./components/PresetsTable"; -import { QuickAddPresets } from "./components/QuickAddPresets"; import { - type AutoApplyField, - PRESET_TEMPLATES, - type PresetTemplate, -} from "./constants"; + type QuickAddAgentPill, + QuickAddPresets, +} from "./components/QuickAddPresets"; +import { type AutoApplyField, PRESET_TEMPLATES } from "./constants"; import type { PresetProjectOption } from "./preset-project-options"; interface PresetsSectionProps { @@ -148,8 +147,19 @@ export function PresetsSection({ [serverPresets], ); - const isTemplateAdded = useCallback( - (template: PresetTemplate) => existingPresetNames.has(template.preset.name), + const quickAddPills = useMemo( + () => + PRESET_TEMPLATES.map((template) => ({ + agentId: template.name, + label: template.preset.name, + description: template.preset.description, + commands: template.preset.commands, + })), + [], + ); + + const isPillAdded = useCallback( + (pill: QuickAddAgentPill) => existingPresetNames.has(pill.label), [existingPresetNames], ); @@ -269,10 +279,15 @@ export function PresetsSection({ [createPreset], ); - const handleAddTemplate = useCallback( - (template: PresetTemplate) => { - if (existingPresetNames.has(template.preset.name)) return; - createPreset.mutate(template.preset); + const handleAddPill = useCallback( + (pill: QuickAddAgentPill) => { + if (existingPresetNames.has(pill.label)) return; + createPreset.mutate({ + name: pill.label, + description: pill.description, + cwd: "", + commands: pill.commands, + }); }, [createPreset, existingPresetNames], ); @@ -467,11 +482,11 @@ export function PresetsSection({ {showQuickAdd && ( )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx index 36d78ba7ee7..e08fbb2ed28 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx @@ -1,3 +1,4 @@ +import type { HostAgentConfigDto } from "@superset/host-service/settings"; import type { ExecutionMode, TerminalPreset } from "@superset/local-db"; import { Alert, AlertDescription } from "@superset/ui/alert"; import { Button } from "@superset/ui/button"; @@ -18,7 +19,8 @@ import { SelectValue, } from "@superset/ui/select"; import { Switch } from "@superset/ui/switch"; -import { Trash2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { ExternalLink, Trash2 } from "lucide-react"; import { useMemo } from "react"; import { HiExclamationTriangle, HiOutlineFolderOpen } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -34,9 +36,20 @@ import type { AutoApplyField } from "../../constants"; import type { PresetProjectOption } from "../../preset-project-options"; import { ProjectTargetingField } from "./components/ProjectTargetingField"; +interface PresetWithAgent extends TerminalPreset { + agentId?: string; +} + interface PresetEditorDialogProps { preset: TerminalPreset | null; projects: PresetProjectOption[]; + /** + * Host-service agent configs. When provided and `preset.agentId` matches + * a config's `presetId`, the dialog renders the linked-agent branch + * (read-only command + Open in Agents settings link). v1 callers omit + * this — no v1 row has agentId, so the linked branch stays dormant. + */ + agents?: HostAgentConfigDto[]; open: boolean; onOpenChange: (open: boolean) => void; onDeletePreset: () => void; @@ -156,6 +169,7 @@ function Segmented({ export function PresetEditorDialog({ preset, projects, + agents, open, onOpenChange, onDeletePreset, @@ -172,6 +186,16 @@ export function PresetEditorDialog({ isWorkspaceCreation, isNewTab, }: PresetEditorDialogProps) { + const linkedAgent = useMemo(() => { + const presetAgentId = (preset as PresetWithAgent | null)?.agentId; + if (!presetAgentId || !agents) return null; + return agents.find((agent) => agent.presetId === presetAgentId) ?? null; + }, [preset, agents]); + const linkedAgentId = (preset as PresetWithAgent | null)?.agentId; + const isLinked = !!linkedAgentId; + const liveCommands = linkedAgent + ? [linkedAgent.command] + : (preset?.commands ?? []); const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); const originRoute = useSettingsOriginRoute(); const trimmedCwd = preset?.cwd.trim() ?? ""; @@ -262,46 +286,104 @@ export function PresetEditorDialog({ {preset ? ( <> - {preset.name.trim() || "Edit preset"} + + {(linkedAgent?.label ?? preset.name).trim() || "Edit preset"} +
- - onFieldChange("name", e.target.value)} - onBlur={() => onFieldBlur("name")} - placeholder="e.g. Dev server" - /> - + {isLinked ? ( + <> + + + + Linked to{" "} + + {linkedAgent?.label ?? linkedAgentId} + + . Edit the command in Agents settings. + + onOpenChange(false)} + > + + + + - - onFieldChange("description", e.target.value)} - onBlur={() => onFieldBlur("description")} - placeholder="Optional" - /> - + +
+ {liveCommands.length > 0 ? ( + liveCommands.map((cmd) => ( +
+ {cmd || "—"} +
+ )) + ) : ( +
+ )} +
+
+ + ) : ( + <> + + onFieldChange("name", e.target.value)} + onBlur={() => onFieldBlur("name")} + placeholder="e.g. Dev server" + /> + - - - + + + onFieldChange("description", e.target.value) + } + onBlur={() => onFieldBlur("description")} + placeholder="Optional" + /> + + + + + + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx index 3d0bdf3a4fa..cdb25f7253f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx @@ -1,65 +1,94 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { cn } from "@superset/ui/utils"; -import { HiOutlineCheck, HiOutlinePlus } from "react-icons/hi2"; +import { HiMiniCommandLine } from "react-icons/hi2"; +import { LuCheck, LuChevronDown, LuPlus } from "react-icons/lu"; import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; -import type { PresetTemplate } from "../../constants"; + +export interface QuickAddAgentPill { + agentId: string; + label: string; + description: string; + commands: string[]; +} interface QuickAddPresetsProps { - templates: PresetTemplate[]; + pills: QuickAddAgentPill[]; isDark: boolean; - isCreatePending: boolean; - isTemplateAdded: (template: PresetTemplate) => boolean; - onAddTemplate: (template: PresetTemplate) => void; + isAddDisabled?: boolean; + isPillAdded: (pill: QuickAddAgentPill) => boolean; + onAddPill: (pill: QuickAddAgentPill) => void; } export function QuickAddPresets({ - templates, + pills, isDark, - isCreatePending, - isTemplateAdded, - onAddTemplate, + isAddDisabled, + isPillAdded, + onAddPill, }: QuickAddPresetsProps) { return ( -
- Quick add - {templates.map((template) => { - const alreadyAdded = isTemplateAdded(template); - const presetIcon = getPresetIcon(template.name, isDark); - const disabled = alreadyAdded || isCreatePending; - return ( - - - + + + {pills.map((pill) => { + const alreadyAdded = isPillAdded(pill); + const icon = getPresetIcon(pill.agentId, isDark); + return ( + { + if (alreadyAdded) { + event.preventDefault(); + return; + } + onAddPill(pill); + }} + className={cn( + "flex items-start gap-3 py-2", + alreadyAdded && "opacity-60", + )} + > +
+ {icon ? ( + ) : ( - + + )} +
+
+
+ {pill.label} +
+ {pill.description && ( +
+ {pill.description} +
)} - {template.name} - - - - {alreadyAdded ? "Already added" : template.preset.description} - - - ); - })} -
+
+ {alreadyAdded && ( + + )} + + ); + })} + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/index.ts index b464d379a64..3b9fd5cda4e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/index.ts @@ -1 +1,4 @@ -export { QuickAddPresets } from "./QuickAddPresets"; +export { + type QuickAddAgentPill, + QuickAddPresets, +} from "./QuickAddPresets"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx index a5c04ec8e00..2a5f634b583 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx @@ -3,24 +3,29 @@ import { normalizeExecutionMode, type TerminalPreset, } from "@superset/local-db"; +import { + AGENT_PRESET_DESCRIPTIONS, + type AgentType, +} from "@superset/shared/agent-command"; import { Button } from "@superset/ui/button"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiOutlinePlus } from "react-icons/hi2"; import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; 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 { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import type { PresetColumnKey } from "renderer/routes/_authenticated/settings/presets/types"; import { PresetEditorDialog } from "../PresetsSection/components/PresetEditorDialog"; import { PresetsTable } from "../PresetsSection/components/PresetsTable"; -import { QuickAddPresets } from "../PresetsSection/components/QuickAddPresets"; import { - type AutoApplyField, - PRESET_TEMPLATES, - type PresetTemplate, -} from "../PresetsSection/constants"; + type QuickAddAgentPill, + QuickAddPresets, +} from "../PresetsSection/components/QuickAddPresets"; +import type { AutoApplyField } from "../PresetsSection/constants"; import type { PresetProjectOption } from "../PresetsSection/preset-project-options"; interface V2PresetsSectionProps { @@ -50,6 +55,12 @@ export function V2PresetsSection({ const collections = useCollections(); useMigrateV1PresetsToV2(); + // Read v2 agent configs from the host service — this is the same + // data source the v2 /settings/agents page reads and writes, so edits + // there propagate here. The query is invalidated by those mutations. + const { activeHostUrl } = useLocalHostService(); + const { data: agents = [] } = useV2AgentConfigs(activeHostUrl); + const { data: v2Presets = [] } = useLiveQuery( (query) => query @@ -166,14 +177,41 @@ export function V2PresetsSection({ } }, [editingPresetId, localPresets, setEditingPreset]); - const existingPresetNames = useMemo( - () => new Set(serverPresets.map((preset) => preset.name)), + const existingAgentIds = useMemo( + () => + new Set( + serverPresets + .map((preset) => (preset as V2TerminalPresetRow).agentId) + .filter((id): id is string => !!id), + ), [serverPresets], ); - const isTemplateAdded = useCallback( - (template: PresetTemplate) => existingPresetNames.has(template.preset.name), - [existingPresetNames], + // Quick-add lists every host-configured agent. We dedupe by presetId + // (= our preset's `agentId`) so a user with multiple Claude configs gets + // one pill and so deleting a preset frees the pill again. + const quickAddPills = useMemo(() => { + const seen = new Set(); + const pills: QuickAddAgentPill[] = []; + for (const agent of agents) { + if (seen.has(agent.presetId) || agent.command.trim().length === 0) { + continue; + } + seen.add(agent.presetId); + pills.push({ + agentId: agent.presetId, + label: agent.label, + description: + AGENT_PRESET_DESCRIPTIONS[agent.presetId as AgentType] ?? "", + commands: [agent.command], + }); + } + return pills; + }, [agents]); + + const isPillAdded = useCallback( + (pill: QuickAddAgentPill) => existingAgentIds.has(pill.agentId), + [existingAgentIds], ); const insertV2Preset = useCallback( @@ -185,6 +223,7 @@ export function V2PresetsSection({ projectIds?: string[] | null; pinnedToBar?: boolean; executionMode?: ExecutionMode; + agentId?: string; }) => { const maxTabOrder = v2Presets.reduce( (max, preset) => Math.max(max, preset.tabOrder), @@ -201,6 +240,7 @@ export function V2PresetsSection({ executionMode: input.executionMode ?? "new-tab", tabOrder: maxTabOrder + 1, createdAt: new Date(), + agentId: input.agentId, }); }, [collections.v2TerminalPresets, v2Presets], @@ -350,12 +390,18 @@ export function V2PresetsSection({ [insertV2Preset], ); - const handleAddTemplate = useCallback( - (template: PresetTemplate) => { - if (existingPresetNames.has(template.preset.name)) return; - insertV2Preset(template.preset); + const handleAddPill = useCallback( + (pill: QuickAddAgentPill) => { + if (existingAgentIds.has(pill.agentId)) return; + insertV2Preset({ + name: pill.label, + description: pill.description, + cwd: "", + commands: pill.commands, + agentId: pill.agentId, + }); }, - [existingPresetNames, insertV2Preset], + [existingAgentIds, insertV2Preset], ); useEffect(() => { @@ -525,25 +571,23 @@ export function V2PresetsSection({ reorder.

- {showPresets && ( - - )} -
- - {showQuickAdd && ( -
- +
+ {showQuickAdd && ( + + )} + {showPresets && ( + + )}
- )} +
{showPresets && ( !open && handleCloseEditor()} onDeletePreset={handleDeleteEditingPreset} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index d87c79101c6..3fe98dce558 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -139,7 +139,7 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.AGENTS_TASK_PROMPTS]: "shared", [SETTING_ITEM_ID.TERMINAL_PRESETS]: "shared", - [SETTING_ITEM_ID.TERMINAL_QUICK_ADD]: "v1", + [SETTING_ITEM_ID.TERMINAL_QUICK_ADD]: "shared", [SETTING_ITEM_ID.TERMINAL_SESSIONS]: "shared", [SETTING_ITEM_ID.TERMINAL_LINK_BEHAVIOR]: "v1",