Improve workspace runtime controls

This commit is contained in:
Dotta committed 2026-04-14 11:51:37 -05:00
1 parent f1c504bc48
commit 46af1e5623
4 files changed
+182 -68

No files matched your search

@@ -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(<WorkspaceRuntimeControls isRunning={false} canStart onAction={vi.fn()} />);
});
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(<WorkspaceRuntimeControls isRunning canStart onAction={vi.fn()} />);
});
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(
<WorkspaceRuntimeControls
isRunning={false}
canStart={false}
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
);
});
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.");
});
});
@@ -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 (
<div className={cn("rounded-xl border border-border/70 bg-background/60 p-3", className)}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime state</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
isRunning
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-border bg-background text-muted-foreground",
)}
>
<Activity className="h-3.5 w-3.5" />
{isRunning ? "Running" : "Stopped"}
</span>
<span className="text-xs text-muted-foreground">{statusCopy}</span>
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
{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 (
<Button
key={action}
variant={action === "start" ? "default" : action === "stop" ? "destructive" : "outline"}
size="sm"
className={cn(
"h-10 w-full justify-start rounded-xl px-3 shadow-none sm:w-auto",
action === "restart" ? "bg-background" : null,
)}
disabled={disabled}
onClick={() => onAction(action)}
>
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
{label}
</Button>
);
})}
</div>
</div>
{!isRunning && !canStart && disabledHint ? <p className="text-xs text-muted-foreground">{disabledHint}</p> : null}
</div>
</div>
);
}
+14 -34
View File
@@ -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<string, unknown> | 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"}
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<WorkspaceRuntimeControls
className="mt-4"
isRunning={runtimeServicesRunning}
canStart={canStartRuntimeServices}
isPending={controlRuntimeServices.isPending}
pendingAction={pendingRuntimeAction}
disabledHint="Execution workspaces need both a working directory and runtime config before services can be started."
onAction={(action) => controlRuntimeServices.mutate(action)}
/>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-3">
+14 -34
View File
@@ -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<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
@@ -338,6 +335,10 @@ export function ProjectWorkspaceDetail() {
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
}
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.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !workspace.runtimeConfig?.workspaceRuntime || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<WorkspaceRuntimeControls
className="mt-4"
isRunning={runtimeServicesRunning}
canStart={canStartRuntimeServices}
isPending={controlRuntimeServices.isPending}
pendingAction={pendingRuntimeAction}
disabledHint="Project workspaces need both a working directory and runtime config before services can be started."
onAction={(action) => controlRuntimeServices.mutate(action)}
/>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-3">