From fc5732439a7818b76f88db310aa5303e7b960525 Mon Sep 17 00:00:00 2001 From: Kiran Kunigiri Date: Sun, 15 Mar 2026 23:41:03 -0700 Subject: [PATCH 1/6] feat(desktop): add workspace run commands Add project-level run command support with a keyboard shortcut (Ctrl+R) to start/stop a dev server from any workspace. - Run command configured in Project Settings > Scripts alongside setup/teardown - Single source of truth: pane.workspaceRun metadata drives all state - Pane reused across restarts and survives app reload - Visual indicators in sidebar (colored bar) and pane toolbar (border + header tint) - Terminal exits when command finishes (uses shell -c, not interactive write) - State persisted via ui-state pane schema for reload recovery --- .../src/lib/trpc/routers/config/config.ts | 2 + .../src/lib/trpc/routers/terminal/terminal.ts | 5 +- .../src/lib/trpc/routers/ui-state/index.ts | 6 + .../routers/workspaces/procedures/query.ts | 51 +++++ .../routers/workspaces/utils/setup.test.ts | 99 ++++++++ .../trpc/routers/workspaces/utils/setup.ts | 8 +- .../src/main/lib/terminal-host/types.ts | 1 + .../lib/terminal/daemon/daemon-manager.ts | 2 + apps/desktop/src/main/lib/terminal/types.ts | 2 + .../src/main/terminal-host/session.test.ts | 70 ++++-- .../desktop/src/main/terminal-host/session.ts | 16 +- .../hooks/useWorkspaceRunCommand.ts | 169 ++++++++++++++ .../workspace/$workspaceId/page.tsx | 10 + .../renderer/routes/_authenticated/layout.tsx | 26 ++- .../ScriptsEditor/ScriptsEditor.tsx | 42 +++- .../WorkspaceRunIndicator.tsx | 94 ++++++++ .../components/WorkspaceRunIndicator/index.ts | 1 + .../WorkspaceListItem/WorkspaceListItem.tsx | 82 ++++--- .../TabsContent/TabView/TabPane.tsx | 8 + .../BasePaneWindow/BasePaneWindow.tsx | 9 +- .../TabsContent/TabView/mosaic-theme.css | 37 +++ .../TabsContent/Terminal/Terminal.tsx | 40 +++- .../Terminal/hooks/useTerminalLifecycle.ts | 213 +++++++++++++++--- .../Terminal/hooks/useTerminalStream.ts | 26 ++- .../ContentView/TabsContent/Terminal/types.ts | 1 + .../desktop/src/renderer/stores/tabs/store.ts | 24 ++ .../stores/tabs/terminal-callbacks.ts | 41 ++++ .../desktop/src/renderer/stores/tabs/types.ts | 7 + apps/desktop/src/shared/hotkeys.ts | 6 + apps/desktop/src/shared/tabs-types.ts | 6 + apps/desktop/src/shared/types/config.ts | 2 + 31 files changed, 997 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/WorkspaceRunIndicator.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts index ef1aa4c688b..8382f089b5d 100644 --- a/apps/desktop/src/lib/trpc/routers/config/config.ts +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -450,6 +450,7 @@ export const createConfigRouter = () => { projectId: z.string(), setup: z.array(z.string()), teardown: z.array(z.string()), + run: z.array(z.string()).optional(), }), ) .mutation(({ input }) => { @@ -482,6 +483,7 @@ export const createConfigRouter = () => { ...existingConfig, setup: input.setup, teardown: input.teardown, + ...(input.run !== undefined && { run: input.run }), }; try { diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9d450745f0b..5819e17b673 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -65,6 +65,7 @@ export const createTerminalRouter = () => { cols: z.number().optional(), rows: z.number().optional(), cwd: z.string().optional(), + command: z.string().trim().min(1).optional(), skipColdRestore: z.boolean().optional(), allowKilled: z.boolean().optional(), themeType: z.enum(["dark", "light"]).optional(), @@ -80,6 +81,7 @@ export const createTerminalRouter = () => { cols, rows, cwd: cwdOverride, + command, skipColdRestore, allowKilled, themeType, @@ -133,7 +135,8 @@ export const createTerminalRouter = () => { cwd, cols, rows, - skipColdRestore, + command, + skipColdRestore: skipColdRestore || !!command, allowKilled, themeType: resolvedThemeType, }); diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 06388583bb6..8786d33d35d 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -86,6 +86,12 @@ const paneSchema = z.object({ targetPaneId: z.string(), }) .optional(), + workspaceRun: z + .object({ + workspaceId: z.string(), + state: z.enum(["running", "stopped-by-user", "stopped-by-exit"]), + }) + .optional(), }); /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index fe676f1018b..2d602501107 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; import { getProjectChildItems } from "../utils/project-children-order"; +import { loadSetupConfig } from "../utils/setup"; import { computeVisualOrder } from "../utils/visual-order"; import { getWorkspacePath } from "../utils/worktree"; @@ -287,5 +288,55 @@ export const createQueryProcedures = () => { : currentIndex + 1; return orderedWorkspaceIds[nextIndex]; }), + + getResolvedRunCommands: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${input.workspaceId} not found`, + }); + } + + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + if (!project) { + return { commands: [] }; + } + + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; + + const worktreePath = + workspace.type === "worktree" && worktree?.path + ? worktree.path + : workspace.type === "branch" + ? project.mainRepoPath + : undefined; + + const config = loadSetupConfig({ + mainRepoPath: project.mainRepoPath, + worktreePath, + projectId: project.id, + }); + + return { + commands: config?.run ?? [], + }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts index 5604d461867..398027d45ad 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.test.ts @@ -529,4 +529,103 @@ describe("mergeConfigs", () => { ); expect(result).toEqual({ setup: ["x"], teardown: ["y"] }); }); + + test("run key override with array", () => { + const result = mergeConfigs( + { run: ["npm run dev"] }, + { run: ["bun run dev"] }, + ); + expect(result).toEqual({ run: ["bun run dev"] }); + }); + + test("run key merge with before/after", () => { + const result = mergeConfigs( + { run: ["npm run dev"] }, + { run: { before: ["echo starting"], after: ["echo done"] } }, + ); + expect(result).toEqual({ + run: ["echo starting", "npm run dev", "echo done"], + }); + }); + + test("run key passes through when not in local config", () => { + const result = mergeConfigs( + { setup: ["install"], run: ["dev"] }, + { setup: ["custom-install"] }, + ); + expect(result).toEqual({ setup: ["custom-install"], run: ["dev"] }); + }); +}); + +describe("run config", () => { + beforeEach(() => { + mkdirSync(join(MAIN_REPO, ".superset"), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("loads run commands from config", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ + setup: ["bun install"], + run: ["bun run dev"], + }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config?.run).toEqual(["bun run dev"]); + }); + + test("returns config without run when run is not set", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ setup: ["bun install"] }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config?.run).toBeUndefined(); + }); + + test("validates run field must be an array", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ run: "not-an-array" }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config).toBeNull(); + }); + + test("local config can override run commands", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ run: ["npm run dev"] }), + ); + writeFileSync( + join(MAIN_REPO, ".superset", "config.local.json"), + JSON.stringify({ run: ["bun run dev"] }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config?.run).toEqual(["bun run dev"]); + }); + + test("local config can merge run commands with before/after", () => { + writeFileSync( + join(MAIN_REPO, ".superset", "config.json"), + JSON.stringify({ run: ["npm run dev"] }), + ); + writeFileSync( + join(MAIN_REPO, ".superset", "config.local.json"), + JSON.stringify({ run: { before: ["export DEBUG=1"] } }), + ); + + const config = loadSetupConfig({ mainRepoPath: MAIN_REPO }); + expect(config?.run).toEqual(["export DEBUG=1", "npm run dev"]); + }); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts index 42cc39f9ab7..ebee741e898 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/setup.ts @@ -49,6 +49,10 @@ function readConfigFile(configPath: string): SetupConfig | null { throw new Error("'teardown' field must be an array of strings"); } + if (parsed.run && !Array.isArray(parsed.run)) { + throw new Error("'run' field must be an array of strings"); + } + return parsed; } catch (error) { console.error( @@ -73,7 +77,7 @@ function readLocalConfigFile(filePath: string): LocalSetupConfig | null { const content = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(content) as LocalSetupConfig; - for (const key of ["setup", "teardown"] as const) { + for (const key of ["setup", "teardown", "run"] as const) { const value = parsed[key]; if (value === undefined) continue; @@ -123,7 +127,7 @@ export function mergeConfigs( ): SetupConfig { const result: SetupConfig = { ...base }; - for (const key of ["setup", "teardown"] as const) { + for (const key of ["setup", "teardown", "run"] as const) { const localValue = local[key]; if (localValue === undefined) continue; diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts index 64bc7cbb26a..a4edb97368a 100644 --- a/apps/desktop/src/main/lib/terminal-host/types.ts +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -167,6 +167,7 @@ export interface CreateOrAttachRequest { workspaceName?: string; workspacePath?: string; rootPath?: string; + command?: string; } export interface CreateOrAttachResponse { diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 10a0b8b43f6..08376552fd7 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -320,6 +320,7 @@ export class DaemonTerminalManager extends EventEmitter { cwd, cols = 80, rows = 24, + command, skipColdRestore, themeType, } = params; @@ -405,6 +406,7 @@ export class DaemonTerminalManager extends EventEmitter { cwd, env, shell, + command, }); this.daemonAliveSessionIds.add(paneId); diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index a3b4cc5922a..88dfd59448d 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -99,6 +99,8 @@ export interface CreateSessionParams { cwd?: string; cols?: number; rows?: number; + /** Command to execute in the terminal instead of starting an interactive shell */ + command?: string; /** Skip cold restore detection (used when auto-resuming after cold restore) */ skipColdRestore?: boolean; /** Allow restarting a session that was explicitly killed */ diff --git a/apps/desktop/src/main/terminal-host/session.test.ts b/apps/desktop/src/main/terminal-host/session.test.ts index 0b6d65d138f..5234d075fa5 100644 --- a/apps/desktop/src/main/terminal-host/session.test.ts +++ b/apps/desktop/src/main/terminal-host/session.test.ts @@ -36,6 +36,23 @@ class FakeChildProcess extends EventEmitter { let fakeChildProcess: FakeChildProcess; let spawnCalls: Array<{ command: string; args: string[] }> = []; +function getSpawnPayload(fakeChild: FakeChildProcess) { + fakeChild.stdout.emit( + "data", + createFrameHeader(PtySubprocessIpcType.Ready, 0), + ); + + const decoder = new PtySubprocessFrameDecoder(); + const frames = fakeChild.stdin.writes.flatMap((chunk) => decoder.push(chunk)); + const spawnFrame = frames.find( + (frame) => frame.type === PtySubprocessIpcType.Spawn, + ); + expect(spawnFrame).toBeDefined(); + return JSON.parse(spawnFrame?.payload.toString("utf8") ?? "{}") as { + args?: string[]; + }; +} + describe("Terminal Host Session shell args", () => { beforeEach(() => { fakeChildProcess = new FakeChildProcess(); @@ -67,27 +84,46 @@ describe("Terminal Host Session shell args", () => { expect(spawnCalls.length).toBe(1); - fakeChildProcess.stdout.emit( - "data", - createFrameHeader(PtySubprocessIpcType.Ready, 0), - ); - - const decoder = new PtySubprocessFrameDecoder(); - const frames = fakeChildProcess.stdin.writes.flatMap((chunk) => - decoder.push(chunk), - ); - const spawnFrame = frames.find( - (frame) => frame.type === PtySubprocessIpcType.Spawn, - ); - - expect(spawnFrame).toBeDefined(); - const spawnPayload = JSON.parse( - spawnFrame?.payload.toString("utf8") ?? "{}", - ) as { args?: string[] }; + const spawnPayload = getSpawnPayload(fakeChildProcess); expect(spawnPayload?.args?.[0]).toBe("--rcfile"); expect(spawnPayload?.args?.[1]?.endsWith(path.join("bash", "rcfile"))).toBe( true, ); }); + + it("uses -lc command args when command is provided", () => { + const session = new Session({ + sessionId: "session-command-args", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/bash", + command: "echo hello && exit 1", + spawnProcess: (command: string, args: readonly string[], _options) => { + spawnCalls.push({ command, args: [...args] }); + return fakeChildProcess as unknown as ChildProcess; + }, + }); + + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + + expect(spawnCalls.length).toBe(1); + + const spawnPayload = getSpawnPayload(fakeChildProcess); + + // Should use -c style args (getCommandShellArgs), not --rcfile (getShellArgs) + expect(spawnPayload?.args?.[0]).not.toBe("--rcfile"); + expect(spawnPayload?.args?.[0]).toMatch(/^-[l]?c$/); + const argsStr = spawnPayload?.args?.join(" ") ?? ""; + expect(argsStr).toContain("echo hello && exit 1"); + }); }); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 27f06a15ff2..a1d4d420ef0 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -12,7 +12,10 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { Socket } from "node:net"; import * as path from "node:path"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; -import { getShellArgs } from "../lib/agent-setup/shell-wrappers"; +import { + getCommandShellArgs, + getShellArgs, +} from "../lib/agent-setup/shell-wrappers"; import { buildSafeEnv } from "../lib/terminal/env"; import { HeadlessEmulator } from "../lib/terminal-host/headless-emulator"; import type { @@ -91,6 +94,7 @@ export interface SessionOptions { workspaceName?: string; workspacePath?: string; rootPath?: string; + command?: string; scrollbackLines?: number; spawnProcess?: SpawnProcess; } @@ -110,6 +114,7 @@ export class Session { readonly paneId: string; readonly tabId: string; readonly shell: string; + readonly command?: string; readonly createdAt: Date; private readonly spawnProcess: SpawnProcess; @@ -173,6 +178,7 @@ export class Session { this.paneId = options.paneId; this.tabId = options.tabId; this.shell = options.shell || this.getDefaultShell(); + this.command = options.command; this.createdAt = new Date(); this.lastAttachedAt = new Date(); this.spawnProcess = options.spawnProcess ?? spawn; @@ -237,7 +243,9 @@ export class Session { const processEnv = buildSafeEnv(envSource); processEnv.TERM = "xterm-256color"; - const shellArgs = getShellArgs(this.shell); + const shellArgs = this.command + ? getCommandShellArgs(this.shell, this.command) + : getShellArgs(this.shell); const subprocessPath = path.join(__dirname, "pty-subprocess.js"); // Spawn subprocess with filtered env to prevent leaking NODE_ENV etc. @@ -295,6 +303,9 @@ export class Session { rows, env: processEnv, }; + + // Command is now passed via shell args (e.g., bash -lc "command"), + // so the PTY process exits when the command finishes. } private pendingSpawn: { @@ -1098,5 +1109,6 @@ export function createSession(request: CreateOrAttachRequest): Session { workspaceName: request.workspaceName, workspacePath: request.workspacePath, rootPath: request.rootPath, + command: request.command, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts new file mode 100644 index 00000000000..6dc6041acd9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts @@ -0,0 +1,169 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; + +interface UseWorkspaceRunCommandOptions { + workspaceId: string; + worktreePath?: string | null; +} + +export function useWorkspaceRunCommand({ + workspaceId, + worktreePath, +}: UseWorkspaceRunCommandOptions) { + const { data: runConfig, isLoading: isRunConfigLoading } = + electronTrpc.workspaces.getResolvedRunCommands.useQuery( + { workspaceId }, + { enabled: !!workspaceId }, + ); + const terminalKill = electronTrpc.terminal.kill.useMutation(); + const killAsync = terminalKill.mutateAsync; + const isStartingRef = useRef(false); + + const addTab = useTabsStore((s) => s.addTab); + const setPaneName = useTabsStore((s) => s.setPaneName); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const setPaneWorkspaceRun = useTabsStore((s) => s.setPaneWorkspaceRun); + const getRestartCallback = useTerminalCallbacksStore( + (s) => s.getRestartCallback, + ); + + // Derive run state from pane metadata (single source of truth) + const runPane = useTabsStore((s) => { + const pane = Object.values(s.panes).find( + (p) => + p.type === "terminal" && p.workspaceRun?.workspaceId === workspaceId, + ); + return pane ?? null; + }); + + const isRunning = runPane?.workspaceRun?.state === "running"; + const isPending = terminalKill.isPending; + + const toggleWorkspaceRun = useCallback(async () => { + if (isPending || isStartingRef.current) return; + + // STOP: if currently running, kill it + if (isRunning && runPane) { + try { + await killAsync({ paneId: runPane.id }); + setPaneWorkspaceRun(runPane.id, { + workspaceId, + state: "stopped-by-user", + }); + } catch (error) { + toast.error("Failed to stop workspace run command", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + return; + } + + // START: resolve command (silently return if query still loading) + if (isRunConfigLoading) return; + const command = buildTerminalCommand(runConfig?.commands); + if (!command) { + toast.error("No workspace run command configured", { + description: + "Add a run script in Project Settings to use the workspace run shortcut.", + }); + return; + } + + isStartingRef.current = true; + try { + const initialCwd = worktreePath?.trim() ? worktreePath : undefined; + + // Reuse existing run pane if available + if (runPane) { + const tabsState = useTabsStore.getState(); + const tab = tabsState.tabs.find((t) => t.id === runPane.tabId); + if (tab) { + setActiveTab(workspaceId, tab.id); + setFocusedPane(tab.id, runPane.id); + } + + setPaneWorkspaceRun(runPane.id, { + workspaceId, + state: "running", + }); + + try { + const restartCallback = getRestartCallback(runPane.id); + if (restartCallback) { + await restartCallback({ command }); + } else { + const existingSession = await electronTrpcClient.terminal.getSession + .query(runPane.id) + .catch(() => null); + if (existingSession?.isAlive) { + await killAsync({ paneId: runPane.id }); + } + await electronTrpcClient.terminal.createOrAttach.mutate({ + paneId: runPane.id, + tabId: runPane.tabId, + workspaceId, + allowKilled: true, + command, + }); + // Re-assert running state — the kill above may have triggered + // the exit listener which flipped state to stopped-by-user. + setPaneWorkspaceRun(runPane.id, { + workspaceId, + state: "running", + }); + } + } catch (error) { + setPaneWorkspaceRun(runPane.id, { + workspaceId, + state: "stopped-by-exit", + }); + toast.error("Failed to run workspace command", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + return; + } + + // Create new pane — command is not passed here; instead the terminal + // lifecycle detects pane.workspaceRun.state === "running" on mount and + // reads the command from defaultRestartCommandRef (resolved via tRPC query). + const result = addTab(workspaceId, { initialCwd }); + const { tabId, paneId } = result; + + setPaneName(paneId, "Workspace Run"); + setPaneWorkspaceRun(paneId, { workspaceId, state: "running" }); + setActiveTab(workspaceId, tabId); + setFocusedPane(tabId, paneId); + } finally { + isStartingRef.current = false; + } + }, [ + addTab, + getRestartCallback, + isRunConfigLoading, + isRunning, + isPending, + killAsync, + runConfig?.commands, + runPane, + setActiveTab, + setFocusedPane, + setPaneName, + setPaneWorkspaceRun, + workspaceId, + worktreePath, + ]); + + return { + isRunning, + isPending, + toggleWorkspaceRun, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index df52db85e95..4771eee717d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -8,6 +8,7 @@ import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; +import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; import { NotFound } from "renderer/routes/not-found"; import { CommandPalette, @@ -181,6 +182,11 @@ function WorkspacePage() { activeTabId ? (s.focusedPaneIds[activeTabId] ?? null) : null, ); + const { toggleWorkspaceRun } = useWorkspaceRunCommand({ + workspaceId, + worktreePath: workspace?.worktreePath, + }); + const { presets } = usePresets(); const openTabWithPreset = useCallback( @@ -219,6 +225,10 @@ function WorkspacePage() { ]); usePresetHotkeys(openTabWithPreset); + useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), undefined, [ + toggleWorkspaceRun, + ]); + useAppHotkey( "CLOSE_TERMINAL", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 3c9c7078424..c0e692738b3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -26,9 +26,10 @@ import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/compo import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; import { useHotkeysSync } from "renderer/stores/hotkeys"; import { useSettingsStore } from "renderer/stores/settings-state"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; -import { MOCK_ORG_ID } from "shared/constants"; +import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; import { CollectionsProvider } from "./providers/CollectionsProvider"; @@ -64,6 +65,29 @@ function AuthenticatedLayout() { useUpdateListener(); useHotkeysSync(); + // Update workspace-run pane state on terminal exit + electronTrpc.notifications.subscribe.useSubscription(undefined, { + onData: (event) => { + if ( + event.type !== NOTIFICATION_EVENTS.TERMINAL_EXIT || + !event.data?.paneId + ) { + return; + } + const pane = useTabsStore.getState().panes[event.data.paneId]; + if (pane?.workspaceRun?.state === "running") { + const nextState = + event.data.reason === "killed" + ? "stopped-by-user" + : "stopped-by-exit"; + useTabsStore.getState().setPaneWorkspaceRun(event.data.paneId, { + workspaceId: pane.workspaceRun.workspaceId, + state: nextState, + }); + } + }, + }); + useEffect(() => { if (!location.pathname.startsWith("/settings")) { setOriginRoute(location.pathname); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx index 8d2715a40df..dbc30d0523a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx @@ -13,9 +13,10 @@ interface ScriptsEditorProps { function parseContentFromConfig(content: string | null): { setup: string; teardown: string; + run: string; } { if (!content) { - return { setup: "", teardown: "" }; + return { setup: "", teardown: "", run: "" }; } try { @@ -23,9 +24,10 @@ function parseContentFromConfig(content: string | null): { return { setup: (parsed.setup ?? []).join("\n"), teardown: (parsed.teardown ?? []).join("\n"), + run: (parsed.run ?? []).join("\n"), }; } catch { - return { setup: "", teardown: "" }; + return { setup: "", teardown: "", run: "" }; } } @@ -168,15 +170,15 @@ export function ScriptsEditor({ projectId, className }: ScriptsEditorProps) { const [setupContent, setSetupContent] = useState(""); const [teardownContent, setTeardownContent] = useState(""); + const [runContent, setRunContent] = useState(""); const [hasChanges, setHasChanges] = useState(false); useEffect(() => { - if (configData?.content) { - const parsed = parseContentFromConfig(configData.content); - setSetupContent(parsed.setup); - setTeardownContent(parsed.teardown); - setHasChanges(false); - } + const parsed = parseContentFromConfig(configData?.content ?? null); + setSetupContent(parsed.setup); + setTeardownContent(parsed.teardown); + setRunContent(parsed.run); + setHasChanges(false); }, [configData?.content]); const updateConfigMutation = electronTrpc.config.updateConfig.useMutation({ @@ -197,12 +199,24 @@ export function ScriptsEditor({ projectId, className }: ScriptsEditorProps) { setHasChanges(true); }, []); + const handleRunChange = useCallback((value: string) => { + setRunContent(value); + setHasChanges(true); + }, []); + const handleSave = useCallback(() => { const setup = setupContent.trim() ? [setupContent.trim()] : []; const teardown = teardownContent.trim() ? [teardownContent.trim()] : []; + const run = runContent.trim() ? [runContent.trim()] : []; - updateConfigMutation.mutate({ projectId, setup, teardown }); - }, [projectId, setupContent, teardownContent, updateConfigMutation]); + updateConfigMutation.mutate({ projectId, setup, teardown, run }); + }, [ + projectId, + setupContent, + teardownContent, + runContent, + updateConfigMutation, + ]); if (isLoading) { return ( @@ -259,6 +273,14 @@ export function ScriptsEditor({ projectId, className }: ScriptsEditorProps) { value={teardownContent} onChange={handleTeardownChange} /> + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/WorkspaceRunIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/WorkspaceRunIndicator.tsx new file mode 100644 index 00000000000..533054a9ec4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/WorkspaceRunIndicator.tsx @@ -0,0 +1,94 @@ +import { cn } from "@superset/ui/utils"; +import { HiMiniPause, HiMiniPlay, HiMiniXMark } from "react-icons/hi2"; +import type { WorkspaceRunState } from "shared/tabs-types"; + +interface WorkspaceRunIndicatorProps { + className?: string; + state: WorkspaceRunState; + variant?: "circle" | "inline" | "toolbar"; +} + +export function WorkspaceRunIndicator({ + className, + state, + variant = "circle", +}: WorkspaceRunIndicatorProps) { + const icon = + state === "running" ? ( + + ) : state === "stopped-by-user" ? ( + + ) : ( + + ); + + const colorClasses = + state === "running" + ? "bg-emerald-500" + : state === "stopped-by-user" + ? "bg-muted-foreground/40" + : "bg-red-400/50"; + + const inlineColorClasses = + state === "running" + ? "bg-emerald-500/15 text-emerald-400" + : state === "stopped-by-user" + ? "bg-muted-foreground/10 text-muted-foreground/50" + : "bg-red-500/15 text-red-400/70"; + + const toolbarColorClasses = + state === "running" + ? "text-emerald-300" + : state === "stopped-by-user" + ? "text-amber-300" + : "text-red-300/70"; + + if (variant === "circle") { + return ( + + {icon} + + ); + } + + if (variant === "toolbar") { + const toolbarIcon = + state === "running" ? ( + + ) : state === "stopped-by-user" ? ( + + ) : ( + + ); + return ( + + {toolbarIcon} + + ); + } + + // inline variant - tinted background with colored icon + return ( + + {icon} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/index.ts new file mode 100644 index 00000000000..3adf6fa7149 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceRunIndicator/index.ts @@ -0,0 +1 @@ +export { WorkspaceRunIndicator } from "./WorkspaceRunIndicator"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 313355ff83a..9af9b3c000f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -8,6 +8,7 @@ import { HiMiniXMark } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { WorkspaceRunIndicator } from "renderer/screens/main/components/WorkspaceRunIndicator"; import { useBranchSyncInvalidation } from "renderer/screens/main/hooks/useBranchSyncInvalidation"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; @@ -76,6 +77,14 @@ export function WorkspaceListItem({ } return getHighestPriorityStatus(paneStatuses()); }); + const workspaceRunState = useTabsStore((state) => { + for (const pane of Object.values(state.panes)) { + if (pane.type === "terminal" && pane.workspaceRun?.workspaceId === id) { + return pane.workspaceRun.state; + } + } + return null; + }); const clearWorkspaceAttentionStatus = useTabsStore( (s) => s.clearWorkspaceAttentionStatus, ); @@ -286,41 +295,46 @@ export function WorkspaceListItem({
)} - - -
+ + +
+ +
+
+ + {isBranchWorkspace ? ( + <> +

Local workspace

+

+ Changes are made directly in the main repository +

+ + ) : ( + <> +

Worktree workspace

+

+ Isolated copy for parallel development +

+ )} - > - -
-
- - {isBranchWorkspace ? ( - <> -

Local workspace

-

- Changes are made directly in the main repository -

- - ) : ( - <> -

Worktree workspace

-

- Isolated copy for parallel development -

- - )} -
-
+ + + {workspaceRunState && showBranchSubtitle && ( + + )} +
{rename.isRenaming ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index e5552d33fe1..81658904d46 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { WorkspaceRunIndicator } from "renderer/screens/main/components/WorkspaceRunIndicator"; import { registerPaneRef, unregisterPaneRef, @@ -58,6 +59,7 @@ export function TabPane({ }: TabPaneProps) { const paneName = useTabsStore((s) => s.panes[paneId]?.name); const paneStatus = useTabsStore((s) => s.panes[paneId]?.status); + const workspaceRun = useTabsStore((s) => s.panes[paneId]?.workspaceRun); const setPaneName = useTabsStore((s) => s.setPaneName); const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const equalizePaneSplits = useTabsStore((s) => s.equalizePaneSplits); @@ -101,6 +103,12 @@ export function TabPane({ renderToolbar={(handlers) => (
+ {workspaceRun && ( + + )} s.focusedPaneIds[tabId] === paneId); + const workspaceRunState = useTabsStore( + (s) => s.panes[paneId]?.workspaceRun?.state, + ); const containerRef = useRef(null); const splitOrientation = useSplitOrientation(containerRef); const isDragging = useDragPaneStore((s) => s.draggingPaneId !== null); @@ -99,7 +103,10 @@ export function BasePaneWindow({ renderToolbar(handlers) ) } - className={isActive ? "mosaic-window-focused" : ""} + className={cn( + isActive && "mosaic-window-focused", + workspaceRunState && `workspace-run-pane-${workspaceRunState}`, + )} onDragStart={() => setDragging(paneId, tabId)} onDragEnd={() => clearDragging()} > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css index f706cc76fef..6e5504daa75 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css @@ -125,3 +125,40 @@ border: 1px solid rgba(59, 130, 246, 0.3); opacity: 1; } + +/* Workspace run pane state colors — border + toolbar tint */ +.workspace-run-pane-running { + --ws-run-color: #10b981; +} +.workspace-run-pane-stopped-by-user { + --ws-run-color: #f59e0b; +} +.workspace-run-pane-stopped-by-exit { + --ws-run-color: #ef4444; +} + +:is(.mosaic-theme-dark, .mosaic-theme-light) [class*="workspace-run-pane-"] { + border-color: color-mix( + in srgb, + var(--ws-run-color) 25%, + var(--color-border) + ); +} +:is(.mosaic-theme-dark, .mosaic-theme-light) + [class*="workspace-run-pane-"] + .mosaic-window-toolbar { + background: color-mix( + in srgb, + var(--ws-run-color) 12%, + var(--color-tertiary) + ); +} +:is(.mosaic-theme-dark, .mosaic-theme-light) + [class*="workspace-run-pane-"].mosaic-window-focused + .mosaic-window-toolbar { + background: color-mix( + in srgb, + var(--ws-run-color) 12%, + var(--color-secondary) + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 8b1d3260bec..68eb4e7c361 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -4,7 +4,9 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; import { SessionKilledOverlay } from "./components"; import { @@ -38,6 +40,7 @@ const stripLeadingEmoji = (text: string) => export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const pane = useTabsStore((s) => s.panes[paneId]); + const isWorkspaceRunPane = Boolean(pane?.workspaceRun?.workspaceId); const paneInitialCwd = pane?.initialCwd; const clearPaneInitialData = useTabsStore((s) => s.clearPaneInitialData); @@ -48,6 +51,17 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const isUnnamedRef = useRef(false); isUnnamedRef.current = workspaceData?.isUnnamed ?? false; + const { data: workspaceRunConfig } = + electronTrpc.workspaces.getResolvedRunCommands.useQuery( + { workspaceId }, + { enabled: isWorkspaceRunPane }, + ); + + const defaultRestartCommandRef = useRef(undefined); + defaultRestartCommandRef.current = isWorkspaceRunPane + ? (buildTerminalCommand(workspaceRunConfig?.commands) ?? undefined) + : undefined; + const utils = electronTrpc.useUtils(); const updateWorkspace = electronTrpc.workspaces.update.useMutation({ onSuccess: () => { @@ -353,8 +367,25 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { unregisterGetSelectionCallbackRef, registerPasteCallbackRef, unregisterPasteCallbackRef, + defaultRestartCommandRef, }); + const registerRestartCallback = useTerminalCallbacksStore( + (s) => s.registerRestartCallback, + ); + const unregisterRestartCallback = useTerminalCallbacksStore( + (s) => s.unregisterRestartCallback, + ); + useEffect(() => { + registerRestartCallback(paneId, restartTerminal); + return () => unregisterRestartCallback(paneId); + }, [ + paneId, + restartTerminal, + registerRestartCallback, + unregisterRestartCallback, + ]); + useEffect(() => { const xterm = xtermRef.current; if (!xterm || !terminalTheme) return; @@ -419,9 +450,12 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { onClose={() => setIsSearchOpen(false)} /> - {exitStatus === "killed" && !connectionError && !isRestoredMode && ( - - )} + {exitStatus === "killed" && + !connectionError && + !isRestoredMode && + !isWorkspaceRunPane && ( + + )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 6914eb3e011..7cd63b9ae5b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -3,6 +3,7 @@ import { SearchAddon } from "@xterm/addon-search"; import type { IDisposable, ITheme, Terminal as XTerm } from "@xterm/xterm"; import type { MutableRefObject, RefObject } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useTabsStore } from "renderer/stores/tabs/store"; import { killTerminalForPane } from "renderer/stores/tabs/utils/terminal-cleanup"; import { scheduleTerminalAttach } from "../attach-scheduler"; @@ -132,11 +133,15 @@ export interface UseTerminalLifecycleOptions { (paneId: string, callback: (text: string) => void) => void >; unregisterPasteCallbackRef: MutableRefObject; + defaultRestartCommandRef: MutableRefObject; } export interface UseTerminalLifecycleReturn { xtermInstance: XTerm | null; - restartTerminal: () => void; + restartTerminal: (options?: { + command?: string; + forceRestart?: boolean; + }) => Promise; } export function useTerminalLifecycle({ @@ -188,10 +193,17 @@ export function useTerminalLifecycle({ unregisterGetSelectionCallbackRef, registerPasteCallbackRef, unregisterPasteCallbackRef, + defaultRestartCommandRef, }: UseTerminalLifecycleOptions): UseTerminalLifecycleReturn { const [xtermInstance, setXtermInstance] = useState(null); - const restartTerminalRef = useRef<() => void>(() => {}); - const restartTerminal = useCallback(() => restartTerminalRef.current(), []); + const restartTerminalRef = useRef< + (options?: { command?: string; forceRestart?: boolean }) => Promise + >(() => Promise.resolve()); + const restartTerminal = useCallback( + (options?: { command?: string; forceRestart?: boolean }) => + restartTerminalRef.current(options), + [], + ); // biome-ignore lint/correctness/useExhaustiveDependencies: refs used intentionally useEffect(() => { @@ -275,44 +287,97 @@ export function useTerminalLifecycle({ maybeApplyInitialState(); }, FIRST_RENDER_RESTORE_FALLBACK_MS); - const restartTerminalSession = () => { - isExitedRef.current = false; - isStreamReadyRef.current = false; - wasKilledByUserRef.current = false; - setExitStatus(null); - resetModes(); - xterm.clear(); - createOrAttachRef.current( - { - paneId, - tabId: tabIdRef.current, - workspaceId, - cols: xterm.cols, - rows: xterm.rows, - allowKilled: true, - }, - { - onSuccess: (result) => { - pendingInitialStateRef.current = result; - maybeApplyInitialState(); - }, - onError: (error) => { - console.error("[Terminal] Failed to restart:", error); - setConnectionError(error.message || "Failed to restart terminal"); - isStreamReadyRef.current = true; - flushPendingEvents(); - }, - }, - ); - }; + const restartTerminalSession = (options?: { + command?: string; + forceRestart?: boolean; + }) => + new Promise((resolve, reject) => { + const command = options?.command ?? defaultRestartCommandRef.current; + const workspaceRun = + useTabsStore.getState().panes[paneId]?.workspaceRun; + isExitedRef.current = false; + isStreamReadyRef.current = false; + wasKilledByUserRef.current = false; + setExitStatus(null); + resetModes(); + xterm.clear(); + if (workspaceRun && command) { + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: workspaceRun.workspaceId, + state: "running", + }); + } + const attach = () => { + createOrAttachRef.current( + { + paneId, + tabId: tabIdRef.current, + workspaceId, + cols: xterm.cols, + rows: xterm.rows, + skipColdRestore: true, + allowKilled: true, + command, + }, + { + onSuccess: (result) => { + setConnectionError(null); + pendingInitialStateRef.current = result; + maybeApplyInitialState(); + resolve(); + }, + onError: (error) => { + console.error("[Terminal] Failed to restart:", error); + if (workspaceRun) { + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: workspaceRun.workspaceId, + state: "stopped-by-exit", + }); + } + setConnectionError( + error.message || "Failed to restart terminal", + ); + isStreamReadyRef.current = true; + flushPendingEvents(); + reject(error); + }, + }, + ); + }; + + if (options?.forceRestart) { + void electronTrpcClient.terminal.kill + .mutate({ paneId }) + .catch((err) => { + console.warn("[Terminal] Kill failed before restart:", err); + }) + .finally(attach); + return; + } + attach(); + }); restartTerminalRef.current = restartTerminalSession; const handleTerminalInput = (data: string) => { if (isRestoredModeRef.current || connectionErrorRef.current) return; if (isExitedRef.current) { - if (!isFocusedRef.current || wasKilledByUserRef.current) return; - restartTerminalSession(); + const isWorkspaceRunPane = Boolean( + useTabsStore.getState().panes[paneId]?.workspaceRun, + ); + if ( + !isFocusedRef.current || + (wasKilledByUserRef.current && !isWorkspaceRunPane) + ) { + return; + } + // For workspace-run panes, don't restart until the run command + // has been resolved via tRPC query — otherwise we'd start a + // plain interactive shell instead of the configured command. + if (isWorkspaceRunPane && !defaultRestartCommandRef.current) { + return; + } + void restartTerminalSession(); return; } writeRef.current({ paneId, data }); @@ -365,6 +430,16 @@ export function useTerminalLifecycle({ const initialCwd = paneInitialCwdRef.current; + const paneWorkspaceRun = + useTabsStore.getState().panes[paneId]?.workspaceRun; + // A "new" workspace run is one triggered by the hook just before this mount, + // indicated by state === "running" AND a command already resolved. + // After app restart, defaultRestartCommandRef won't be set yet, so we + // treat that as a stale persisted state that needs recovery, not a new run. + const isNewWorkspaceRun = + paneWorkspaceRun?.state === "running" && + !!defaultRestartCommandRef.current; + const cancelInitialAttach = scheduleTerminalAttach({ paneId, priority: isFocusedRef.current ? 0 : 1, @@ -402,6 +477,10 @@ export function useTerminalLifecycle({ cols: xterm.cols, rows: xterm.rows, cwd: initialCwd, + ...(isNewWorkspaceRun && { + command: defaultRestartCommandRef.current, + skipColdRestore: true, + }), }, { onSuccess: (result) => { @@ -465,6 +544,67 @@ export function useTerminalLifecycle({ ); }; + // Handle workspace-run panes that need recovery (stopped or stale "running" after restart) + if (paneWorkspaceRun && !isNewWorkspaceRun) { + // Check if session is still alive + void electronTrpcClient.terminal.getSession + .query(paneId) + .then(async (existingSession) => { + if (isUnmounted || attachCanceled) return; + if (existingSession?.isAlive) { + // Session survived — attach normally and ensure state is "running" + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: paneWorkspaceRun.workspaceId, + state: "running", + }); + startAttach(); + return; + } + // Session is dead — show exited state. + // If persisted state was "running", it was a stale state from before restart. + const wasStoppedByUser = + paneWorkspaceRun.state === "stopped-by-user"; + const resolvedState = + paneWorkspaceRun.state === "running" + ? "stopped-by-exit" + : paneWorkspaceRun.state; + + // Update pane metadata to reflect resolved state + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: paneWorkspaceRun.workspaceId, + state: resolvedState, + }); + + isExitedRef.current = true; + wasKilledByUserRef.current = wasStoppedByUser; + isStreamReadyRef.current = true; + setExitStatus(wasStoppedByUser ? "killed" : "exited"); + if (wasStoppedByUser) { + xterm.writeln("\r\n[Session killed]"); + } else { + xterm.writeln("\r\n[Process exited]"); + } + xterm.writeln("[Press any key to restart]"); + done(); + }) + .catch(() => { + if (isUnmounted || attachCanceled) return; + // On error, conservatively mark as exited and show restart prompt + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: paneWorkspaceRun.workspaceId, + state: "stopped-by-exit", + }); + isExitedRef.current = true; + wasKilledByUserRef.current = false; + isStreamReadyRef.current = true; + setExitStatus("exited"); + xterm.writeln("\r\n[Process exited]"); + xterm.writeln("[Press any key to restart]"); + done(); + }); + return; + } + startAttach(); return; }, @@ -670,6 +810,9 @@ export function useTerminalLifecycle({ killTerminalForPane(paneId); coldRestoreState.delete(paneId); pendingDetaches.delete(paneId); + } else if (useTabsStore.getState().panes[paneId]?.workspaceRun) { + // Keep workspace-run panes attached while hidden + pendingDetaches.delete(paneId); } else { const detachTimeout = setTimeout(() => { detachRef.current({ paneId }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index b89a3198029..7cb30059895 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -62,15 +62,35 @@ export function useTerminalStream({ wasKilledByUserRef.current = wasKilledByUser; setExitStatus(wasKilledByUser ? "killed" : "exited"); + const currentPaneForRun = useTabsStore.getState().panes[paneId]; + const isWorkspaceRunPane = Boolean(currentPaneForRun?.workspaceRun); + if (currentPaneForRun?.workspaceRun) { + const nextState = wasKilledByUser + ? "stopped-by-user" + : "stopped-by-exit"; + useTabsStore.getState().setPaneWorkspaceRun(paneId, { + workspaceId: currentPaneForRun.workspaceRun.workspaceId, + state: nextState, + }); + } + if (wasKilledByUser) { xterm.writeln("\r\n\r\n[Session killed]"); - xterm.writeln("[Restart to start a new session]"); - } else if (exitCode === 0) { + xterm.writeln( + isWorkspaceRunPane + ? "[Press any key to restart]" + : "[Restart to start a new session]", + ); + } else if (exitCode === 0 && !isWorkspaceRunPane) { // Clean exit (e.g. typing "exit") — close the pane/tab removePane(paneId); return; } else { - xterm.writeln(`\r\n\r\n[Process exited with code ${exitCode}]`); + xterm.writeln( + exitCode === 0 + ? "\r\n\r\n[Process exited]" + : `\r\n\r\n[Process exited with code ${exitCode}]`, + ); xterm.writeln("[Press any key to restart]"); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index b38d86275df..2816b7747e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -67,6 +67,7 @@ export interface CreateOrAttachInput { skipColdRestore?: boolean; allowKilled?: boolean; themeType?: "dark" | "light"; + command?: string; } /** diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 9f979d5af92..aed49f20dc0 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1079,6 +1079,21 @@ export const useTabsStore = create()( ), }); }, + setPaneWorkspaceRun: (paneId, workspaceRun) => { + set((state) => { + const pane = state.panes[paneId]; + if (!pane) return state; + return { + panes: { + ...state.panes, + [paneId]: { + ...pane, + workspaceRun: workspaceRun ?? undefined, + }, + }, + }; + }); + }, setPaneAutoTitle: (paneId, title) => { set((state) => { const pane = state.panes[paneId]; @@ -2093,6 +2108,15 @@ export const useTabsStore = create()( if (pane.status === "working" || pane.status === "permission") { pane.status = "idle"; } + // Workspace-run "running" state can't survive a restart — + // the daemon session is gone. Mark as exited so the sidebar + // indicator is correct even if the pane never remounts. + if (pane.workspaceRun?.state === "running") { + pane.workspaceRun = { + ...pane.workspaceRun, + state: "stopped-by-exit", + }; + } } } diff --git a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts index 96e4b4f064b..3616445b1c6 100644 --- a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts +++ b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts @@ -5,6 +5,10 @@ interface TerminalCallbacksState { scrollToBottomCallbacks: Map void>; getSelectionCallbacks: Map string>; pasteCallbacks: Map void>; + restartCallbacks: Map< + string, + (options?: { command?: string; forceRestart?: boolean }) => Promise + >; registerClearCallback: (paneId: string, callback: () => void) => void; unregisterClearCallback: (paneId: string) => void; getClearCallback: (paneId: string) => (() => void) | undefined; @@ -26,6 +30,22 @@ interface TerminalCallbacksState { ) => void; unregisterPasteCallback: (paneId: string) => void; getPasteCallback: (paneId: string) => ((text: string) => void) | undefined; + registerRestartCallback: ( + paneId: string, + callback: (options?: { + command?: string; + forceRestart?: boolean; + }) => Promise, + ) => void; + unregisterRestartCallback: (paneId: string) => void; + getRestartCallback: ( + paneId: string, + ) => + | ((options?: { + command?: string; + forceRestart?: boolean; + }) => Promise) + | undefined; } export const useTerminalCallbacksStore = create()( @@ -34,6 +54,7 @@ export const useTerminalCallbacksStore = create()( scrollToBottomCallbacks: new Map(), getSelectionCallbacks: new Map(), pasteCallbacks: new Map(), + restartCallbacks: new Map(), registerClearCallback: (paneId, callback) => { set((state) => { @@ -114,5 +135,25 @@ export const useTerminalCallbacksStore = create()( getPasteCallback: (paneId) => { return get().pasteCallbacks.get(paneId); }, + + registerRestartCallback: (paneId, callback) => { + set((state) => { + const newCallbacks = new Map(state.restartCallbacks); + newCallbacks.set(paneId, callback); + return { restartCallbacks: newCallbacks }; + }); + }, + + unregisterRestartCallback: (paneId) => { + set((state) => { + const newCallbacks = new Map(state.restartCallbacks); + newCallbacks.delete(paneId); + return { restartCallbacks: newCallbacks }; + }); + }, + + getRestartCallback: (paneId) => { + return get().restartCallbacks.get(paneId); + }, }), ); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 5692a24a605..e5241cda2b9 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -134,6 +134,13 @@ export interface TabsStore extends TabsState { markPaneAsUsed: (paneId: string) => void; setPaneStatus: (paneId: string, status: PaneStatus) => void; setPaneName: (paneId: string, name: string) => void; + setPaneWorkspaceRun: ( + paneId: string, + workspaceRun: { + workspaceId: string; + state: "running" | "stopped-by-user" | "stopped-by-exit"; + } | null, + ) => void; setPaneAutoTitle: (paneId: string, title: string) => void; clearWorkspaceAttentionStatus: (workspaceId: string) => void; resetWorkspaceStatus: (workspaceId: string) => void; diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index f172f936c88..2f61b5db4fc 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -722,6 +722,12 @@ export const HOTKEYS = { category: "Workspace", description: "Quickly create a workspace in the current project", }), + RUN_WORKSPACE_COMMAND: defineHotkey({ + keys: "meta+shift+g", + label: "Run Workspace Command", + category: "Workspace", + description: "Start or stop the workspace run command", + }), FOCUS_TASK_SEARCH: defineHotkey({ keys: "meta+f", label: "Focus Task Search", diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 0cecf34d9a8..fd42302cfec 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -142,8 +142,14 @@ export interface Pane { chat?: ChatPaneState; // For chat panes browser?: BrowserPaneState; // For browser (webview) panes devtools?: DevToolsPaneState; // For devtools panes + workspaceRun?: { + workspaceId: string; + state: "running" | "stopped-by-user" | "stopped-by-exit"; + }; } +export type WorkspaceRunState = NonNullable["state"]; + export interface ChatLaunchConfig { initialPrompt?: string; draftInput?: string; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index c7fe8d3cd73..6de0f6942e7 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1,6 +1,7 @@ export interface SetupConfig { setup?: string[]; teardown?: string[]; + run?: string[]; } export interface LocalScriptMerge { @@ -11,6 +12,7 @@ export interface LocalScriptMerge { export interface LocalSetupConfig { setup?: string[] | LocalScriptMerge; teardown?: string[] | LocalScriptMerge; + run?: string[] | LocalScriptMerge; } export interface SetupAction { From bb729cb78324891fb5f16a85dcb3d18bd7027d37 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 19 Mar 2026 21:45:21 -0700 Subject: [PATCH 2/6] fix(desktop): treat run scripts as configured --- apps/desktop/src/lib/trpc/routers/config/config.ts | 10 ++++++++-- apps/desktop/src/shared/constants.ts | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts index 8382f089b5d..6d39f570a8a 100644 --- a/apps/desktop/src/lib/trpc/routers/config/config.ts +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -26,12 +26,18 @@ function hasConfiguredScripts( (s): s is string => typeof s === "string" && s.trim().length > 0, ) : []; - return setup.length > 0 || teardown.length > 0; + const run = Array.isArray(config?.run) + ? config.run.filter( + (s): s is string => typeof s === "string" && s.trim().length > 0, + ) + : []; + return setup.length > 0 || teardown.length > 0 || run.length > 0; } const CONFIG_TEMPLATE = `{ "setup": [], - "teardown": [] + "teardown": [], + "run": [] } `; diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index c6e4c841fcf..c19264f803c 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -24,7 +24,8 @@ export const PORTS_FILE_NAME = "ports.json"; export const CONFIG_TEMPLATE = `{ "setup": [], - "teardown": [] + "teardown": [], + "run": [] }`; export const NOTIFICATION_EVENTS = { From 70c26193e107fa61f41924bb11b5a5cc1a2e0590 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 19 Mar 2026 22:23:21 -0700 Subject: [PATCH 3/6] refactor(desktop): separate workspace run lifecycle --- .../_dashboard/project/$projectId/page.tsx | 1 + .../hooks/useWorkspaceRunCommand.ts | 32 ++--- .../ScriptsEditor/ScriptsEditor.tsx | 101 ++++++++++++--- .../Terminal/hooks/useTerminalLifecycle.ts | 106 ++++----------- .../Terminal/hooks/workspaceRun.ts | 121 ++++++++++++++++++ 5 files changed, 245 insertions(+), 116 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx index 4efd41357fe..b9cafceddcf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx @@ -144,6 +144,7 @@ function ProjectPage() { onSuccess: async () => { await utils.config.getConfigContent.invalidate({ projectId }); await utils.config.shouldShowSetupCard.invalidate({ projectId }); + await utils.workspaces.getResolvedRunCommands.invalidate(); }, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts index 6dc6041acd9..673b2de0bd0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts @@ -1,6 +1,5 @@ import { toast } from "@superset/ui/sonner"; import { useCallback, useRef } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -15,13 +14,6 @@ export function useWorkspaceRunCommand({ workspaceId, worktreePath, }: UseWorkspaceRunCommandOptions) { - const { data: runConfig, isLoading: isRunConfigLoading } = - electronTrpc.workspaces.getResolvedRunCommands.useQuery( - { workspaceId }, - { enabled: !!workspaceId }, - ); - const terminalKill = electronTrpc.terminal.kill.useMutation(); - const killAsync = terminalKill.mutateAsync; const isStartingRef = useRef(false); const addTab = useTabsStore((s) => s.addTab); @@ -43,15 +35,14 @@ export function useWorkspaceRunCommand({ }); const isRunning = runPane?.workspaceRun?.state === "running"; - const isPending = terminalKill.isPending; const toggleWorkspaceRun = useCallback(async () => { - if (isPending || isStartingRef.current) return; + if (isStartingRef.current) return; // STOP: if currently running, kill it if (isRunning && runPane) { try { - await killAsync({ paneId: runPane.id }); + await electronTrpcClient.terminal.kill.mutate({ paneId: runPane.id }); setPaneWorkspaceRun(runPane.id, { workspaceId, state: "stopped-by-user", @@ -64,9 +55,13 @@ export function useWorkspaceRunCommand({ return; } - // START: resolve command (silently return if query still loading) - if (isRunConfigLoading) return; - const command = buildTerminalCommand(runConfig?.commands); + // START: always fetch the latest config so run-script detection never + // depends on stale cache state or on a query still loading in the view. + const runConfig = + await electronTrpcClient.workspaces.getResolvedRunCommands.query({ + workspaceId, + }); + const command = buildTerminalCommand(runConfig.commands); if (!command) { toast.error("No workspace run command configured", { description: @@ -102,7 +97,9 @@ export function useWorkspaceRunCommand({ .query(runPane.id) .catch(() => null); if (existingSession?.isAlive) { - await killAsync({ paneId: runPane.id }); + await electronTrpcClient.terminal.kill.mutate({ + paneId: runPane.id, + }); } await electronTrpcClient.terminal.createOrAttach.mutate({ paneId: runPane.id, @@ -147,11 +144,7 @@ export function useWorkspaceRunCommand({ }, [ addTab, getRestartCallback, - isRunConfigLoading, isRunning, - isPending, - killAsync, - runConfig?.commands, runPane, setActiveTab, setFocusedPane, @@ -163,7 +156,6 @@ export function useWorkspaceRunCommand({ return { isRunning, - isPending, toggleWorkspaceRun, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx index dbc30d0523a..c01db36b12c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx @@ -37,6 +37,7 @@ interface ScriptTextareaProps { placeholder: string; value: string; onChange: (value: string) => void; + onBlur?: () => void; } function ScriptTextarea({ @@ -45,6 +46,7 @@ function ScriptTextarea({ placeholder, value, onChange, + onBlur, }: ScriptTextareaProps) { const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); @@ -125,6 +127,7 @@ function ScriptTextarea({