diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ba0dcfa465e..4676a7d9ed9 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -29,6 +29,18 @@ function getSettings() { return row; } +/** Get presets tagged with a given auto-apply field, falling back to the isDefault preset */ +export function getPresetsForTrigger( + field: "applyOnWorkspaceCreated" | "applyOnNewTab", +) { + const row = getSettings(); + const presets = row.terminalPresets ?? []; + const tagged = presets.filter((p) => p[field]); + if (tagged.length > 0) return tagged; + const defaultPreset = presets.find((p) => p.isDefault); + return defaultPreset ? [defaultPreset] : []; +} + export const createSettingsRouter = () => { return router({ getLastUsedApp: publicProcedure.query(() => { @@ -159,6 +171,54 @@ export const createSettingsRouter = () => { return { success: true }; }), + setPresetAutoApply: publicProcedure + .input( + z.object({ + id: z.string(), + field: z.enum(["applyOnWorkspaceCreated", "applyOnNewTab"]), + enabled: z.boolean(), + }), + ) + .mutation(({ input }) => { + const row = getSettings(); + const presets = row.terminalPresets ?? []; + + const updatedPresets = presets.map((p) => { + if (p.id !== input.id) return p; + + // Migrate legacy isDefault preset to explicit fields on first toggle + const needsMigration = + p.isDefault && + p.applyOnWorkspaceCreated === undefined && + p.applyOnNewTab === undefined; + + const base = needsMigration + ? { + ...p, + isDefault: undefined, + applyOnWorkspaceCreated: true as const, + applyOnNewTab: true as const, + } + : p; + + return { + ...base, + [input.field]: input.enabled ? true : undefined, + }; + }); + + localDb + .insert(settings) + .values({ id: 1, terminalPresets: updatedPresets }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPresets: updatedPresets }, + }) + .run(); + + return { success: true }; + }), + reorderTerminalPresets: publicProcedure .input( z.object({ @@ -206,6 +266,14 @@ export const createSettingsRouter = () => { return presets.find((p) => p.isDefault) ?? null; }), + getWorkspaceCreationPresets: publicProcedure.query(() => + getPresetsForTrigger("applyOnWorkspaceCreated"), + ), + + getNewTabPresets: publicProcedure.query(() => + getPresetsForTrigger("applyOnNewTab"), + ), + getSelectedRingtoneId: publicProcedure.query(() => { const row = getSettings(); const storedId = row.selectedRingtoneId; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts index 0e380dc3e57..ce8780614fe 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts @@ -1,21 +1,13 @@ -import { settings } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; -import { localDb } from "main/lib/local-db"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import type { WorkspaceInitProgress } from "shared/types/workspace-init"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; +import { getPresetsForTrigger } from "../../settings"; import { getProject, getWorkspaceWithRelations } from "../utils/db-helpers"; import { loadSetupConfig } from "../utils/setup"; import { initializeWorkspaceWorktree } from "../utils/workspace-init"; -function getDefaultPreset() { - const row = localDb.select().from(settings).get(); - if (!row) return null; - const presets = row.terminalPresets ?? []; - return presets.find((p) => p.isDefault) ?? null; -} - export const createInitProcedures = () => { return router({ onInitProgress: publicProcedure @@ -120,12 +112,12 @@ export const createInitProcedures = () => { worktreePath: relations.worktree?.path, projectName: project.name, }); - const defaultPreset = getDefaultPreset(); + const defaultPresets = getPresetsForTrigger("applyOnWorkspaceCreated"); return { projectId: project.id, initialCommands: setupConfig?.setup ?? null, - defaultPreset, + defaultPresets, }; }), }); diff --git a/apps/desktop/src/renderer/react-query/presets/index.ts b/apps/desktop/src/renderer/react-query/presets/index.ts index a2c2b888985..b4c340034e2 100644 --- a/apps/desktop/src/renderer/react-query/presets/index.ts +++ b/apps/desktop/src/renderer/react-query/presets/index.ts @@ -48,18 +48,19 @@ function useDeleteTerminalPreset( }); } -function useSetDefaultPreset( +function useSetPresetAutoApply( options?: Parameters< - typeof electronTrpc.settings.setDefaultPreset.useMutation + typeof electronTrpc.settings.setPresetAutoApply.useMutation >[0], ) { const utils = electronTrpc.useUtils(); - return electronTrpc.settings.setDefaultPreset.useMutation({ + return electronTrpc.settings.setPresetAutoApply.useMutation({ ...options, onSuccess: async (...args) => { await utils.settings.getTerminalPresets.invalidate(); - await utils.settings.getDefaultPreset.invalidate(); + await utils.settings.getWorkspaceCreationPresets.invalidate(); + await utils.settings.getNewTabPresets.invalidate(); await options?.onSuccess?.(...args); }, }); @@ -81,31 +82,23 @@ function useReorderTerminalPresets( }); } -/** - * Combined hook for accessing terminal presets with all CRUD operations - * Provides easy access to presets data and mutations from anywhere in the app - */ export function usePresets() { const { data: presets = [], isLoading } = electronTrpc.settings.getTerminalPresets.useQuery(); - const { data: defaultPreset } = - electronTrpc.settings.getDefaultPreset.useQuery(); - const createPreset = useCreateTerminalPreset(); const updatePreset = useUpdateTerminalPreset(); const deletePreset = useDeleteTerminalPreset(); - const setDefaultPreset = useSetDefaultPreset(); + const setPresetAutoApply = useSetPresetAutoApply(); const reorderPresets = useReorderTerminalPresets(); return { presets, - defaultPreset, isLoading, createPreset, updatePreset, deletePreset, - setDefaultPreset, + setPresetAutoApply, reorderPresets, }; } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts index 468761474a1..699bf40dbab 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts @@ -38,7 +38,7 @@ export function useCreateBranchWorkspace( workspaceId: data.workspace.id, projectId: data.projectId, initialCommands: setupData?.initialCommands ?? null, - defaultPreset: setupData?.defaultPreset ?? null, + defaultPresets: setupData?.defaultPresets ?? [], }); // Branch workspaces skip git init, so mark ready immediately to trigger terminal setup diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/start-claude-session.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/start-claude-session.ts index 50ca43bf59e..6c7bb002ab9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/start-claude-session.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/start-claude-session.ts @@ -33,8 +33,7 @@ async function execute( workspaceId: workspace.id, projectId: pending?.projectId ?? workspace.projectId, initialCommands: [...(pending?.initialCommands ?? []), params.command], - // Preserve undefined (signals "fetch from backend") vs null (no preset needed) - defaultPreset: pending ? pending.defaultPreset : null, + defaultPresets: pending?.defaultPresets, }); return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts index df42000bffd..11586832422 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/presets/types.ts @@ -9,6 +9,7 @@ export interface PresetColumnConfig { label: string; placeholder: string; mono?: boolean; + tooltip?: string; } export const PRESET_COLUMNS: PresetColumnConfig[] = [ @@ -20,9 +21,11 @@ export const PRESET_COLUMNS: PresetColumnConfig[] = [ }, { key: "cwd", - label: "CWD", + label: "Directory", placeholder: "e.g. ./src (optional)", mono: true, + tooltip: + "Working directory for the terminal session (relative to workspace root)", }, { key: "commands", 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 8800cdab342..136ad182d72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -1,116 +1,39 @@ -import type { - ExecutionMode, - TerminalLinkBehavior, - TerminalPreset, -} from "@superset/local-db"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@superset/ui/alert-dialog"; -import { Button } from "@superset/ui/button"; -import { Label } from "@superset/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; -import { toast } from "@superset/ui/sonner"; -import { Switch } from "@superset/ui/switch"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - HiOutlineCheck, - HiOutlinePlus, - HiOutlineQuestionMarkCircle, -} from "react-icons/hi2"; -import { - getPresetIcon, - useIsDarkTheme, -} from "renderer/assets/app-icons/preset-icons"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { usePresets } from "renderer/react-query/presets"; -import { - PRESET_COLUMNS, - type PresetColumnKey, -} from "renderer/routes/_authenticated/settings/presets/types"; -import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET } from "shared/constants"; +import type { ReactNode } from "react"; import { isItemVisible, SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; -import { PresetRow } from "./components/PresetRow"; - -interface PresetTemplate { - name: string; - preset: { - name: string; - description: string; - cwd: string; - commands: string[]; - }; -} - -const PRESET_TEMPLATES: PresetTemplate[] = [ - { - name: "codex", - preset: { - name: "codex", - description: "Danger mode: All permissions auto-approved", - cwd: "", - commands: [ - 'codex -c model_reasoning_effort="high" --ask-for-approval never --sandbox danger-full-access -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', - ], - }, - }, - { - name: "claude", - preset: { - name: "claude", - description: "Danger mode: All permissions auto-approved", - cwd: "", - commands: ["claude --dangerously-skip-permissions"], - }, - }, - { - name: "gemini", - preset: { - name: "gemini", - description: "Danger mode: All permissions auto-approved", - cwd: "", - commands: ["gemini --yolo"], - }, - }, - { - name: "cursor-agent", - preset: { - name: "cursor-agent", - description: "Cursor AI agent for terminal-based coding assistance", - cwd: "", - commands: ["cursor-agent"], - }, - }, - { - name: "opencode", - preset: { - name: "opencode", - description: "OpenCode: Open-source AI coding agent", - cwd: "", - commands: ["opencode"], - }, - }, -]; +import { AutoApplyPresetSetting } from "./components/AutoApplyPresetSetting"; +import { LinkBehaviorSetting } from "./components/LinkBehaviorSetting"; +import { PresetsSection } from "./components/PresetsSection"; +import { SessionsSection } from "./components/SessionsSection"; interface TerminalSettingsProps { visibleItems?: SettingItemId[] | null; } +/** + * Renders a list of visible sections with automatic border separators. + * Each section is its own component that owns its data-fetching, + * so query resolutions in one section don't re-render others. + */ +function SectionList({ children }: { children: ReactNode[] }) { + const visibleChildren = children.filter(Boolean); + return ( +
+ {visibleChildren.map((child, i) => ( +
0 ? "pt-6 border-t mt-6" : ""} + > + {child} +
+ ))} +
+ ); +} + export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { const showPresets = isItemVisible( SETTING_ITEM_ID.TERMINAL_PRESETS, @@ -124,385 +47,14 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) { SETTING_ITEM_ID.TERMINAL_AUTO_APPLY_PRESET, visibleItems, ); - const showSessions = isItemVisible( - SETTING_ITEM_ID.TERMINAL_SESSIONS, - visibleItems, - ); const showLinkBehavior = isItemVisible( SETTING_ITEM_ID.TERMINAL_LINK_BEHAVIOR, visibleItems, ); - - const utils = electronTrpc.useUtils(); - const isDark = useIsDarkTheme(); - - // Presets - const { - presets: serverPresets, - isLoading: isLoadingPresets, - createPreset, - updatePreset, - deletePreset, - setDefaultPreset, - reorderPresets, - } = usePresets(); - const [localPresets, setLocalPresets] = - useState(serverPresets); - const presetsContainerRef = useRef(null); - const prevPresetsCountRef = useRef(serverPresets.length); - const serverPresetsRef = useRef(serverPresets); - - useEffect(() => { - serverPresetsRef.current = serverPresets; - }, [serverPresets]); - - useEffect(() => { - setLocalPresets(serverPresets); - - if (serverPresets.length > prevPresetsCountRef.current) { - requestAnimationFrame(() => { - presetsContainerRef.current?.scrollTo({ - top: presetsContainerRef.current.scrollHeight, - behavior: "smooth", - }); - }); - } - prevPresetsCountRef.current = serverPresets.length; - }, [serverPresets]); - - const existingPresetNames = useMemo( - () => new Set(serverPresets.map((p) => p.name)), - [serverPresets], - ); - - const isTemplateAdded = (template: PresetTemplate) => - existingPresetNames.has(template.preset.name); - - const handleCellChange = useCallback( - (rowIndex: number, column: PresetColumnKey, value: string) => { - setLocalPresets((prev) => - prev.map((p, i) => (i === rowIndex ? { ...p, [column]: value } : p)), - ); - }, - [], - ); - - const handleCellBlur = useCallback( - (rowIndex: number, column: PresetColumnKey) => { - setLocalPresets((currentLocal) => { - const preset = currentLocal[rowIndex]; - if (!preset) return currentLocal; - const serverPreset = serverPresetsRef.current.find( - (p) => p.id === preset.id, - ); - if (!serverPreset) return currentLocal; - if (preset[column] === serverPreset[column]) return currentLocal; - - updatePreset.mutate({ - id: preset.id, - patch: { [column]: preset[column] }, - }); - return currentLocal; - }); - }, - [updatePreset], - ); - - const handleCommandsChange = useCallback( - (rowIndex: number, commands: string[]) => { - setLocalPresets((prev) => { - const preset = prev[rowIndex]; - const isDelete = preset && commands.length < preset.commands.length; - const newPresets = prev.map((p, i) => - i === rowIndex ? { ...p, commands } : p, - ); - - // Save immediately on delete since onBlur won't have the updated state yet - if (isDelete && preset) { - updatePreset.mutate({ - id: preset.id, - patch: { commands }, - }); - } - return newPresets; - }); - }, - [updatePreset], - ); - - const handleCommandsBlur = useCallback( - (rowIndex: number) => { - setLocalPresets((currentLocal) => { - const preset = currentLocal[rowIndex]; - if (!preset) return currentLocal; - const serverPreset = serverPresetsRef.current.find( - (p) => p.id === preset.id, - ); - if (!serverPreset) return currentLocal; - if ( - JSON.stringify(preset.commands) === - JSON.stringify(serverPreset.commands) - ) - return currentLocal; - - updatePreset.mutate({ - id: preset.id, - patch: { commands: preset.commands }, - }); - return currentLocal; - }); - }, - [updatePreset], - ); - - const handleExecutionModeChange = useCallback( - (rowIndex: number, mode: ExecutionMode) => { - setLocalPresets((currentLocal) => { - const preset = currentLocal[rowIndex]; - if (!preset) return currentLocal; - - const newPresets = currentLocal.map((p, i) => - i === rowIndex ? { ...p, executionMode: mode } : p, - ); - - updatePreset.mutate({ - id: preset.id, - patch: { executionMode: mode }, - }); - - return newPresets; - }); - }, - [updatePreset], - ); - - const handleAddRow = useCallback(() => { - createPreset.mutate({ - name: "", - cwd: "", - commands: [""], - }); - }, [createPreset]); - - const handleAddTemplate = useCallback( - (template: PresetTemplate) => { - if (existingPresetNames.has(template.preset.name)) return; - createPreset.mutate(template.preset); - }, - [createPreset, existingPresetNames], - ); - - const handleDeleteRow = useCallback( - (rowIndex: number) => { - setLocalPresets((currentLocal) => { - const preset = currentLocal[rowIndex]; - if (preset) { - deletePreset.mutate({ id: preset.id }); - } - return currentLocal; - }); - }, - [deletePreset], - ); - - const handleSetDefault = useCallback( - (presetId: string | null) => { - setDefaultPreset.mutate({ id: presetId }); - }, - [setDefaultPreset], - ); - - const handleLocalReorder = useCallback( - (fromIndex: number, toIndex: number) => { - setLocalPresets((prev) => { - const newPresets = [...prev]; - const [removed] = newPresets.splice(fromIndex, 1); - newPresets.splice(toIndex, 0, removed); - return newPresets; - }); - }, - [], - ); - - const handlePersistReorder = useCallback( - (presetId: string, targetIndex: number) => { - reorderPresets.mutate({ presetId, targetIndex }); - }, - [reorderPresets], - ); - - const { data: daemonSessions } = - electronTrpc.terminal.listDaemonSessions.useQuery(); - const sessions = daemonSessions?.sessions ?? []; - const aliveSessions = useMemo( - () => sessions.filter((session) => session.isAlive), - [sessions], + const showSessions = isItemVisible( + SETTING_ITEM_ID.TERMINAL_SESSIONS, + visibleItems, ); - const sessionsSorted = useMemo(() => { - return [...aliveSessions].sort((a, b) => { - // Attached sessions first, then newest attach time. - if (a.attachedClients !== b.attachedClients) { - return b.attachedClients - a.attachedClients; - } - const aTime = a.lastAttachedAt ? Date.parse(a.lastAttachedAt) : 0; - const bTime = b.lastAttachedAt ? Date.parse(b.lastAttachedAt) : 0; - return bTime - aTime; - }); - }, [aliveSessions]); - - const [confirmKillAllOpen, setConfirmKillAllOpen] = useState(false); - const [confirmClearHistoryOpen, setConfirmClearHistoryOpen] = useState(false); - const [confirmRestartDaemonOpen, setConfirmRestartDaemonOpen] = - useState(false); - const [showSessionList, setShowSessionList] = useState(false); - const [pendingKillSession, setPendingKillSession] = useState<{ - sessionId: string; - workspaceId: string; - } | null>(null); - - // Terminal link behavior setting - const { data: terminalLinkBehavior, isLoading: isLoadingLinkBehavior } = - electronTrpc.settings.getTerminalLinkBehavior.useQuery(); - - const setTerminalLinkBehavior = - electronTrpc.settings.setTerminalLinkBehavior.useMutation({ - onMutate: async ({ behavior }) => { - await utils.settings.getTerminalLinkBehavior.cancel(); - const previous = utils.settings.getTerminalLinkBehavior.getData(); - utils.settings.getTerminalLinkBehavior.setData(undefined, behavior); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getTerminalLinkBehavior.setData( - undefined, - context.previous, - ); - } - }, - onSettled: () => { - utils.settings.getTerminalLinkBehavior.invalidate(); - }, - }); - - const handleLinkBehaviorChange = (value: string) => { - setTerminalLinkBehavior.mutate({ - behavior: value as TerminalLinkBehavior, - }); - }; - - // Auto-apply default preset setting - const { data: autoApplyDefaultPreset, isLoading: isLoadingAutoApply } = - electronTrpc.settings.getAutoApplyDefaultPreset.useQuery(); - - const setAutoApplyDefaultPreset = - electronTrpc.settings.setAutoApplyDefaultPreset.useMutation({ - onMutate: async ({ enabled }) => { - await utils.settings.getAutoApplyDefaultPreset.cancel(); - const previous = utils.settings.getAutoApplyDefaultPreset.getData(); - utils.settings.getAutoApplyDefaultPreset.setData(undefined, enabled); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getAutoApplyDefaultPreset.setData( - undefined, - context.previous, - ); - } - }, - onSettled: () => { - utils.settings.getAutoApplyDefaultPreset.invalidate(); - }, - }); - - const handleAutoApplyToggle = (enabled: boolean) => { - setAutoApplyDefaultPreset.mutate({ enabled }); - }; - - const killAllDaemonSessions = - electronTrpc.terminal.killAllDaemonSessions.useMutation({ - onMutate: async () => { - await utils.terminal.listDaemonSessions.cancel(); - const previous = utils.terminal.listDaemonSessions.getData(); - utils.terminal.listDaemonSessions.setData(undefined, { - sessions: [], - }); - return { previous }; - }, - onSuccess: (result) => { - if (result.remainingCount > 0) { - toast.warning("Some sessions could not be killed", { - description: `${result.killedCount} terminated, ${result.remainingCount} remaining`, - }); - } else { - toast.success("Killed all terminal sessions", { - description: `${result.killedCount} sessions terminated`, - }); - } - }, - onError: (error, _vars, context) => { - if (context?.previous) { - utils.terminal.listDaemonSessions.setData( - undefined, - context.previous, - ); - } - toast.error("Failed to kill sessions", { - description: error.message, - }); - }, - onSettled: () => { - setTimeout(() => { - utils.terminal.listDaemonSessions.invalidate(); - }, 300); - }, - }); - - const clearTerminalHistory = - electronTrpc.terminal.clearTerminalHistory.useMutation({ - onSuccess: () => { - toast.success("Cleared terminal history"); - utils.terminal.listDaemonSessions.invalidate(); - }, - onError: (error) => { - toast.error("Failed to clear terminal history", { - description: error.message, - }); - }, - }); - - const killDaemonSession = electronTrpc.terminal.kill.useMutation({ - onSuccess: () => { - toast.success("Killed terminal session"); - utils.terminal.listDaemonSessions.invalidate(); - }, - onError: (error) => { - toast.error("Failed to kill session", { - description: error.message, - }); - }, - }); - - const restartDaemon = electronTrpc.terminal.restartDaemon.useMutation({ - onSuccess: () => { - toast.success("Daemon restarted", { - description: - "Terminal daemon has been restarted. Open a terminal to spawn a fresh daemon.", - }); - utils.terminal.listDaemonSessions.invalidate(); - }, - onError: (error) => { - toast.error("Failed to restart daemon", { - description: error.message, - }); - }, - }); - - const formatTimestamp = (value?: string) => { - if (!value) return "—"; - return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); - }; return (
@@ -513,536 +65,18 @@ export function TerminalSettings({ visibleItems }: TerminalSettingsProps) {

-
- {/* Presets Section */} + {(showPresets || showQuickAdd) && ( -
-
-
- -

- Presets let you quickly launch terminals with pre-configured - commands. -

-
- {showPresets && ( - - )} -
- - {showQuickAdd && ( -
- - Quick add: - - {PRESET_TEMPLATES.map((template) => { - const alreadyAdded = isTemplateAdded(template); - const presetIcon = getPresetIcon(template.name, isDark); - return ( - - - - - - {alreadyAdded - ? "Already added" - : template.preset.description} - - - ); - })} -
- )} - - {showPresets && ( -
-
-
- {PRESET_COLUMNS.map((column) => ( -
- {column.label} -
- ))} - - -
- Mode - -
-
- -

Execution Mode

-

- Sequential: Commands run one after - another in a single terminal (joined with &&) -

-

- Parallel: Each command runs in its own - split pane within a single tab -

-
-
-
- Actions -
-
- -
- {isLoadingPresets ? ( -
- Loading presets... -
- ) : localPresets.length > 0 ? ( - localPresets.map((preset, index) => ( - - )) - ) : ( -
- No presets yet. Click "Add Preset" to create your first - preset. -
- )} -
-
- )} -
+ )} - - {showAutoApplyPreset && ( -
-
- -

- Automatically apply your default preset when creating new - workspaces -

-
- -
- )} - - {showLinkBehavior && ( -
-
- -

- Choose how to open file paths when Cmd+clicking in the terminal -

-
- -
- )} - - {showSessions && ( -
-
-
- - -
-

- Daemon sessions running: {aliveSessions.length} -

- {aliveSessions.length >= 20 && ( -

- Large numbers of persistent terminals can increase CPU/memory - usage. Consider killing old sessions if you notice slowdowns. -

- )} -
- -
- - - - -
- - {showSessionList && aliveSessions.length > 0 && ( -
-
- - - - - - - - - - - - - {sessionsSorted.map((session) => ( - - - - - - - - - ))} - -
- Workspace - - Session - - Clients - - PID - - Last attached - - Action -
- {session.workspaceId} - - {session.sessionId} - - {session.attachedClients} - - {session.pid ?? "—"} - - {formatTimestamp(session.lastAttachedAt)} - - -
-
-
- )} -
- )} -
- - - - - - Kill all terminal sessions? - - -
- - This will terminate all persistent terminal processes (builds, - tests, agents, etc.). - - - You can't undo this action. Terminal panes will show "Process - exited" and can be restarted. - -
-
-
- - - - -
-
- - - - - - Clear terminal history? - - -
- - This deletes the saved scrollback used for reboot/crash - recovery. - - - Running terminal processes continue, but older output may no - longer be available after restarting the app. - -
-
-
- - - - -
-
- - { - if (!open) setPendingKillSession(null); - }} - > - - - - Kill terminal session? - - -
- - This will terminate the session and its underlying process. - - {pendingKillSession && ( - - {pendingKillSession.workspaceId} /{" "} - {pendingKillSession.sessionId} - - )} -
-
-
- - - - -
-
- - - - - - Restart terminal daemon? - - -
- - This will shut down the terminal daemon process and kill all - running sessions. Use this to fix terminals that are stuck or - unresponsive. - - - A fresh daemon will start automatically when you open a new - terminal. - -
-
-
- - - - -
-
+ {showAutoApplyPreset && } + {showLinkBehavior && } + {showSessions && } +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/AutoApplyPresetSetting.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/AutoApplyPresetSetting.tsx new file mode 100644 index 00000000000..4f69fe7142d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/AutoApplyPresetSetting.tsx @@ -0,0 +1,54 @@ +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET } from "shared/constants"; + +export function AutoApplyPresetSetting() { + const utils = electronTrpc.useUtils(); + + const { data: autoApplyDefaultPreset, isLoading } = + electronTrpc.settings.getAutoApplyDefaultPreset.useQuery(); + + const setAutoApplyDefaultPreset = + electronTrpc.settings.setAutoApplyDefaultPreset.useMutation({ + onMutate: async ({ enabled }) => { + await utils.settings.getAutoApplyDefaultPreset.cancel(); + const previous = utils.settings.getAutoApplyDefaultPreset.getData(); + utils.settings.getAutoApplyDefaultPreset.setData(undefined, enabled); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getAutoApplyDefaultPreset.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getAutoApplyDefaultPreset.invalidate(); + }, + }); + + return ( +
+
+ +

+ Automatically apply the workspace creation preset when creating new + workspaces +

+
+ + setAutoApplyDefaultPreset.mutate({ enabled }) + } + disabled={isLoading || setAutoApplyDefaultPreset.isPending} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/LinkBehaviorSetting.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/LinkBehaviorSetting.tsx new file mode 100644 index 00000000000..963a2157f40 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/LinkBehaviorSetting.tsx @@ -0,0 +1,68 @@ +import type { TerminalLinkBehavior } from "@superset/local-db"; +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function LinkBehaviorSetting() { + const utils = electronTrpc.useUtils(); + + const { data: terminalLinkBehavior, isLoading } = + electronTrpc.settings.getTerminalLinkBehavior.useQuery(); + + const setTerminalLinkBehavior = + electronTrpc.settings.setTerminalLinkBehavior.useMutation({ + onMutate: async ({ behavior }) => { + await utils.settings.getTerminalLinkBehavior.cancel(); + const previous = utils.settings.getTerminalLinkBehavior.getData(); + utils.settings.getTerminalLinkBehavior.setData(undefined, behavior); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getTerminalLinkBehavior.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getTerminalLinkBehavior.invalidate(); + }, + }); + + return ( +
+
+ +

+ Choose how to open file paths when Cmd+clicking in the 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 785d5e298db..d959a374295 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx @@ -1,5 +1,5 @@ import { EXECUTION_MODES, type ExecutionMode } from "@superset/local-db"; -import { Button } from "@superset/ui/button"; +import { Checkbox } from "@superset/ui/checkbox"; import { Input } from "@superset/ui/input"; import { Select, @@ -8,10 +8,8 @@ import { SelectTrigger, SelectValue, } from "@superset/ui/select"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; -import { HiOutlineStar, HiStar } from "react-icons/hi2"; import { LuGripVertical, LuTrash } from "react-icons/lu"; import { PRESET_COLUMNS, @@ -67,6 +65,8 @@ function PresetCell({ ); } +type AutoApplyField = "applyOnWorkspaceCreated" | "applyOnNewTab"; + interface PresetRowProps { preset: TerminalPreset; rowIndex: number; @@ -77,7 +77,11 @@ interface PresetRowProps { onCommandsBlur: (rowIndex: number) => void; onExecutionModeChange: (rowIndex: number, mode: ExecutionMode) => void; onDelete: (rowIndex: number) => void; - onSetDefault: (presetId: string | null) => void; + onToggleAutoApply: ( + presetId: string, + field: AutoApplyField, + enabled: boolean, + ) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; onPersistReorder: (presetId: string, targetIndex: number) => void; } @@ -92,7 +96,7 @@ export function PresetRow({ onCommandsBlur, onExecutionModeChange, onDelete, - onSetDefault, + onToggleAutoApply, onLocalReorder, onPersistReorder, }: PresetRowProps) { @@ -125,12 +129,17 @@ export function PresetRow({ }, }); - preview(drop(rowRef)); - drag(dragHandleRef); + useEffect(() => { + preview(drop(rowRef)); + drag(dragHandleRef); + }, [preview, drop, drag]); - const handleToggleDefault = () => { - onSetDefault(preset.isDefault ? null : preset.id); - }; + const isWorkspaceCreation = + preset.applyOnWorkspaceCreated || + (!preset.applyOnNewTab && preset.isDefault); + const isNewTab = + preset.applyOnNewTab || + (!preset.applyOnWorkspaceCreated && preset.isDefault); return (
-
- - - - - - {preset.isDefault - ? "Remove as default" - : "Set as default for new terminals"} - - -
+
+ + onToggleAutoApply(preset.id, "applyOnNewTab", checked === true) + } + /> +
+
+ + +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection.tsx new file mode 100644 index 00000000000..76d939614e9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection.tsx @@ -0,0 +1,458 @@ +import type { ExecutionMode, TerminalPreset } from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + HiOutlineCheck, + HiOutlinePlus, + HiOutlineQuestionMarkCircle, +} from "react-icons/hi2"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { usePresets } from "renderer/react-query/presets"; +import { + PRESET_COLUMNS, + type PresetColumnKey, +} from "renderer/routes/_authenticated/settings/presets/types"; +import { PresetRow } from "./PresetRow"; + +interface PresetTemplate { + name: string; + preset: { + name: string; + description: string; + cwd: string; + commands: string[]; + }; +} + +const PRESET_TEMPLATES: PresetTemplate[] = [ + { + name: "codex", + preset: { + name: "codex", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: [ + 'codex -c model_reasoning_effort="high" --ask-for-approval never --sandbox danger-full-access -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', + ], + }, + }, + { + name: "claude", + preset: { + name: "claude", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: ["claude --dangerously-skip-permissions"], + }, + }, + { + name: "gemini", + preset: { + name: "gemini", + description: "Danger mode: All permissions auto-approved", + cwd: "", + commands: ["gemini --yolo"], + }, + }, + { + name: "cursor-agent", + preset: { + name: "cursor-agent", + description: "Cursor AI agent for terminal-based coding assistance", + cwd: "", + commands: ["cursor-agent"], + }, + }, + { + name: "opencode", + preset: { + name: "opencode", + description: "OpenCode: Open-source AI coding agent", + cwd: "", + commands: ["opencode"], + }, + }, +]; + +interface PresetsSectionProps { + showPresets: boolean; + showQuickAdd: boolean; +} + +export function PresetsSection({ + showPresets, + showQuickAdd, +}: PresetsSectionProps) { + const isDark = useIsDarkTheme(); + const { + presets: serverPresets, + isLoading: isLoadingPresets, + createPreset, + updatePreset, + deletePreset, + setPresetAutoApply, + reorderPresets, + } = usePresets(); + + const [localPresets, setLocalPresets] = + useState(serverPresets); + const presetsContainerRef = useRef(null); + const prevPresetsCountRef = useRef(serverPresets.length); + const serverPresetsRef = useRef(serverPresets); + + useEffect(() => { + serverPresetsRef.current = serverPresets; + }, [serverPresets]); + + useEffect(() => { + setLocalPresets(serverPresets); + + if (serverPresets.length > prevPresetsCountRef.current) { + requestAnimationFrame(() => { + presetsContainerRef.current?.scrollTo({ + top: presetsContainerRef.current.scrollHeight, + behavior: "smooth", + }); + }); + } + prevPresetsCountRef.current = serverPresets.length; + }, [serverPresets]); + + const existingPresetNames = useMemo( + () => new Set(serverPresets.map((p) => p.name)), + [serverPresets], + ); + + const isTemplateAdded = (template: PresetTemplate) => + existingPresetNames.has(template.preset.name); + + const handleCellChange = useCallback( + (rowIndex: number, column: PresetColumnKey, value: string) => { + setLocalPresets((prev) => + prev.map((p, i) => (i === rowIndex ? { ...p, [column]: value } : p)), + ); + }, + [], + ); + + const handleCellBlur = useCallback( + (rowIndex: number, column: PresetColumnKey) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (p) => p.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if (preset[column] === serverPreset[column]) return currentLocal; + + updatePreset.mutate({ + id: preset.id, + patch: { [column]: preset[column] }, + }); + return currentLocal; + }); + }, + [updatePreset], + ); + + const handleCommandsChange = useCallback( + (rowIndex: number, commands: string[]) => { + setLocalPresets((prev) => { + const preset = prev[rowIndex]; + const isDelete = preset && commands.length < preset.commands.length; + const newPresets = prev.map((p, i) => + i === rowIndex ? { ...p, commands } : p, + ); + + if (isDelete && preset) { + updatePreset.mutate({ + id: preset.id, + patch: { commands }, + }); + } + return newPresets; + }); + }, + [updatePreset], + ); + + const handleCommandsBlur = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (p) => p.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if ( + JSON.stringify(preset.commands) === + JSON.stringify(serverPreset.commands) + ) + return currentLocal; + + updatePreset.mutate({ + id: preset.id, + patch: { commands: preset.commands }, + }); + return currentLocal; + }); + }, + [updatePreset], + ); + + const handleExecutionModeChange = useCallback( + (rowIndex: number, mode: ExecutionMode) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + + const newPresets = currentLocal.map((p, i) => + i === rowIndex ? { ...p, executionMode: mode } : p, + ); + + updatePreset.mutate({ + id: preset.id, + patch: { executionMode: mode }, + }); + + return newPresets; + }); + }, + [updatePreset], + ); + + const handleAddRow = useCallback(() => { + createPreset.mutate({ + name: "", + cwd: "", + commands: [""], + }); + }, [createPreset]); + + const handleAddTemplate = useCallback( + (template: PresetTemplate) => { + if (existingPresetNames.has(template.preset.name)) return; + createPreset.mutate(template.preset); + }, + [createPreset, existingPresetNames], + ); + + const handleDeleteRow = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (preset) { + deletePreset.mutate({ id: preset.id }); + } + return currentLocal; + }); + }, + [deletePreset], + ); + + const handleToggleAutoApply = useCallback( + ( + presetId: string, + field: "applyOnWorkspaceCreated" | "applyOnNewTab", + enabled: boolean, + ) => { + setPresetAutoApply.mutate({ id: presetId, field, enabled }); + }, + [setPresetAutoApply], + ); + + const handleLocalReorder = useCallback( + (fromIndex: number, toIndex: number) => { + setLocalPresets((prev) => { + const newPresets = [...prev]; + const [removed] = newPresets.splice(fromIndex, 1); + newPresets.splice(toIndex, 0, removed); + return newPresets; + }); + }, + [], + ); + + const handlePersistReorder = useCallback( + (presetId: string, targetIndex: number) => { + reorderPresets.mutate({ presetId, targetIndex }); + }, + [reorderPresets], + ); + + return ( +
+
+
+ +

+ Presets let you quickly launch terminals with pre-configured + commands. +

+
+ {showPresets && ( + + )} +
+ + {showQuickAdd && ( +
+ + Quick add: + + {PRESET_TEMPLATES.map((template) => { + const alreadyAdded = isTemplateAdded(template); + const presetIcon = getPresetIcon(template.name, isDark); + return ( + + + + + + {alreadyAdded ? "Already added" : template.preset.description} + + + ); + })} +
+ )} + + {showPresets && ( +
+
+
+ {PRESET_COLUMNS.map((column) => + column.tooltip ? ( + + +
+ {column.label} + +
+
+ + {column.tooltip} + +
+ ) : ( +
+ {column.label} +
+ ), + )} + + +
+ Mode + +
+
+ +

Execution Mode

+

+ Sequential: Commands run one after another in + a single terminal (joined with &&) +

+

+ Parallel: Each command runs in its own split + pane within a single tab +

+
+
+ + +
+ Workspace + +
+
+ + Auto-run this preset when creating a new workspace + +
+ + +
+ Tab + +
+
+ + Auto-run this preset when opening a new tab + +
+
+
+ +
+ {isLoadingPresets ? ( +
+ Loading presets... +
+ ) : localPresets.length > 0 ? ( + localPresets.map((preset, index) => ( + + )) + ) : ( +
+ No presets yet. Click "Add Preset" to create your first preset. +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SessionsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SessionsSection.tsx new file mode 100644 index 00000000000..36dda370f0f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SessionsSection.tsx @@ -0,0 +1,442 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function SessionsSection() { + const utils = electronTrpc.useUtils(); + + const { data: daemonSessions } = + electronTrpc.terminal.listDaemonSessions.useQuery(); + const sessions = daemonSessions?.sessions ?? []; + const aliveSessions = useMemo( + () => sessions.filter((session) => session.isAlive), + [sessions], + ); + const sessionsSorted = useMemo(() => { + return [...aliveSessions].sort((a, b) => { + if (a.attachedClients !== b.attachedClients) { + return b.attachedClients - a.attachedClients; + } + const aTime = a.lastAttachedAt ? Date.parse(a.lastAttachedAt) : 0; + const bTime = b.lastAttachedAt ? Date.parse(b.lastAttachedAt) : 0; + return bTime - aTime; + }); + }, [aliveSessions]); + + const [confirmKillAllOpen, setConfirmKillAllOpen] = useState(false); + const [confirmClearHistoryOpen, setConfirmClearHistoryOpen] = useState(false); + const [confirmRestartDaemonOpen, setConfirmRestartDaemonOpen] = + useState(false); + const [showSessionList, setShowSessionList] = useState(false); + const [pendingKillSession, setPendingKillSession] = useState<{ + sessionId: string; + workspaceId: string; + } | null>(null); + + const killAllDaemonSessions = + electronTrpc.terminal.killAllDaemonSessions.useMutation({ + onMutate: async () => { + await utils.terminal.listDaemonSessions.cancel(); + const previous = utils.terminal.listDaemonSessions.getData(); + utils.terminal.listDaemonSessions.setData(undefined, { + sessions: [], + }); + return { previous }; + }, + onSuccess: (result) => { + if (result.remainingCount > 0) { + toast.warning("Some sessions could not be killed", { + description: `${result.killedCount} terminated, ${result.remainingCount} remaining`, + }); + } else { + toast.success("Killed all terminal sessions", { + description: `${result.killedCount} sessions terminated`, + }); + } + }, + onError: (error, _vars, context) => { + if (context?.previous) { + utils.terminal.listDaemonSessions.setData( + undefined, + context.previous, + ); + } + toast.error("Failed to kill sessions", { + description: error.message, + }); + }, + onSettled: () => { + setTimeout(() => { + utils.terminal.listDaemonSessions.invalidate(); + }, 300); + }, + }); + + const clearTerminalHistory = + electronTrpc.terminal.clearTerminalHistory.useMutation({ + onSuccess: () => { + toast.success("Cleared terminal history"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to clear terminal history", { + description: error.message, + }); + }, + }); + + const killDaemonSession = electronTrpc.terminal.kill.useMutation({ + onSuccess: () => { + toast.success("Killed terminal session"); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to kill session", { + description: error.message, + }); + }, + }); + + const restartDaemon = electronTrpc.terminal.restartDaemon.useMutation({ + onSuccess: () => { + toast.success("Daemon restarted", { + description: + "Terminal daemon has been restarted. Open a terminal to spawn a fresh daemon.", + }); + utils.terminal.listDaemonSessions.invalidate(); + }, + onError: (error) => { + toast.error("Failed to restart daemon", { + description: error.message, + }); + }, + }); + + const formatTimestamp = (value?: string) => { + if (!value) return "—"; + return value.replace("T", " ").replace(/\.\d+Z$/, "Z"); + }; + + return ( + <> +
+
+
+ + +
+

+ Daemon sessions running: {aliveSessions.length} +

+ {aliveSessions.length >= 20 && ( +

+ Large numbers of persistent terminals can increase CPU/memory + usage. Consider killing old sessions if you notice slowdowns. +

+ )} +
+ +
+ + + + +
+ + {showSessionList && aliveSessions.length > 0 && ( +
+
+ + + + + + + + + + + + + {sessionsSorted.map((session) => ( + + + + + + + + + ))} + +
+ Workspace + Session + Clients + PID + Last attached + Action
+ {session.workspaceId} + + {session.sessionId} + + {session.attachedClients} + + {session.pid ?? "—"} + + {formatTimestamp(session.lastAttachedAt)} + + +
+
+
+ )} +
+ + + + + + Kill all terminal sessions? + + +
+ + This will terminate all persistent terminal processes (builds, + tests, agents, etc.). + + + You can't undo this action. Terminal panes will show "Process + exited" and can be restarted. + +
+
+
+ + + + +
+
+ + + + + + Clear terminal history? + + +
+ + This deletes the saved scrollback used for reboot/crash + recovery. + + + Running terminal processes continue, but older output may no + longer be available after restarting the app. + +
+
+
+ + + + +
+
+ + { + if (!open) setPendingKillSession(null); + }} + > + + + + Kill terminal session? + + +
+ + This will terminate the session and its underlying process. + + {pendingKillSession && ( + + {pendingKillSession.workspaceId} /{" "} + {pendingKillSession.sessionId} + + )} +
+
+
+ + + + +
+
+ + + + + + Restart terminal daemon? + + +
+ + This will shut down the terminal daemon process and kill all + running sessions. Use this to fix terminals that are stuck or + unresponsive. + + + A fresh daemon will start automatically when you open a new + terminal. + +
+
+
+ + + + +
+
+ + ); +} 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 c9632ae229a..cb611ce0951 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 @@ -405,8 +405,9 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ { id: SETTING_ITEM_ID.TERMINAL_AUTO_APPLY_PRESET, section: "terminal", - title: "Auto-apply Default Preset", - description: "Automatically apply default preset when creating workspaces", + title: "Auto-Apply Default Preset", + description: + "Automatically apply the workspace creation preset when creating new workspaces", keywords: [ "terminal", "preset", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx index ff37c84f56d..2cf37b499d2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx @@ -2,17 +2,14 @@ import { toast } from "@superset/ui/sonner"; import { useCallback, useEffect, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { AddTabWithMultiplePanesOptions } from "renderer/stores/tabs/types"; +import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { type PendingTerminalSetup, useWorkspaceInitStore, } from "renderer/stores/workspace-init"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET } from "shared/constants"; -/** - * Handles terminal setup when workspaces become ready. - * Mounted at app root to survive dialog unmounts. - */ +/** Mounted at app root to survive dialog unmounts. */ export function WorkspaceInitEffects() { const initProgress = useWorkspaceInitStore((s) => s.initProgress); const pendingTerminalSetups = useWorkspaceInitStore( @@ -31,78 +28,29 @@ export function WorkspaceInitEffects() { const processingRef = useRef>(new Set()); const addTab = useTabsStore((state) => state.addTab); - const addPane = useTabsStore((state) => state.addPane); - const addPanesToTab = useTabsStore((state) => state.addPanesToTab); - const addTabWithMultiplePanes = useTabsStore( - (state) => state.addTabWithMultiplePanes, - ); const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); - const renameTab = useTabsStore((state) => state.renameTab); + const { openPreset } = useTabsWithPresets(); const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation(); const utils = electronTrpc.useUtils(); - const createPresetTerminal = useCallback( - ( - workspaceId: string, - preset: NonNullable, - existingTabId?: string, - ) => { - const isParallel = - preset.executionMode === "parallel" && preset.commands.length > 1; - - if (existingTabId) { - if (isParallel) { - addPanesToTab(existingTabId, { - commands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - } else { - addPane(existingTabId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - } - return; - } - - if (isParallel) { - const options: AddTabWithMultiplePanesOptions = { - commands: preset.commands, - initialCwd: preset.cwd || undefined, - }; - const { tabId } = addTabWithMultiplePanes(workspaceId, options); - renameTab(tabId, preset.name); - } else { - const { tabId } = addTab(workspaceId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - renameTab(tabId, preset.name); - } - }, - [addTab, addPane, addPanesToTab, addTabWithMultiplePanes, renameTab], - ); - const handleTerminalSetup = useCallback( (setup: PendingTerminalSetup, onComplete: () => void) => { const hasSetupScript = Array.isArray(setup.initialCommands) && setup.initialCommands.length > 0; - const hasDefaultPreset = - shouldApplyPreset && - setup.defaultPreset != null && - setup.defaultPreset.commands.length > 0; + const presets = (setup.defaultPresets ?? []).filter( + (p) => p.commands.length > 0, + ); + const hasPresets = shouldApplyPreset && presets.length > 0; - if (hasSetupScript && hasDefaultPreset && setup.defaultPreset) { + if (hasSetupScript && hasPresets) { const { tabId: setupTabId, paneId: setupPaneId } = addTab( setup.workspaceId, ); setTabAutoTitle(setupTabId, "Workspace Setup"); - createPresetTerminal( - setup.workspaceId, - setup.defaultPreset, - setupTabId, - ); + for (const preset of presets) { + openPreset(setup.workspaceId, preset); + } createOrAttach.mutate( { @@ -171,26 +119,17 @@ export function WorkspaceInitEffects() { return; } - if ( - shouldApplyPreset && - setup.defaultPreset && - setup.defaultPreset.commands.length > 0 - ) { - createPresetTerminal(setup.workspaceId, setup.defaultPreset); + if (hasPresets) { + for (const preset of presets) { + openPreset(setup.workspaceId, preset); + } onComplete(); return; } - // No setup script or default preset — sidebar card handles the prompt onComplete(); }, - [ - addTab, - setTabAutoTitle, - createOrAttach, - createPresetTerminal, - shouldApplyPreset, - ], + [addTab, setTabAutoTitle, createOrAttach, openPreset, shouldApplyPreset], ); useEffect(() => { @@ -201,7 +140,6 @@ export function WorkspaceInitEffects() { continue; } - // No initProgress means workspace is already initialized — process immediately if (!progress) { processingRef.current.add(workspaceId); handleTerminalSetup(setup, () => { @@ -216,13 +154,13 @@ export function WorkspaceInitEffects() { // Always fetch from backend to ensure we have the latest preset // (client-side preset query may not have resolved when pending setup was created) - if (setup.defaultPreset === undefined) { + if (setup.defaultPresets === undefined) { utils.workspaces.getSetupCommands .fetch({ workspaceId }) .then((setupData) => { const completeSetup: PendingTerminalSetup = { ...setup, - defaultPreset: setupData?.defaultPreset ?? null, + defaultPresets: setupData?.defaultPresets ?? [], }; handleTerminalSetup(completeSetup, () => { removePendingTerminalSetup(workspaceId); @@ -282,7 +220,7 @@ export function WorkspaceInitEffects() { workspaceId, projectId: setupData.projectId, initialCommands: setupData.initialCommands, - defaultPreset: setupData.defaultPreset, + defaultPresets: setupData.defaultPresets ?? [], }; handleTerminalSetup(fetchedSetup, () => { diff --git a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts index 2d082087402..88b82ed8239 100644 --- a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts +++ b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts @@ -1,17 +1,13 @@ import type { TerminalPreset } from "@superset/local-db"; import { useCallback, useMemo } from "react"; import type { MosaicBranch } from "react-mosaic-component"; -import { usePresets } from "renderer/react-query/presets"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "./store"; import type { AddTabOptions } from "./types"; -/** - * Hook that wraps tab store actions with default preset support. - * When a default preset is configured, new terminals will automatically - * use that preset's commands and cwd. - */ export function useTabsWithPresets() { - const { defaultPreset } = usePresets(); + const { data: newTabPresets = [] } = + electronTrpc.settings.getNewTabPresets.useQuery(); const storeAddTab = useTabsStore((s) => s.addTab); const storeAddTabWithMultiplePanes = useTabsStore( @@ -23,20 +19,48 @@ export function useTabsWithPresets() { const storeSplitPaneAuto = useTabsStore((s) => s.splitPaneAuto); const renameTab = useTabsStore((s) => s.renameTab); - const defaultPresetOptions: AddTabOptions | undefined = useMemo(() => { - if (!defaultPreset) return undefined; + const firstPreset = newTabPresets[0] ?? null; + + const firstPresetOptions: AddTabOptions | undefined = useMemo(() => { + if (!firstPreset) return undefined; return { - initialCommands: defaultPreset.commands, - initialCwd: defaultPreset.cwd || undefined, + initialCommands: firstPreset.commands, + initialCwd: firstPreset.cwd || undefined, }; - }, [defaultPreset]); + }, [firstPreset]); + + const openPresetAsTab = useCallback( + (workspaceId: string, preset: TerminalPreset) => { + const isParallel = + preset.executionMode === "parallel" && preset.commands.length > 1; - const shouldUseParallelMode = useMemo(() => { - return ( - defaultPreset?.executionMode === "parallel" && - defaultPreset.commands.length > 1 - ); - }, [defaultPreset]); + let tabId: string; + let paneId: string; + + if (isParallel) { + const result = storeAddTabWithMultiplePanes(workspaceId, { + commands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + tabId = result.tabId; + paneId = result.paneIds[0]; + } else { + const result = storeAddTab(workspaceId, { + initialCommands: preset.commands, + initialCwd: preset.cwd || undefined, + }); + tabId = result.tabId; + paneId = result.paneId; + } + + if (preset.name) { + renameTab(tabId, preset.name); + } + + return { tabId, paneId }; + }, + [storeAddTab, storeAddTabWithMultiplePanes, renameTab], + ); const addTab = useCallback( (workspaceId: string, options?: AddTabOptions) => { @@ -44,43 +68,26 @@ export function useTabsWithPresets() { return storeAddTab(workspaceId, options); } - if (shouldUseParallelMode && defaultPreset) { - const { tabId, paneIds } = storeAddTabWithMultiplePanes(workspaceId, { - commands: defaultPreset.commands, - initialCwd: defaultPreset.cwd || undefined, - }); - - if (defaultPreset.name) { - renameTab(tabId, defaultPreset.name); - } - - return { tabId, paneId: paneIds[0] }; + if (newTabPresets.length === 0) { + return storeAddTab(workspaceId); } - const result = storeAddTab(workspaceId, defaultPresetOptions); - - if (defaultPreset?.name) { - renameTab(result.tabId, defaultPreset.name); + const firstResult = openPresetAsTab(workspaceId, newTabPresets[0]); + for (let i = 1; i < newTabPresets.length; i++) { + openPresetAsTab(workspaceId, newTabPresets[i]); } - return result; + return { tabId: firstResult.tabId, paneId: firstResult.paneId }; }, - [ - storeAddTab, - storeAddTabWithMultiplePanes, - defaultPresetOptions, - defaultPreset, - shouldUseParallelMode, - renameTab, - ], + [storeAddTab, newTabPresets, openPresetAsTab], ); const addPane = useCallback( (tabId: string, options?: AddTabOptions) => { - const effectiveOptions = options ?? defaultPresetOptions; + const effectiveOptions = options ?? firstPresetOptions; return storeAddPane(tabId, effectiveOptions); }, - [storeAddPane, defaultPresetOptions], + [storeAddPane, firstPresetOptions], ); const splitPaneVertical = useCallback( @@ -90,7 +97,7 @@ export function useTabsWithPresets() { path?: MosaicBranch[], options?: AddTabOptions, ) => { - const effectiveOptions = options ?? defaultPresetOptions; + const effectiveOptions = options ?? firstPresetOptions; return storeSplitPaneVertical( tabId, sourcePaneId, @@ -98,7 +105,7 @@ export function useTabsWithPresets() { effectiveOptions, ); }, - [storeSplitPaneVertical, defaultPresetOptions], + [storeSplitPaneVertical, firstPresetOptions], ); const splitPaneHorizontal = useCallback( @@ -108,7 +115,7 @@ export function useTabsWithPresets() { path?: MosaicBranch[], options?: AddTabOptions, ) => { - const effectiveOptions = options ?? defaultPresetOptions; + const effectiveOptions = options ?? firstPresetOptions; return storeSplitPaneHorizontal( tabId, sourcePaneId, @@ -116,7 +123,7 @@ export function useTabsWithPresets() { effectiveOptions, ); }, - [storeSplitPaneHorizontal, defaultPresetOptions], + [storeSplitPaneHorizontal, firstPresetOptions], ); const splitPaneAuto = useCallback( @@ -127,7 +134,7 @@ export function useTabsWithPresets() { path?: MosaicBranch[], options?: AddTabOptions, ) => { - const effectiveOptions = options ?? defaultPresetOptions; + const effectiveOptions = options ?? firstPresetOptions; return storeSplitPaneAuto( tabId, sourcePaneId, @@ -136,31 +143,7 @@ export function useTabsWithPresets() { effectiveOptions, ); }, - [storeSplitPaneAuto, defaultPresetOptions], - ); - - const openPreset = useCallback( - (workspaceId: string, preset: TerminalPreset) => { - const isParallel = - preset.executionMode === "parallel" && preset.commands.length > 1; - - const { tabId } = isParallel - ? storeAddTabWithMultiplePanes(workspaceId, { - commands: preset.commands, - initialCwd: preset.cwd || undefined, - }) - : storeAddTab(workspaceId, { - initialCommands: preset.commands, - initialCwd: preset.cwd || undefined, - }); - - if (preset.name) { - renameTab(tabId, preset.name); - } - - return { tabId }; - }, - [storeAddTab, storeAddTabWithMultiplePanes, renameTab], + [storeSplitPaneAuto, firstPresetOptions], ); return { @@ -169,7 +152,6 @@ export function useTabsWithPresets() { splitPaneVertical, splitPaneHorizontal, splitPaneAuto, - openPreset, - defaultPreset, + openPreset: openPresetAsTab, }; } diff --git a/apps/desktop/src/renderer/stores/workspace-init.ts b/apps/desktop/src/renderer/stores/workspace-init.ts index 25fb88337e4..26aad9b4654 100644 --- a/apps/desktop/src/renderer/stores/workspace-init.ts +++ b/apps/desktop/src/renderer/stores/workspace-init.ts @@ -7,7 +7,8 @@ export interface PendingTerminalSetup { workspaceId: string; projectId: string; initialCommands: string[] | null; - defaultPreset?: TerminalPreset | null; + /** When undefined, signals that presets haven't been fetched yet and should be loaded from the backend */ + defaultPresets?: TerminalPreset[]; } interface WorkspaceInitState { diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index bd91026d78e..be05a5d5e74 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -61,6 +61,8 @@ export const terminalPresetSchema = z.object({ cwd: z.string(), commands: z.array(z.string()), isDefault: z.boolean().optional(), + applyOnWorkspaceCreated: z.boolean().optional(), + applyOnNewTab: z.boolean().optional(), executionMode: z.enum(EXECUTION_MODES).optional(), });