diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx new file mode 100644 index 00000000000..c0db951642a --- /dev/null +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceRuntimeControls } from "./WorkspaceRuntimeControls"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("WorkspaceRuntimeControls", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("only shows start when services are not running", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); + expect(buttons).toEqual(["Start"]); + }); + + it("shows stop and restart when services are already running", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); + expect(buttons).toEqual(["Stop", "Restart"]); + }); + + it("keeps start visible but disabled when runtime prerequisites are missing", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const startButton = container.querySelector("button"); + expect(startButton?.textContent?.trim()).toBe("Start"); + expect(startButton?.hasAttribute("disabled")).toBe(true); + expect(container.textContent).toContain("Add runtime settings first."); + }); +}); diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx new file mode 100644 index 00000000000..0d845ea47a9 --- /dev/null +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -0,0 +1,90 @@ +import { Activity, Loader2, Play, RotateCcw, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type WorkspaceRuntimeAction = "start" | "stop" | "restart"; + +type WorkspaceRuntimeControlsProps = { + isRunning: boolean; + canStart: boolean; + isPending?: boolean; + pendingAction?: WorkspaceRuntimeAction | null; + disabledHint?: string | null; + onAction: (action: WorkspaceRuntimeAction) => void; + className?: string; +}; + +export function hasRunningRuntimeServices( + runtimeServices: Array<{ status: string }> | null | undefined, +) { + return (runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); +} + +export function WorkspaceRuntimeControls({ + isRunning, + canStart, + isPending = false, + pendingAction = null, + disabledHint = null, + onAction, + className, +}: WorkspaceRuntimeControlsProps) { + const actions: WorkspaceRuntimeAction[] = isRunning ? ["stop", ...(canStart ? ["restart" as const] : [])] : ["start"]; + const statusCopy = isRunning + ? "Attached services are live for this workspace." + : canStart + ? "Ready to start attached services." + : "Add a working directory and runtime config to start services."; + + return ( +
+
+
+
+
Runtime state
+
+ + + {isRunning ? "Running" : "Stopped"} + + {statusCopy} +
+
+
+ {actions.map((action) => { + const Icon = action === "start" ? Play : action === "stop" ? Square : RotateCcw; + const label = action === "start" ? "Start" : action === "stop" ? "Stop" : "Restart"; + const showSpinner = isPending && pendingAction === action; + const disabled = isPending || ((action === "start" || action === "restart") && !canStart); + + return ( + + ); + })} +
+
+ {!isRunning && !canStart && disabledHint ?

{disabledHint}

: null} +
+
+ ); +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 3aa4a95fb96..99c91ea1cfb 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -15,6 +15,7 @@ import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { IssuesList } from "../components/IssuesList"; import { PageTabBar } from "../components/PageTabBar"; +import { hasRunningRuntimeServices, WorkspaceRuntimeControls } from "../components/WorkspaceRuntimeControls"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; @@ -66,10 +67,6 @@ function readText(value: string | null | undefined) { return value ?? ""; } -function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) { - return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); -} - function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -452,6 +449,10 @@ export function ExecutionWorkspaceDetail() { } if (!workspace || !form || !initialState) return null; + const canStartRuntimeServices = Boolean(effectiveRuntimeConfig) && Boolean(workspace.cwd); + const runtimeServicesRunning = hasRunningRuntimeServices(workspace.runtimeServices); + const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; + if (workspaceId && activeTab === null) { let cachedTab: ExecutionWorkspaceTab = "configuration"; try { @@ -530,37 +531,16 @@ export function ExecutionWorkspaceDetail() { : "none"}

-
- - - -
+ controlRuntimeServices.mutate(action)} + /> {workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index a5fe8ac1635..39d639f0b1b 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { ChoosePathButton } from "../components/PathInstructionsModal"; import { projectsApi } from "../api/projects"; +import { hasRunningRuntimeServices, WorkspaceRuntimeControls } from "../components/WorkspaceRuntimeControls"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; @@ -61,10 +62,6 @@ function readText(value: string | null | undefined) { return value ?? ""; } -function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) { - return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); -} - function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -338,6 +335,10 @@ export function ProjectWorkspaceDetail() { return

Workspace not found for this project.

; } + const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && Boolean(workspace.cwd); + const runtimeServicesRunning = hasRunningRuntimeServices(workspace.runtimeServices); + const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; + const saveChanges = () => { const validationError = validateWorkspaceForm(form); if (validationError) { @@ -604,37 +605,16 @@ export function ProjectWorkspaceDetail() { Shared services for this project workspace. Execution workspaces inherit this config unless they override it.

-
- - - -
+ controlRuntimeServices.mutate(action)} + /> {workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (