mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
Improve workspace runtime controls
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user