From ffd238646aaf2ccdadbf082be2a4f8766edabb08 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:49:16 -0700 Subject: [PATCH 1/9] feat(host-service): host agent configs (v2 PR 1, argv-array shape) (#3914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(host-service): host agent configs (v2 PR 1, argv-array shape) PR 1 of the canonical workspace.create() refactor — see plans/20260425-host-agent-configs-pr1.md. Introduces the V2 host-runtime agent config model. Configs live in host.db, are edited through a new V2 Agents settings page, and are listed via a new settings.agentConfigs.* tRPC router. The V2 modal + launch dispatch are intentionally NOT migrated in this PR; that lands later (PR 5). Backend - New host_agent_configs table (argv-array shape: command, args[], promptTransport, promptArgs[], env) with migration 0004. Storing argv directly avoids shell-quoting bugs and makes prompt injection a list push instead of string concatenation. - Hardcoded AgentPreset catalog (claude, amp, codex, gemini, opencode, pi, copilot, cursor-agent). Default seed = claude/amp/codex/gemini/ copilot. Superset Chat intentionally excluded. - settings.agentConfigs.{list, listPresets, add, update, remove, reorder, resetToDefaults} with seeding on first list, duplicate- presetId support, and reorder integrity validation. - 17 router tests against an in-memory bun:sqlite db. Renderer (desktop) - Under FEATURE_FLAGS.V2_CLOUD, the Agents settings page renders a new V2AgentsSettings component that talks to the active host's settings.agentConfigs.*. Non-V2 keeps the legacy preset UI unchanged. - Single shell-style command input parsed via shell-quote into command + args; separate prompt-only args input; argv/stdin transport toggle. Add (preset picker), remove, up/down reorder, reset-to- defaults wired through tRPC mutations + react-query invalidation. - 8 argv-parsing/joining tests. Launch resolution (consumed in a later PR): argv = prompt ? [command, ...args, ...promptArgs, ...(transport === "argv" ? [prompt] : [])] : [command, ...args] Empty launches drop promptArgs, so codex/opencode/copilot don't carry their prompt-mode flags into promptless sessions. * test(host-service): run agent-configs router tests against real migrations Replaces the hand-rolled CREATE TABLE with the actual drizzle migrate() run. Now every test boots from an empty in-memory db that migrates 0000…0004 in sequence, so any drift between schema.ts and the generated migration SQL would fail every test instead of silently passing. * feat(desktop): polish V2 agents settings UI to match v1 (icons, drag-to-reorder) Restructure V2AgentsSettings to mirror v1's AgentCard look-and-feel: - Each row now shows preset icon + label + description + chevron, matching v1's AgentCardHeader layout. Icons resolve via getPresetIcon by presetId. - Drag-to-reorder via @dnd-kit/sortable replaces the up/down arrow buttons. Reorder mutation has optimistic updates so the row settles immediately on drop and rolls back on error. - Splits the card into its own folder (components/V2AgentCard) per AGENTS.md project structure (one folder per component). Server side: AgentPreset gains a `description` field (read-only, returned by listPresets). Renderer maps presetId → description from listPresets to feed the card. No on-disk schema change — descriptions remain catalog-only, never stored on the user's instance row. * feat(desktop): show preset icons in Add agent dropdown Renders the same getPresetIcon-resolved SVG next to each label in the dropdown. Bundled-asset lookup only, no extra IPC or host-service call. * feat(host-service): add Mastracode preset (--prompt, no suffix) Adds Mastracode as an Add-menu template only (not in default seed, matching v1's includeInDefaultTerminalPresets behavior). Uses `mastracode --prompt ` for prompt launches and `mastracode` alone for promptless launches. v1's `; mastracode` post-prompt REPL suffix is intentionally dropped — V2 doesn't model shell chaining. * fix(host-service): address review tier 1 + tier 2 on agent configs Backend (agent-configs.ts): - parseArgv/parseEnv wrap JSON.parse in try/catch — a single bad row no longer fails the whole list() response. - Trim label and command via z.string().trim().min(1) so whitespace-only values can't slip through to the UI as blank rows. - Wrap reorder updates in a transaction so a crash mid-loop can't leave display_order half-updated. - Wrap resetToDefaults delete+insert in a transaction so a crash between the two can't leave the table empty. - remove() now reads the row first and throws NOT_FOUND for unknown ids, matching update()'s behavior. Renderer (V2AgentCard.tsx): - Roll back optimistic promptTransport on update error so the toggle doesn't lie about persistence. Renderer (V2AgentsSettings.tsx): - Render an explicit error state (with Retry) when configsQuery fails, so a host-service outage isn't masked as "no agents configured". argv helpers (argv.ts): - Stop trimming/dropping empty tokens; quote command alongside args in joinCommandArgs. Round-trip is now lossless for paths with spaces and empty quoted args. Tests: - 3 new router tests (NOT_FOUND remove, whitespace rejection, trim). - 2 new argv tests (path with spaces, empty quoted arg). --- .../AgentsSettings/AgentsSettings.tsx | 10 + .../V2AgentsSettings/V2AgentsSettings.tsx | 263 +++++++++++++ .../components/V2AgentCard/V2AgentCard.tsx | 306 +++++++++++++++ .../components/V2AgentCard/index.ts | 1 + .../components/V2AgentsSettings/index.ts | 1 + .../V2AgentsSettings/utils/argv.test.ts | 80 ++++ .../components/V2AgentsSettings/utils/argv.ts | 47 +++ packages/host-service/package.json | 4 + .../host-service/src/trpc/router/router.ts | 2 + .../router/settings/agent-configs.test.ts | 271 +++++++++++++ .../src/trpc/router/settings/agent-configs.ts | 362 ++++++++++++++++++ .../src/trpc/router/settings/agent-presets.ts | 168 ++++++++ .../src/trpc/router/settings/index.ts | 9 + 13 files changed, 1524 insertions(+) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/V2AgentCard.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.ts create mode 100644 packages/host-service/src/trpc/router/settings/agent-configs.test.ts create mode 100644 packages/host-service/src/trpc/router/settings/agent-configs.ts create mode 100644 packages/host-service/src/trpc/router/settings/agent-presets.ts create mode 100644 packages/host-service/src/trpc/router/settings/index.ts 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 15a7c7df4e0..452ac5c77ed 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 @@ -1,9 +1,11 @@ +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { isItemVisible, SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; +import { V2AgentsSettings } from "../V2AgentsSettings"; import { AgentCard } from "./components/AgentCard"; interface AgentsSettingsProps { @@ -11,6 +13,14 @@ interface AgentsSettingsProps { } export function AgentsSettings({ visibleItems }: AgentsSettingsProps) { + const { isV2CloudEnabled } = useIsV2CloudEnabled(); + if (isV2CloudEnabled) { + return ; + } + return ; +} + +function V1AgentsSettings({ visibleItems }: AgentsSettingsProps) { const { data: presets = [], isLoading } = electronTrpc.settings.getAgentPresets.useQuery(); 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 new file mode 100644 index 00000000000..5f228cac5f2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx @@ -0,0 +1,263 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import type { + AgentPreset, + HostAgentConfigDto, +} from "@superset/host-service/settings"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, RotateCcw } from "lucide-react"; +import { useMemo } from "react"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { V2AgentCard } from "./components/V2AgentCard"; + +const QUERY_KEY = ["host-agent-configs"] as const; + +export function V2AgentsSettings() { + const { activeHostUrl } = useLocalHostService(); + const queryClient = useQueryClient(); + const isDark = useIsDarkTheme(); + + const configsQuery = useQuery({ + queryKey: [...QUERY_KEY, activeHostUrl] as const, + enabled: !!activeHostUrl, + queryFn: () => { + if (!activeHostUrl) return [] as HostAgentConfigDto[]; + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.list.query(); + }, + }); + + const presetsQuery = useQuery({ + queryKey: [...QUERY_KEY, "presets", activeHostUrl] as const, + enabled: !!activeHostUrl, + queryFn: () => { + if (!activeHostUrl) return [] as AgentPreset[]; + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.listPresets.query(); + }, + }); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, activeHostUrl] }); + + const addMutation = useMutation({ + mutationFn: (presetId: string) => { + if (!activeHostUrl) throw new Error("Host service is not available"); + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.add.mutate({ presetId }); + }, + onSuccess: () => invalidate(), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to add agent"), + }); + + const reorderMutation = useMutation({ + mutationFn: (ids: string[]) => { + if (!activeHostUrl) throw new Error("Host service is not available"); + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.reorder.mutate({ ids }); + }, + onMutate: async (ids) => { + await queryClient.cancelQueries({ + queryKey: [...QUERY_KEY, activeHostUrl], + }); + const previous = queryClient.getQueryData([ + ...QUERY_KEY, + activeHostUrl, + ]); + if (previous) { + const byId = new Map(previous.map((row) => [row.id, row])); + const next = ids + .map((id, index) => { + const row = byId.get(id); + return row ? { ...row, order: index } : null; + }) + .filter((row): row is HostAgentConfigDto => row !== null); + queryClient.setQueryData([...QUERY_KEY, activeHostUrl], next); + } + return { previous }; + }, + onError: (err, _ids, ctx) => { + if (ctx?.previous) { + queryClient.setQueryData([...QUERY_KEY, activeHostUrl], ctx.previous); + } + toast.error(err instanceof Error ? err.message : "Failed to reorder"); + }, + onSettled: () => invalidate(), + }); + + const resetMutation = useMutation({ + mutationFn: () => { + if (!activeHostUrl) throw new Error("Host service is not available"); + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.resetToDefaults.mutate(); + }, + onSuccess: () => invalidate(), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to reset"), + }); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const configs = configsQuery.data ?? []; + const presets = presetsQuery.data ?? []; + const sortableIds = useMemo(() => configs.map((row) => row.id), [configs]); + const descriptionByPresetId = useMemo( + () => + new Map(presets.map((preset) => [preset.presetId, preset.description])), + [presets], + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = sortableIds.indexOf(String(active.id)); + const newIndex = sortableIds.indexOf(String(over.id)); + if (oldIndex < 0 || newIndex < 0) return; + reorderMutation.mutate(arrayMove(sortableIds, oldIndex, newIndex)); + }; + + return ( +
+
+
+

Agents

+

+ Configure terminal agents available on this host. Drag to reorder. +

+
+
+ + + + + + + {presets.map((preset) => { + const icon = getPresetIcon(preset.presetId, isDark); + return ( + addMutation.mutate(preset.presetId)} + className="gap-2" + > + {icon ? ( + + ) : ( +
+ )} + {preset.label} + + ); + })} + + +
+
+ + {configsQuery.isLoading ? ( +

+ Loading agent settings... +

+ ) : configsQuery.isError ? ( +
+

+ Couldn't load agent settings:{" "} + {configsQuery.error instanceof Error + ? configsQuery.error.message + : "host service unavailable"} +

+ +
+ ) : configs.length === 0 ? ( +

+ No agents configured. Add one from the menu above. +

+ ) : ( + + +
+ {configs.map((config) => ( + + ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/V2AgentCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/V2AgentCard.tsx new file mode 100644 index 00000000000..a154effc7f4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/V2AgentCard.tsx @@ -0,0 +1,306 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { + HostAgentConfigDto, + PromptTransport, +} from "@superset/host-service/settings"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@superset/ui/card"; +import { Collapsible, CollapsibleContent } from "@superset/ui/collapsible"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useMutation } from "@tanstack/react-query"; +import { ChevronDownIcon, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { LuGripVertical } from "react-icons/lu"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + joinArgs, + joinCommandArgs, + parseArgs, + parseCommandString, +} from "../../utils/argv"; + +interface V2AgentCardProps { + config: HostAgentConfigDto; + description: string; + onChanged: () => void; +} + +export function V2AgentCard({ + config, + description, + onChanged, +}: V2AgentCardProps) { + const { activeHostUrl } = useLocalHostService(); + const isDark = useIsDarkTheme(); + const icon = getPresetIcon(config.presetId, isDark); + + const { + setNodeRef, + setActivatorNodeRef, + attributes, + listeners, + isDragging, + transform, + transition, + } = useSortable({ id: config.id }); + + const [isOpen, setIsOpen] = useState(false); + const [label, setLabel] = useState(config.label); + const [commandText, setCommandText] = useState( + joinCommandArgs(config.command, config.args), + ); + const [promptArgsText, setPromptArgsText] = useState( + joinArgs(config.promptArgs), + ); + const [promptTransport, setPromptTransport] = useState( + config.promptTransport, + ); + + useEffect(() => { + setLabel(config.label); + setCommandText(joinCommandArgs(config.command, config.args)); + setPromptArgsText(joinArgs(config.promptArgs)); + setPromptTransport(config.promptTransport); + }, [ + config.label, + config.command, + config.args, + config.promptArgs, + config.promptTransport, + ]); + + const updateMutation = useMutation({ + mutationFn: ( + patch: Parameters< + ReturnType< + typeof getHostServiceClientByUrl + >["settings"]["agentConfigs"]["update"]["mutate"] + >[0]["patch"], + ) => { + if (!activeHostUrl) throw new Error("Host service is not available"); + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.update.mutate({ id: config.id, patch }); + }, + onSuccess: () => onChanged(), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to save"), + }); + + const removeMutation = useMutation({ + mutationFn: () => { + if (!activeHostUrl) throw new Error("Host service is not available"); + return getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.remove.mutate({ id: config.id }); + }, + onSuccess: () => onChanged(), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to remove"), + }); + + const handleLabelBlur = () => { + if (label !== config.label && label.trim().length > 0) { + updateMutation.mutate({ label }); + } + }; + + const handleCommandBlur = () => { + const { command, args } = parseCommandString(commandText); + if (command.length === 0) { + toast.error("Command cannot be empty"); + setCommandText(joinCommandArgs(config.command, config.args)); + return; + } + const changed = + command !== config.command || + args.length !== config.args.length || + args.some((arg, i) => arg !== config.args[i]); + if (changed) updateMutation.mutate({ command, args }); + }; + + const handlePromptArgsBlur = () => { + const args = parseArgs(promptArgsText); + const changed = + args.length !== config.promptArgs.length || + args.some((arg, i) => arg !== config.promptArgs[i]); + if (changed) updateMutation.mutate({ promptArgs: args }); + }; + + const handleTransportChange = (next: PromptTransport) => { + if (next === promptTransport) return; + const prev = promptTransport; + setPromptTransport(next); + updateMutation.mutate( + { promptTransport: next }, + { onError: () => setPromptTransport(prev) }, + ); + }; + + return ( +
+ + + {/* biome-ignore lint/a11y/useSemanticElements: div needed to avoid invalid nested + {icon ? ( + + ) : ( +
+ )} +
+ {config.label} + + {description} + +
+
+ +
+
+ + +
+ + setLabel(e.target.value)} + onBlur={handleLabelBlur} + /> +
+
+ + setCommandText(e.target.value)} + onBlur={handleCommandBlur} + placeholder="claude --permission-mode acceptEdits" + /> +

+ Argv used for promptless launches. The prompt is appended + after the prompt-only args. +

+
+
+ + setPromptArgsText(e.target.value)} + onBlur={handlePromptArgsBlur} + placeholder="(empty)" + /> +

+ Inserted only when launching with a prompt — e.g.{" "} + -- for codex, --prompt for opencode,{" "} + -i for copilot. +

+
+
+ +
+ + +
+

+ argv: append the prompt as the last argv + element. stdin: pipe the prompt to the + spawned process's stdin. +

+
+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/index.ts new file mode 100644 index 00000000000..e30a9509f0f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/V2AgentCard/index.ts @@ -0,0 +1 @@ +export { V2AgentCard } from "./V2AgentCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/index.ts new file mode 100644 index 00000000000..93515cf6725 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/index.ts @@ -0,0 +1 @@ +export { V2AgentsSettings } from "./V2AgentsSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.test.ts new file mode 100644 index 00000000000..6f6a87a47e5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { + joinArgs, + joinCommandArgs, + parseArgs, + parseCommandString, +} from "./argv"; + +describe("parseCommandString", () => { + it("splits a simple command and args", () => { + expect(parseCommandString("claude --permission-mode acceptEdits")).toEqual({ + command: "claude", + args: ["--permission-mode", "acceptEdits"], + }); + }); + + it("preserves quoted segments containing spaces", () => { + expect( + parseCommandString('codex -c "model_reasoning_effort=high"'), + ).toEqual({ + command: "codex", + args: ["-c", "model_reasoning_effort=high"], + }); + }); + + it("returns empty command for empty input", () => { + expect(parseCommandString("")).toEqual({ command: "", args: [] }); + expect(parseCommandString(" ")).toEqual({ command: "", args: [] }); + }); +}); + +describe("joinCommandArgs", () => { + it("returns command alone when args are empty", () => { + expect(joinCommandArgs("amp", [])).toBe("amp"); + }); + + it("round-trips a command path with spaces", () => { + const command = "/opt/My Agent/bin/runner"; + const args = ["--flag"]; + const joined = joinCommandArgs(command, args); + const reparsed = parseCommandString(joined); + expect(reparsed.command).toBe(command); + expect(reparsed.args).toEqual(args); + }); + + it("round-trips an empty quoted arg", () => { + const args = ["--name", "", "--flag"]; + const joined = joinCommandArgs("amp", args); + const reparsed = parseCommandString(joined); + expect(reparsed.command).toBe("amp"); + expect(reparsed.args).toEqual(args); + }); + + it("round-trips quoted args through parse and join", () => { + const args = ["-c", "model_reasoning_effort=high"]; + const joined = joinCommandArgs("codex", args); + const reparsed = parseCommandString(joined); + expect(reparsed.command).toBe("codex"); + expect(reparsed.args).toEqual(args); + }); + + it("round-trips claude default through parse and join", () => { + const original = "claude --permission-mode acceptEdits"; + const { command, args } = parseCommandString(original); + expect(joinCommandArgs(command, args)).toBe(original); + }); +}); + +describe("parseArgs / joinArgs", () => { + it("round-trips an empty list", () => { + expect(parseArgs("")).toEqual([]); + expect(joinArgs([])).toBe(""); + }); + + it("round-trips simple flag args", () => { + expect(parseArgs("--")).toEqual(["--"]); + expect(parseArgs("-i")).toEqual(["-i"]); + expect(joinArgs(["--prompt"])).toBe("--prompt"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.ts new file mode 100644 index 00000000000..d37b888b445 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/utils/argv.ts @@ -0,0 +1,47 @@ +import { parse, quote } from "shell-quote"; + +/** + * Format a command + argv array as an editable shell-style string. + * Round-trips through `parseCommandString` losslessly: the command + * and every argv element are quoted (when needed) so paths with + * spaces and explicit empty strings survive the round trip. + */ +export function joinCommandArgs(command: string, args: string[]): string { + const tokens = command.length === 0 ? args : [command, ...args]; + if (tokens.length === 0) return ""; + return quote(tokens); +} + +/** + * Parse a shell-style string into `command` (first token) and the rest as + * `args`. Drops control operators (`|`, `>`, etc.) — this is a launch + * spec, not a shell invocation. Empty quoted args (`""`) and tokens with + * embedded spaces are preserved exactly. + */ +export function parseCommandString(input: string): { + command: string; + args: string[]; +} { + const tokens = parse(input).filter( + (token): token is string => typeof token === "string", + ); + if (tokens.length === 0) return { command: "", args: [] }; + const [command, ...args] = tokens; + return { command: command ?? "", args }; +} + +/** Format a bare argv array (no leading executable). */ +export function joinArgs(args: string[]): string { + if (args.length === 0) return ""; + return quote(args); +} + +/** + * Parse a bare argv array (no leading executable). Preserves empty + * quoted args; drops only shell control operators. + */ +export function parseArgs(input: string): string[] { + return parse(input).filter( + (token): token is string => typeof token === "string", + ); +} diff --git a/packages/host-service/package.json b/packages/host-service/package.json index ddf22d8994f..b3b7194bd17 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -35,6 +35,10 @@ "./attachments": { "types": "./src/trpc/router/attachments/index.ts", "default": "./src/trpc/router/attachments/index.ts" + }, + "./settings": { + "types": "./src/trpc/router/settings/index.ts", + "default": "./src/trpc/router/settings/index.ts" } }, "scripts": { diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index fbec24985d3..6bcff364d32 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -14,6 +14,7 @@ import { notificationsRouter } from "./notifications"; import { portsRouter } from "./ports"; import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; +import { settingsRouter } from "./settings"; import { terminalRouter } from "./terminal"; import { workspaceRouter } from "./workspace"; import { workspaceCleanupRouter } from "./workspace-cleanup"; @@ -36,6 +37,7 @@ export const appRouter = router({ pullRequests: pullRequestsRouter, project: projectRouter, ports: portsRouter, + settings: settingsRouter, terminal: terminalRouter, workspace: workspaceRouter, workspaces: workspacesRouter, diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts new file mode 100644 index 00000000000..6561aa5998b --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts @@ -0,0 +1,271 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, it } from "bun:test"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import * as schema from "../../../db/schema"; +import type { HostServiceContext } from "../../../types"; +import { agentConfigsRouter } from "./agent-configs"; +import { AGENT_PRESETS } from "./agent-presets"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle"); + +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = drizzle(sqlite, { schema }); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + return db; +} + +function createCaller() { + const db = createTestDb(); + const ctx = { db, isAuthenticated: true } as unknown as HostServiceContext; + return agentConfigsRouter.createCaller(ctx); +} + +async function listFirst( + caller: ReturnType, +) { + const rows = await caller.list(); + const first = rows[0]; + if (!first) throw new Error("expected seeded rows but list was empty"); + return first; +} + +const DEFAULT_PRESET_IDS = ["claude", "amp", "codex", "gemini", "copilot"]; + +describe("agentConfigsRouter", () => { + describe("list()", () => { + it("seeds bundled defaults on first call", async () => { + const caller = createCaller(); + + const result = await caller.list(); + + expect(result.map((row) => row.presetId)).toEqual(DEFAULT_PRESET_IDS); + expect(result.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + }); + + it("does not seed Superset Chat", async () => { + const caller = createCaller(); + const result = await caller.list(); + expect( + result.find((row) => row.presetId === "superset-chat"), + ).toBeUndefined(); + }); + + it("returns existing rows on subsequent calls without re-seeding", async () => { + const caller = createCaller(); + const first = await caller.list(); + const second = await caller.list(); + expect(second.map((row) => row.id)).toEqual(first.map((row) => row.id)); + }); + + it("returns rows in displayOrder", async () => { + const caller = createCaller(); + const seeded = await caller.list(); + await caller.reorder({ + ids: [...seeded.map((row) => row.id)].reverse(), + }); + + const reordered = await caller.list(); + expect(reordered.map((row) => row.presetId)).toEqual( + [...DEFAULT_PRESET_IDS].reverse(), + ); + expect(reordered.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + }); + }); + + describe("listPresets()", () => { + it("returns the full hardcoded preset catalog", async () => { + const caller = createCaller(); + const presets = await caller.listPresets(); + expect(presets.map((preset) => preset.presetId)).toEqual( + AGENT_PRESETS.map((preset) => preset.presetId), + ); + }); + }); + + describe("add()", () => { + it("copies preset fields and assigns a unique id and next order", async () => { + const caller = createCaller(); + await caller.list(); + + const created = await caller.add({ presetId: "pi" }); + + expect(created.presetId).toBe("pi"); + expect(created.command).toBe("pi"); + expect(created.promptTransport).toBe("argv"); + expect(created.order).toBe(5); + const all = await caller.list(); + expect(all).toHaveLength(6); + expect(new Set(all.map((row) => row.id)).size).toBe(6); + }); + + it("allows duplicate presetId entries with distinct ids", async () => { + const caller = createCaller(); + await caller.list(); + + const a = await caller.add({ presetId: "claude" }); + const b = await caller.add({ presetId: "claude" }); + + expect(a.id).not.toBe(b.id); + const claudes = (await caller.list()).filter( + (row) => row.presetId === "claude", + ); + expect(claudes).toHaveLength(3); + }); + + it("rejects unknown presetId", async () => { + const caller = createCaller(); + await expect( + caller.add({ presetId: "nonexistent-preset" }), + ).rejects.toThrow(); + }); + }); + + describe("update()", () => { + it("persists label, command, args, promptTransport, promptArgs, env", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + + const updated = await caller.update({ + id: first.id, + patch: { + label: "Custom Claude", + command: "claude-yolo", + args: ["--mode", "fast"], + promptTransport: "stdin", + promptArgs: ["-X"], + env: { ANTHROPIC_API_KEY: "test" }, + }, + }); + + expect(updated.label).toBe("Custom Claude"); + expect(updated.command).toBe("claude-yolo"); + expect(updated.args).toEqual(["--mode", "fast"]); + expect(updated.promptTransport).toBe("stdin"); + expect(updated.promptArgs).toEqual(["-X"]); + expect(updated.env).toEqual({ ANTHROPIC_API_KEY: "test" }); + }); + + it("rejects invalid promptTransport", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + await expect( + caller.update({ + id: first.id, + // biome-ignore lint/suspicious/noExplicitAny: testing invalid input + patch: { promptTransport: "file" as any }, + }), + ).rejects.toThrow(); + }); + + it("rejects an empty patch", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + await expect( + caller.update({ id: first.id, patch: {} }), + ).rejects.toThrow(); + }); + + it("rejects update for missing id", async () => { + const caller = createCaller(); + await expect( + caller.update({ id: "does-not-exist", patch: { label: "x" } }), + ).rejects.toThrow(); + }); + + it("rejects whitespace-only label and command", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + await expect( + caller.update({ id: first.id, patch: { label: " " } }), + ).rejects.toThrow(); + await expect( + caller.update({ id: first.id, patch: { command: " " } }), + ).rejects.toThrow(); + }); + + it("trims label and command on save", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + const result = await caller.update({ + id: first.id, + patch: { label: " Trimmed ", command: " trimmed-cmd " }, + }); + expect(result.label).toBe("Trimmed"); + expect(result.command).toBe("trimmed-cmd"); + }); + }); + + describe("remove()", () => { + it("deletes a config by id", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + + const result = await caller.remove({ id: first.id }); + + expect(result.success).toBe(true); + const remaining = await caller.list(); + expect(remaining.find((row) => row.id === first.id)).toBeUndefined(); + }); + + it("throws NOT_FOUND for an unknown id", async () => { + const caller = createCaller(); + await caller.list(); + await expect(caller.remove({ id: "does-not-exist" })).rejects.toThrow( + /not found/i, + ); + }); + }); + + describe("reorder()", () => { + it("persists the submitted id order", async () => { + const caller = createCaller(); + const seeded = await caller.list(); + const reversed = [...seeded.map((row) => row.id)].reverse(); + + const result = await caller.reorder({ ids: reversed }); + + expect(result.map((row) => row.id)).toEqual(reversed); + expect(result.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + }); + + it("rejects when ids do not match existing configs", async () => { + const caller = createCaller(); + const seeded = await caller.list(); + + await expect( + caller.reorder({ + ids: [...seeded.slice(0, 2).map((row) => row.id)], + }), + ).rejects.toThrow(); + }); + + it("rejects duplicate ids", async () => { + const caller = createCaller(); + const first = await listFirst(caller); + await expect( + caller.reorder({ ids: [first.id, first.id] }), + ).rejects.toThrow(); + }); + }); + + describe("resetToDefaults()", () => { + it("replaces current configs with bundled defaults", async () => { + const caller = createCaller(); + const seedFirst = await listFirst(caller); + await caller.update({ + id: seedFirst.id, + patch: { label: "Renamed" }, + }); + await caller.add({ presetId: "pi" }); + + const result = await caller.resetToDefaults(); + + expect(result.map((row) => row.presetId)).toEqual(DEFAULT_PRESET_IDS); + expect(result.find((row) => row.label === "Renamed")).toBeUndefined(); + expect(result.find((row) => row.presetId === "pi")).toBeUndefined(); + }); + }); +}); diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.ts b/packages/host-service/src/trpc/router/settings/agent-configs.ts new file mode 100644 index 00000000000..278a54b06fd --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/agent-configs.ts @@ -0,0 +1,362 @@ +import { randomUUID } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { asc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import type { HostDb } from "../../../db"; +import { hostAgentConfigs } from "../../../db/schema"; +import { protectedProcedure, router } from "../../index"; +import { + AGENT_PRESETS, + type AgentPreset, + getDefaultSeedPresets, + getPresetById, + type PromptTransport, +} from "./agent-presets"; + +const promptTransportSchema = z.enum(["argv", "stdin"]); + +const presetIdSchema = z + .string() + .refine((value) => getPresetById(value) !== undefined, { + message: "Unknown presetId", + }); + +const argvSchema = z.array(z.string()); +const envSchema = z.record(z.string(), z.string()); + +interface HostAgentConfigOutput { + id: string; + presetId: string; + label: string; + command: string; + args: string[]; + promptTransport: PromptTransport; + promptArgs: string[]; + env: Record; + order: number; +} + +interface HostAgentConfigRow { + id: string; + presetId: string; + label: string; + command: string; + argsJson: string; + promptTransport: string; + promptArgsJson: string; + envJson: string; + displayOrder: number; +} + +function parseArgv(value: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return []; + } + if ( + !Array.isArray(parsed) || + parsed.some((item) => typeof item !== "string") + ) { + return []; + } + return parsed as string[]; +} + +function parseEnv(value: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return {}; + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.values(parsed).some((item) => typeof item !== "string") + ) { + return {}; + } + return parsed as Record; +} + +function toOutput(row: HostAgentConfigRow): HostAgentConfigOutput { + return { + id: row.id, + presetId: row.presetId, + label: row.label, + command: row.command, + args: parseArgv(row.argsJson), + promptTransport: row.promptTransport as PromptTransport, + promptArgs: parseArgv(row.promptArgsJson), + env: parseEnv(row.envJson), + order: row.displayOrder, + }; +} + +function rowFromPreset( + preset: AgentPreset, + displayOrder: number, +): typeof hostAgentConfigs.$inferInsert { + return { + id: randomUUID(), + presetId: preset.presetId, + label: preset.label, + command: preset.command, + argsJson: JSON.stringify(preset.args), + promptTransport: preset.promptTransport, + promptArgsJson: JSON.stringify(preset.promptArgs), + envJson: JSON.stringify(preset.env), + displayOrder, + }; +} + +function listOrdered(db: HostDb): HostAgentConfigRow[] { + return db + .select() + .from(hostAgentConfigs) + .orderBy(asc(hostAgentConfigs.displayOrder)) + .all(); +} + +function seedDefaultsIfEmpty(db: HostDb): HostAgentConfigRow[] { + const existing = listOrdered(db); + if (existing.length > 0) return existing; + const seeds = getDefaultSeedPresets().map((preset, index) => + rowFromPreset(preset, index), + ); + if (seeds.length === 0) return existing; + db.insert(hostAgentConfigs).values(seeds).run(); + return listOrdered(db); +} + +const updatePatchSchema = z + .object({ + label: z.string().trim().min(1).optional(), + command: z.string().trim().min(1).optional(), + args: argvSchema.optional(), + promptTransport: promptTransportSchema.optional(), + promptArgs: argvSchema.optional(), + env: envSchema.optional(), + }) + .refine( + (patch) => + patch.label !== undefined || + patch.command !== undefined || + patch.args !== undefined || + patch.promptTransport !== undefined || + patch.promptArgs !== undefined || + patch.env !== undefined, + { message: "Patch must update at least one field" }, + ); + +export const agentConfigsRouter = router({ + /** + * List configured host agents in persisted order. Seeds bundled defaults + * on first call when no configs exist. + */ + list: protectedProcedure.query(({ ctx }) => { + const rows = seedDefaultsIfEmpty(ctx.db); + return rows.map(toOutput); + }), + + /** + * Available add templates. Returns the hardcoded preset list — the UI + * uses this to render the "add agent" picker. + */ + listPresets: protectedProcedure.query(() => + AGENT_PRESETS.map((preset) => ({ + ...preset, + args: [...preset.args], + promptArgs: [...preset.promptArgs], + env: { ...preset.env }, + })), + ), + + /** + * Create a new host agent config from a hardcoded preset. Allows + * duplicate `presetId` entries — each gets a fresh `id`. + */ + add: protectedProcedure + .input(z.object({ presetId: presetIdSchema })) + .mutation(({ ctx, input }) => { + const preset = getPresetById(input.presetId); + if (!preset) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown presetId: ${input.presetId}`, + }); + } + const existing = listOrdered(ctx.db); + const nextOrder = + existing.length === 0 + ? 0 + : Math.max(...existing.map((row) => row.displayOrder)) + 1; + const insert = rowFromPreset(preset, nextOrder); + ctx.db.insert(hostAgentConfigs).values(insert).run(); + const created = ctx.db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, insert.id)) + .get(); + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to read back inserted host agent config", + }); + } + return toOutput(created); + }), + + /** + * Update editable fields on an existing config. `presetId` and `order` + * are not mutable. + */ + update: protectedProcedure + .input( + z.object({ + id: z.string().min(1), + patch: updatePatchSchema, + }), + ) + .mutation(({ ctx, input }) => { + const existing = ctx.db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, input.id)) + .get(); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Host agent config not found: ${input.id}`, + }); + } + const update: Partial = { + updatedAt: Date.now(), + }; + if (input.patch.label !== undefined) update.label = input.patch.label; + if (input.patch.command !== undefined) + update.command = input.patch.command; + if (input.patch.args !== undefined) + update.argsJson = JSON.stringify(input.patch.args); + if (input.patch.promptTransport !== undefined) + update.promptTransport = input.patch.promptTransport; + if (input.patch.promptArgs !== undefined) + update.promptArgsJson = JSON.stringify(input.patch.promptArgs); + if (input.patch.env !== undefined) + update.envJson = JSON.stringify(input.patch.env); + ctx.db + .update(hostAgentConfigs) + .set(update) + .where(eq(hostAgentConfigs.id, input.id)) + .run(); + const updated = ctx.db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, input.id)) + .get(); + if (!updated) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to read back updated host agent config", + }); + } + return toOutput(updated); + }), + + /** Delete a single host agent config by id. Throws NOT_FOUND if missing. */ + remove: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(({ ctx, input }) => { + const existing = ctx.db + .select({ id: hostAgentConfigs.id }) + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, input.id)) + .get(); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Host agent config not found: ${input.id}`, + }); + } + ctx.db + .delete(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, input.id)) + .run(); + return { success: true as const }; + }), + + /** + * Persist a new ordering. The submitted ids must match the current + * configured ids exactly — no additions, no removals, no duplicates. + * All updates run in a single transaction so a crash mid-loop can't + * leave displayOrder half-updated. + */ + reorder: protectedProcedure + .input(z.object({ ids: z.array(z.string().min(1)).min(1) })) + .mutation(({ ctx, input }) => { + const existing = listOrdered(ctx.db); + const existingIds = new Set(existing.map((row) => row.id)); + const inputIds = new Set(input.ids); + if (inputIds.size !== input.ids.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Reorder ids must be unique", + }); + } + if ( + existingIds.size !== inputIds.size || + input.ids.some((id) => !existingIds.has(id)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Reorder ids must match existing configs exactly", + }); + } + const now = Date.now(); + ctx.db.transaction((tx) => { + input.ids.forEach((id, index) => { + tx.update(hostAgentConfigs) + .set({ displayOrder: index, updatedAt: now }) + .where(eq(hostAgentConfigs.id, id)) + .run(); + }); + }); + return listOrdered(ctx.db).map(toOutput); + }), + + /** + * Replace the current configs with the bundled defaults. Wrapped in a + * transaction so a crash between delete and insert can't leave the + * table empty. + */ + resetToDefaults: protectedProcedure.mutation(({ ctx }) => { + ctx.db.transaction((tx) => { + const existing = tx + .select({ id: hostAgentConfigs.id }) + .from(hostAgentConfigs) + .all(); + if (existing.length > 0) { + tx.delete(hostAgentConfigs) + .where( + inArray( + hostAgentConfigs.id, + existing.map((row) => row.id), + ), + ) + .run(); + } + const seeds = getDefaultSeedPresets().map((preset, index) => + rowFromPreset(preset, index), + ); + if (seeds.length > 0) { + tx.insert(hostAgentConfigs).values(seeds).run(); + } + }); + return listOrdered(ctx.db).map(toOutput); + }), +}); + +export type HostAgentConfigDto = HostAgentConfigOutput; diff --git a/packages/host-service/src/trpc/router/settings/agent-presets.ts b/packages/host-service/src/trpc/router/settings/agent-presets.ts new file mode 100644 index 00000000000..7fec9751ef8 --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/agent-presets.ts @@ -0,0 +1,168 @@ +export type PromptTransport = "argv" | "stdin"; + +export interface AgentPreset { + presetId: string; + label: string; + description: string; + command: string; + args: string[]; + promptTransport: PromptTransport; + promptArgs: string[]; + env: Record; +} + +/** + * Hardcoded terminal agent presets. Used as add templates and as the seed + * for first `list()` / `resetToDefaults()`. + * + * Launch resolution: + * prompt + * ? [command, ...args, ...promptArgs, ...(promptTransport === "argv" ? [prompt] : [])] + * : [command, ...args] + * + * `promptArgs` is only included when launching with a prompt — codex's + * trailing `--`, opencode's `--prompt`, and copilot's `-i` therefore do + * not appear in promptless launches. Stdin transport pipes the prompt to + * the spawned process's stdin instead of pushing it to argv. + * + * Superset Chat is intentionally excluded — its model/provider config + * lives in chat settings, not in terminal-agent configs. + */ +export const AGENT_PRESETS = [ + { + presetId: "claude", + label: "Claude", + description: + "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", + command: "claude", + args: ["--permission-mode", "acceptEdits"], + promptTransport: "argv", + promptArgs: [], + env: {}, + }, + { + presetId: "amp", + label: "Amp", + description: + "Amp's coding agent for terminal-first coding, subagents, and task work.", + command: "amp", + args: [], + promptTransport: "stdin", + promptArgs: [], + env: {}, + }, + { + presetId: "codex", + label: "Codex", + description: + "OpenAI's coding agent for reading, modifying, and running code across tasks.", + command: "codex", + args: [ + "-c", + 'model_reasoning_effort="high"', + "-c", + 'model_reasoning_summary="detailed"', + "-c", + "model_supports_reasoning_summaries=true", + "--full-auto", + ], + promptTransport: "argv", + promptArgs: ["--"], + env: {}, + }, + { + presetId: "gemini", + label: "Gemini", + description: + "Google's open-source terminal agent for coding, problem-solving, and task work.", + command: "gemini", + args: ["--approval-mode=auto_edit"], + promptTransport: "argv", + promptArgs: [], + env: {}, + }, + { + presetId: "mastracode", + label: "Mastracode", + description: + "Mastra's coding agent for building, debugging, and shipping code from the terminal.", + command: "mastracode", + args: [], + promptTransport: "argv", + promptArgs: ["--prompt"], + env: {}, + }, + { + presetId: "opencode", + label: "OpenCode", + description: "Open-source coding agent for the terminal, IDE, and desktop.", + command: "opencode", + args: [], + promptTransport: "argv", + promptArgs: ["--prompt"], + env: {}, + }, + { + presetId: "pi", + label: "Pi", + description: + "Minimal terminal coding harness for flexible coding workflows.", + command: "pi", + args: [], + promptTransport: "argv", + promptArgs: [], + env: {}, + }, + { + presetId: "copilot", + label: "Copilot", + description: + "GitHub's coding agent for planning, editing, and building in your repo.", + command: "copilot", + args: ["--allow-tool=write"], + promptTransport: "argv", + promptArgs: ["-i"], + env: {}, + }, + { + presetId: "cursor-agent", + label: "Cursor Agent", + description: + "Cursor's coding agent for editing, running, and debugging code in parallel.", + command: "cursor-agent", + args: [], + promptTransport: "argv", + promptArgs: [], + env: {}, + }, +] as const satisfies readonly AgentPreset[]; + +const DEFAULT_PRESET_IDS = new Set([ + "claude", + "amp", + "codex", + "gemini", + "copilot", +]); + +export function getDefaultSeedPresets(): AgentPreset[] { + return AGENT_PRESETS.filter((preset) => + DEFAULT_PRESET_IDS.has(preset.presetId), + ).map((preset) => ({ + ...preset, + args: [...preset.args], + promptArgs: [...preset.promptArgs], + env: { ...preset.env }, + })); +} + +export function getPresetById(presetId: string): AgentPreset | undefined { + const preset = AGENT_PRESETS.find((item) => item.presetId === presetId); + if (!preset) return undefined; + return { + ...preset, + args: [...preset.args], + promptArgs: [...preset.promptArgs], + env: { ...preset.env }, + }; +} diff --git a/packages/host-service/src/trpc/router/settings/index.ts b/packages/host-service/src/trpc/router/settings/index.ts new file mode 100644 index 00000000000..14837d2f53d --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/index.ts @@ -0,0 +1,9 @@ +import { router } from "../../index"; +import { agentConfigsRouter } from "./agent-configs"; + +export const settingsRouter = router({ + agentConfigs: agentConfigsRouter, +}); + +export type { HostAgentConfigDto } from "./agent-configs"; +export type { AgentPreset, PromptTransport } from "./agent-presets"; From 0d6e5f7d0cd420ad1528723fa547887a5f626a71 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Mon, 4 May 2026 14:21:37 -0700 Subject: [PATCH 2/9] feat(desktop): v2 agents at startup + in v2 new workspace modal (#4052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): surface v2 host agents at startup and in new workspace modal Prefill v2 host_agent_configs from a top-level hook so Settings → Agents loads instantly instead of fetching only on navigation, and switch the v2 new workspace modal off v1 getAgentPresets onto the same v2 source so custom labels and multiple per-preset configs actually appear in the agent picker. AgentSelect drops its hard dependency on AgentDefinitionId/ResolvedAgentConfig and accepts a structural { id, label, iconId? } so v2 callers can pass UUIDs while keeping the preset-keyed icon. WorkspaceCreateAgent relaxes to string and bumps its localStorage key so persisted v1 slugs don't poison the v2 picker. * fix(setup): skip Node-unsafe ports during port-base allocation Next.js refuses to bind ports Node treats as unsafe (e.g. 5060/sip, 5061/sips), so a workspace that landed on base 5060 couldn't run web/api. Extend SUPERSET_RESERVED_PORTS to cover the Node-unsafe ports that fall in our [3000, ...) allocation window so port_base_is_safe routes around them. * chore(desktop): trim comments on v2-agents wiring * fix(desktop): address PR review on v2 agents wiring P1 — DashboardNewWorkspaceModal latched on "No agent" when opened before the startup prefetch resolved. useAgentLaunchPreferences captured defaultAgent at useState init; once query data arrived, the corrective effect short-circuited because "none" was always in validAgents. Add a one-shot effect that promotes the placeholder to the first configured agent on initial fetch when no stored preference exists. P2 — useV2AgentConfigs had no staleTime, so each consumer would refetch on mount and negate the prefetch. Set staleTime to Infinity since mutations in Settings → Agents already invalidate the key explicitly. * fix(desktop): scope v2 agent picker to the launch host CodeRabbit flagged that the v2 new workspace modal sourced agents from the locally-active host while submitting to draft.hostId (which can target a remote device). Agent UUIDs are host-scoped, so picking on host A and submitting to host B sent a config id host B didn't recognize. useV2AgentConfigs now takes an explicit hostUrl. AgentHooks and V2AgentsSettings keep passing the local active URL; PromptGroup resolves the launch host URL the same way the upload path does and feeds that in. Promote effect updated to react to host switches: it now runs whenever the current selection isn't a real agent and the user hasn't explicitly chosen "none", instead of only on initial mount. --- .superset/lib/setup/steps.sh | 32 +++++++++ .../components/AgentSelect/AgentSelect.tsx | 23 +++---- .../renderer/hooks/useV2AgentConfigs/index.ts | 4 ++ .../useV2AgentConfigs/useV2AgentConfigs.ts | 27 ++++++++ .../components/AgentHooks/AgentHooks.tsx | 7 ++ .../PromptGroup/PromptGroup.tsx | 66 ++++++++++++++++--- .../PromptGroup/types.ts | 8 +-- .../V2AgentsSettings/V2AgentsSettings.tsx | 17 ++--- 8 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/useV2AgentConfigs/index.ts create mode 100644 apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 34c5031232d..9ce849a3076 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -307,6 +307,38 @@ step_start_electric() { return 0 } +# Ports we must avoid because the OS (or commonly-installed services) listen on +# them, OR because Node/Next.js refuses to bind them. Bases whose +# [base, base+range) window contains any of these are skipped during allocation. +# +# - 5000, 7000: macOS Control Center / AirPlay Receiver (Sonoma+). Cannot be +# freed without disabling AirPlay Receiver in System Settings, so we just +# route around them. +# - Node/Next.js "unsafe ports" in our [3000, ...) allocation range. Next.js +# refuses to start on these with errors like "Bad port: '5060' is reserved +# for sip" (see https://nextjs.org/docs/messages/reserved-port). +# 3659 apple-sasl +# 4045 lockd / npp +# 5060 sip +# 5061 sips +# 6000 X11 +# 6566 sane-port +# 6665-6669, 6697 IRC / IRC+TLS +SUPERSET_RESERVED_PORTS="3659 4045 5000 5060 5061 6000 6566 6665 6666 6667 6668 6669 6697 7000" + +# Returns 0 if the [base, base+range) window contains no reserved port. +port_base_is_safe() { + local base=$1 + local range=$2 + local reserved + for reserved in $SUPERSET_RESERVED_PORTS; do + if [ "$reserved" -ge "$base" ] && [ "$reserved" -lt "$((base + range))" ]; then + return 1 + fi + done + return 0 +} + allocate_port_base() { local alloc_file="$HOME/.superset/port-allocations.json" local lock_dir="$HOME/.superset/port-allocations.lock" diff --git a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx index 74ac677cfa6..64aaf93e08e 100644 --- a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx +++ b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx @@ -1,7 +1,3 @@ -import type { - AgentDefinitionId, - ResolvedAgentConfig, -} from "@superset/shared/agent-settings"; import { Select, SelectContent, @@ -18,8 +14,16 @@ import { const CONFIGURE_AGENTS_VALUE = "__configure_agents__"; +// v1 callers' `id` doubles as the icon key. v2 ids are UUIDs, so v2 callers +// pass `iconId: presetId` to keep the preset-keyed icon lookup working. +export interface AgentSelectAgent { + id: string; + label: string; + iconId?: string; +} + interface AgentSelectProps { - agents: ResolvedAgentConfig[]; + agents: AgentSelectAgent[]; value?: T; placeholder: string; onValueChange: (value: T) => void; @@ -49,13 +53,10 @@ export function AgentSelect({ }: AgentSelectProps) { const navigate = useNavigate(); const isDark = useIsDarkTheme(); - const selectableIds = new Set( - agents.map((agent) => agent.id), - ); + const selectableIds = new Set(agents.map((agent) => agent.id)); const selectedValue = value != null && - ((allowNone && value === noneValue) || - selectableIds.has(value as AgentDefinitionId)) + ((allowNone && value === noneValue) || selectableIds.has(value)) ? value : undefined; const showSeparator = (allowNone || agents.length > 0) && !disabled; @@ -84,7 +85,7 @@ export function AgentSelect({ {noneLabel} )} {agents.map((agent) => { - const icon = getPresetIcon(agent.id, isDark); + const icon = getPresetIcon(agent.iconId ?? agent.id, isDark); return ( diff --git a/apps/desktop/src/renderer/hooks/useV2AgentConfigs/index.ts b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/index.ts new file mode 100644 index 00000000000..7c2f1447453 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/index.ts @@ -0,0 +1,4 @@ +export { + useV2AgentConfigs, + V2_AGENT_CONFIGS_QUERY_KEY, +} from "./useV2AgentConfigs"; diff --git a/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts new file mode 100644 index 00000000000..c1dac607509 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2AgentConfigs/useV2AgentConfigs.ts @@ -0,0 +1,27 @@ +import type { HostAgentConfigDto } from "@superset/host-service/settings"; +import { useQuery } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +export const V2_AGENT_CONFIGS_QUERY_KEY = ["host-agent-configs"] as const; + +/** + * Caller passes the host URL explicitly so this hook works for any host the + * user is targeting (local, remote-via-relay, or whatever the new-workspace + * modal has resolved). Cache is keyed on URL so distinct hosts don't share + * entries. Configs only change via Settings → Agents mutations that invalidate + * this key — `staleTime: Infinity` keeps the startup prefetch warm across + * navigation instead of every consumer refetching on mount. + */ +export function useV2AgentConfigs(hostUrl: string | null) { + return useQuery({ + queryKey: [...V2_AGENT_CONFIGS_QUERY_KEY, hostUrl] as const, + enabled: !!hostUrl, + queryFn: () => { + if (!hostUrl) return [] as HostAgentConfigDto[]; + return getHostServiceClientByUrl( + hostUrl, + ).settings.agentConfigs.list.query(); + }, + staleTime: Number.POSITIVE_INFINITY, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx index a95eb503316..c30405b580f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx @@ -1,3 +1,5 @@ +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { useCommandWatcher } from "./hooks/useCommandWatcher"; import { useDevicePresence } from "./hooks/useDevicePresence"; @@ -6,7 +8,12 @@ import { useDevicePresence } from "./hooks/useDevicePresence"; * useCommandWatcher uses useCollections which must be inside the provider. */ export function AgentHooks() { + const { activeHostUrl } = useLocalHostService(); useDevicePresence(); useCommandWatcher(); + // Warm v2 agent cache for the local host so Settings doesn't refetch on + // first navigation. Remote-host caches populate lazily when the modal + // targets a different device. + useV2AgentConfigs(activeHostUrl); return null; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx index 51f3204e199..62d38dbb398 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -25,7 +25,7 @@ import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/componen import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; import { resolveHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; -import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { PLATFORM } from "renderer/hotkeys"; import { authClient } from "renderer/lib/auth-client"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -112,22 +112,70 @@ export function PromptGroup({ linkedPR, } = draft; - // ── Agent presets ──────────────────────────────────────────────── - const { agents: enabledAgentPresets, isFetched: agentsFetched } = - useEnabledAgents(); + // ── Agent configs (v2 host_agent_configs) ─────────────────────── + // Scoped to the launch host, not the local active host: agent UUIDs only + // exist on the host that owns them, so picking from the local list while + // submitting to a remote host would send a config id the target doesn't + // recognize. + const launchHostUrl = useMemo(() => { + const id = draft.hostId ?? machineId; + if (!id || !activeOrganizationId) return null; + return ( + resolveHostUrl({ + hostId: id, + machineId, + activeHostUrl, + organizationId: activeOrganizationId, + }) ?? null + ); + }, [draft.hostId, machineId, activeHostUrl, activeOrganizationId]); + const v2AgentConfigsQuery = useV2AgentConfigs(launchHostUrl); + const v2Agents = useMemo( + () => + (v2AgentConfigsQuery.data ?? []).map((config) => ({ + id: config.id, + label: config.label, + iconId: config.presetId, + })), + [v2AgentConfigsQuery.data], + ); const selectableAgentIds = useMemo( - () => enabledAgentPresets.map((preset) => preset.id), - [enabledAgentPresets], + () => v2Agents.map((agent) => agent.id), + [v2Agents], ); const { selectedAgent, setSelectedAgent } = useAgentLaunchPreferences({ agentStorageKey: AGENT_STORAGE_KEY, - defaultAgent: "claude", + defaultAgent: "none", fallbackAgent: "none", validAgents: ["none", ...selectableAgentIds], - agentsReady: agentsFetched, + agentsReady: v2AgentConfigsQuery.isFetched, }); + // Promote the placeholder "none" → first configured agent whenever the + // current selection isn't a real agent and the user hasn't explicitly + // chosen "none". Fires on initial open (where useState init captured + // "none" before the query resolved) AND on host switch (where the + // previous host's UUID isn't valid here, so the corrective effect inside + // useAgentLaunchPreferences resets to "none"). The corrective effect + // can't rescue these on its own because "none" is always in validAgents. + useEffect(() => { + if (!v2AgentConfigsQuery.isFetched) return; + if (selectedAgent !== "none") return; + const stored = + typeof window !== "undefined" + ? window.localStorage.getItem(AGENT_STORAGE_KEY) + : null; + if (stored === "none") return; + const first = selectableAgentIds[0]; + if (first) setSelectedAgent(first); + }, [ + v2AgentConfigsQuery.isFetched, + selectableAgentIds, + selectedAgent, + setSelectedAgent, + ]); + const branchPreview = branchNameEdited ? sanitizeUserBranchName(branchName) : ""; @@ -383,7 +431,7 @@ export function PromptGroup({ - agents={enabledAgentPresets} + agents={v2Agents} value={selectedAgent} placeholder="No agent" onValueChange={setSelectedAgent} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts index 41ee248568c..932f41ed246 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts @@ -1,8 +1,8 @@ -import type { AgentDefinitionId } from "@superset/shared/agent-settings"; +// v2 ids are runtime host_agent_configs UUIDs, not a static enum like v1. +export type WorkspaceCreateAgent = string; -export type WorkspaceCreateAgent = AgentDefinitionId | "none"; - -export const AGENT_STORAGE_KEY = "lastSelectedWorkspaceCreateAgent"; +// New key — old one held v1 preset slugs that won't match v2 UUIDs. +export const AGENT_STORAGE_KEY = "lastSelectedV2WorkspaceCreateAgent"; export const PILL_BUTTON_CLASS = "!h-[22px] min-h-0 rounded-md border-[0.5px] border-border bg-foreground/[0.04] shadow-none text-[11px]"; 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 5f228cac5f2..31d977e1b89 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 @@ -33,27 +33,20 @@ import { getPresetIcon, useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; +import { + V2_AGENT_CONFIGS_QUERY_KEY as QUERY_KEY, + useV2AgentConfigs, +} from "renderer/hooks/useV2AgentConfigs"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { V2AgentCard } from "./components/V2AgentCard"; -const QUERY_KEY = ["host-agent-configs"] as const; - export function V2AgentsSettings() { const { activeHostUrl } = useLocalHostService(); const queryClient = useQueryClient(); const isDark = useIsDarkTheme(); - const configsQuery = useQuery({ - queryKey: [...QUERY_KEY, activeHostUrl] as const, - enabled: !!activeHostUrl, - queryFn: () => { - if (!activeHostUrl) return [] as HostAgentConfigDto[]; - return getHostServiceClientByUrl( - activeHostUrl, - ).settings.agentConfigs.list.query(); - }, - }); + const configsQuery = useV2AgentConfigs(activeHostUrl); const presetsQuery = useQuery({ queryKey: [...QUERY_KEY, "presets", activeHostUrl] as const, From 8b037ae105f83f07c43ad7fc717b7fa6cda22691 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Tue, 5 May 2026 14:07:06 -0700 Subject: [PATCH 3/9] feat(desktop): setup/teardown scripts editor for v2 projects (#4090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * plan: v2 setup/teardown scripts editor Plan-only. Code changes deferred until the plan is reviewed. Scope is strictly v2 — no v1 ScriptsEditor / electronTrpc config router / v1 SetupScriptCard touches. Per project rule: v1 desktop UI is sunset. * feat(v2): setup/teardown scripts editor + sidebar CTA Adds the v2 equivalent of the v1 project-settings scripts editor and makes v2 workspace creation honor the configured setup array (was only running a literal `/.superset/setup.sh`). - host-service config loader resolves `/.superset/config.json` + `~/.superset/projects//config.json` user override + local `.superset/config.local.json` overlay; no worktree-level read so the main repo stays the single source of truth. - new host-service `config` router: getConfigContent, updateConfig, shouldShowSetupCard. - setup-terminal helper joins resolved setup commands with `&&` and falls back to `bash /.superset/setup.sh` against the main repo. Worktrees reach the canonical .superset/ via $SUPERSET_ROOT_PATH. - V2ScriptsEditor mounts in V2ProjectSettings (Setup/Teardown tabs, blur-only save, multi-line→array, drag-drop import). - V2SetupScriptCard sidebar CTA with a per-machine zustand+persist dismissals store, gated on the active v2 workspace route. v1 paths intentionally untouched. * test(host-service): cover v2 setup config loader, router, terminal resolver Adds integration tests for the v2 setup/teardown work: - config loader: malformed JSON, mixed-type rejection, override precedence, before/after/replace overlay, three-layer stacking, empty-array override clearing base, partial-keys passthrough, path-traversal guard, no worktree consultation. - config router (in-memory drizzle): getConfigContent / updateConfig (preserves run + unrelated keys, overwrites malformed) / shouldShowSetupCard (run-only, teardown-only, all-empty cases). - resolveInitialCommand: && joining, fallback to setup.sh, single-quote escape, config-wins-over-fallback, whitespace filter. Adds optional `homeDir` parameter to loadSetupConfig and resolveInitialCommand to allow test sandboxes without process.env mutation. Production callers don't pass it (defaults to os.homedir()). --- .../DashboardSidebar/DashboardSidebar.tsx | 34 ++ .../V2SetupScriptCard/V2SetupScriptCard.tsx | 64 ++++ .../components/V2SetupScriptCard/index.ts | 1 + .../V2ProjectSettings/V2ProjectSettings.tsx | 10 + .../V2ScriptsEditor/V2ScriptsEditor.tsx | 361 ++++++++++++++++++ .../components/V2ScriptsEditor/index.ts | 1 + .../stores/v2-setup-card-dismissals/index.ts | 1 + .../stores/v2-setup-card-dismissals/store.ts | 34 ++ .../src/runtime/setup/config.test.ts | 322 ++++++++++++++++ .../host-service/src/runtime/setup/config.ts | 222 +++++++++++ .../src/trpc/router/config/config.test.ts | 249 ++++++++++++ .../src/trpc/router/config/config.ts | 125 ++++++ .../src/trpc/router/config/index.ts | 1 + .../host-service/src/trpc/router/router.ts | 2 + .../shared/setup-terminal.test.ts | 142 +++++++ .../shared/setup-terminal.ts | 146 ++++--- .../src/trpc/router/workspaces/workspaces.ts | 31 +- plans/20260505-setup-teardown-scripts-v2.md | 228 +++++++++++ 18 files changed, 1898 insertions(+), 76 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/V2SetupScriptCard.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/V2ScriptsEditor.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/V2ScriptsEditor/index.ts create mode 100644 apps/desktop/src/renderer/stores/v2-setup-card-dismissals/index.ts create mode 100644 apps/desktop/src/renderer/stores/v2-setup-card-dismissals/store.ts create mode 100644 packages/host-service/src/runtime/setup/config.test.ts create mode 100644 packages/host-service/src/runtime/setup/config.ts create mode 100644 packages/host-service/src/trpc/router/config/config.test.ts create mode 100644 packages/host-service/src/trpc/router/config/config.ts create mode 100644 packages/host-service/src/trpc/router/config/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.test.ts create mode 100644 plans/20260505-setup-teardown-scripts-v2.md diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index d52c7d9db3f..6a42c95a781 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -18,14 +18,17 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useMatchRoute } from "@tanstack/react-router"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext"; +import { V2SetupScriptCard } from "./components/V2SetupScriptCard"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; @@ -91,6 +94,10 @@ export function DashboardSidebar({ useDashboardSidebarData(); const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups); const { reorderProjects } = useDashboardSidebarState(); + const matchRoute = useMatchRoute(); + const { activeHostUrl } = useLocalHostService(); + const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" }); + const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), @@ -120,6 +127,26 @@ export function DashboardSidebar({ .filter((g): g is DashboardSidebarProject => g != null); }, [groups, projectOrder]); + const activeV2Project = useMemo(() => { + if (!activeV2WorkspaceId) return null; + for (const project of groups) { + for (const child of project.children) { + if ( + child.type === "workspace" && + child.workspace.id === activeV2WorkspaceId + ) { + return project; + } + if (child.type === "section") { + for (const ws of child.section.workspaces) { + if (ws.id === activeV2WorkspaceId) return project; + } + } + } + } + return null; + }, [groups, activeV2WorkspaceId]); + const handleDragEnd = useCallback( ({ active, over }: DragEndEvent) => { if (over && active.id !== over.id) { @@ -194,6 +221,13 @@ export function DashboardSidebar({
{!isCollapsed && } + {!isCollapsed && activeV2Project && activeHostUrl && ( + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/V2SetupScriptCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/V2SetupScriptCard.tsx new file mode 100644 index 00000000000..9fe038fac4b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/V2SetupScriptCard.tsx @@ -0,0 +1,64 @@ +import { SidebarCard } from "@superset/ui/sidebar-card"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useV2SetupCardDismissalsStore } from "renderer/stores/v2-setup-card-dismissals"; + +interface V2SetupScriptCardProps { + hostUrl: string; + projectId: string; + projectName: string; + isCollapsed?: boolean; +} + +export function V2SetupScriptCard({ + hostUrl, + projectId, + projectName, + isCollapsed, +}: V2SetupScriptCardProps) { + const navigate = useNavigate(); + const isDismissed = useV2SetupCardDismissalsStore((s) => + s.isDismissed(projectId), + ); + const dismiss = useV2SetupCardDismissalsStore((s) => s.dismiss); + + const { data: shouldShow } = useQuery({ + queryKey: ["host-config", "shouldShowSetupCard", hostUrl, projectId], + queryFn: () => + getHostServiceClientByUrl(hostUrl).config.shouldShowSetupCard.query({ + projectId, + }), + refetchOnWindowFocus: true, + }); + + if (isCollapsed || isDismissed || !shouldShow) return null; + + return ( + + + + navigate({ + to: "/settings/projects/$projectId", + params: { projectId }, + }) + } + onDismiss={() => dismiss(projectId)} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts new file mode 100644 index 00000000000..6e034e14506 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/V2SetupScriptCard/index.ts @@ -0,0 +1 @@ +export { V2SetupScriptCard } from "./V2SetupScriptCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index 5358a0a76f8..1c4da7504fe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -9,6 +9,7 @@ import { ProjectSettingsHeader } from "../../../../project/$projectId/components import { DeleteProjectSection } from "./components/DeleteProjectSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; import { RepositorySection } from "./components/RepositorySection"; +import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; interface V2ProjectSettingsProps { projectId: string; @@ -67,6 +68,15 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { /> + {activeHostUrl && ( + + + + )} + typeof s === "string") + : []; + const teardown = Array.isArray(parsed?.teardown) + ? parsed.teardown.filter( + (s: unknown): s is string => typeof s === "string", + ) + : []; + return { + setup: setup.join("\n"), + teardown: teardown.join("\n"), + }; + } catch { + return { setup: "", teardown: "" }; + } +} + +function toCommandsArray(value: string): string[] { + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +type SaveStatus = "idle" | "saving" | "saved"; + +export function V2ScriptsEditor({ + hostUrl, + projectId, + className, +}: V2ScriptsEditorProps) { + const queryClient = useQueryClient(); + + const configQueryKey = [ + "host-config", + "getConfigContent", + hostUrl, + projectId, + ]; + + const { data: configData, isLoading } = useQuery({ + queryKey: configQueryKey, + queryFn: () => + getHostServiceClientByUrl(hostUrl).config.getConfigContent.query({ + projectId, + }), + }); + + const [setupValue, setSetupValue] = useState(""); + const [teardownValue, setTeardownValue] = useState(""); + const [saveStatus, setSaveStatus] = useState("idle"); + const focusedRef = useRef<"setup" | "teardown" | null>(null); + const lastSavedRef = useRef<{ setup: string[]; teardown: string[] }>({ + setup: [], + teardown: [], + }); + const savedTimerRef = useRef(null); + + useEffect(() => { + // Don't clobber an in-progress edit when the server-side query refetches. + if (focusedRef.current) return; + const parsed = parseConfigContent(configData?.content ?? null); + setSetupValue(parsed.setup); + setTeardownValue(parsed.teardown); + lastSavedRef.current = { + setup: toCommandsArray(parsed.setup), + teardown: toCommandsArray(parsed.teardown), + }; + }, [configData?.content]); + + useEffect(() => { + return () => { + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, []); + + const updateMutation = useMutation({ + mutationFn: (input: { + projectId: string; + setup: string[]; + teardown: string[]; + }) => getHostServiceClientByUrl(hostUrl).config.updateConfig.mutate(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: configQueryKey }); + }, + }); + + const flushSave = useCallback( + async (next: { setup: string[]; teardown: string[] }) => { + if ( + arraysEqual(next.setup, lastSavedRef.current.setup) && + arraysEqual(next.teardown, lastSavedRef.current.teardown) + ) { + return; + } + + if (savedTimerRef.current) { + clearTimeout(savedTimerRef.current); + savedTimerRef.current = null; + } + + setSaveStatus("saving"); + try { + await updateMutation.mutateAsync({ projectId, ...next }); + lastSavedRef.current = next; + setSaveStatus("saved"); + savedTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedTimerRef.current = null; + }, 2000); + } catch (error) { + console.error("[v2-scripts/save] failed", error); + setSaveStatus("idle"); + } + }, + [projectId, updateMutation], + ); + + const handleBlur = useCallback( + async (field: "setup" | "teardown") => { + focusedRef.current = null; + + const trimmedSetup = setupValue + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/^\n+|\n+$/g, ""); + const trimmedTeardown = teardownValue + .split("\n") + .map((line) => line.trim()) + .join("\n") + .replace(/^\n+|\n+$/g, ""); + + if (trimmedSetup !== setupValue) setSetupValue(trimmedSetup); + if (trimmedTeardown !== teardownValue) setTeardownValue(trimmedTeardown); + + await flushSave({ + setup: toCommandsArray(field === "setup" ? trimmedSetup : setupValue), + teardown: toCommandsArray( + field === "teardown" ? trimmedTeardown : teardownValue, + ), + }); + }, + [flushSave, setupValue, teardownValue], + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ {saveStatus === "saving" && ( + + + Saving… + + )} + {saveStatus === "saved" && ( + + + Saved + + )} +
+ +
+ + + + Setup + Teardown + + + { + focusedRef.current = "setup"; + }} + onBlur={() => handleBlur("setup")} + /> + + + { + focusedRef.current = "teardown"; + }} + onBlur={() => handleBlur("teardown")} + /> + + +
+ ); +} + +interface ScriptFieldProps { + field: "setup" | "teardown"; + description: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + onFocus: () => void; + onBlur: () => void; +} + +function ScriptField({ + description, + placeholder, + value, + onChange, + onFocus, + onBlur, +}: ScriptFieldProps) { + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const importFirstFile = useCallback( + async (files: File[]) => { + const scriptFile = files.find((file) => + file.name.match(/\.(sh|bash|zsh|command)$/i), + ); + if (!scriptFile) return; + try { + onChange(await scriptFile.text()); + } catch (error) { + console.error("[v2-scripts/import] failed to read file", error); + } + }, + [onChange], + ); + + return ( +
+

{description}

+ + {/* biome-ignore lint/a11y/useSemanticElements: drop zone wrapper */} +
{ + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }} + onDrop={async (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + await importFirstFile(Array.from(e.dataTransfer.files)); + }} + > +