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 ? (