From 5d42382df4c5724085967027485fcd39b91b01ae Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:07:04 -0500 Subject: [PATCH] feat(ui): stabilize workspace service controls (#9705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent work. > - Execution workspaces provide the local service loop where operators start, stop, restart, inspect, and open workspace services. > - The existing header exposed those actions through separate controls whose position and labeling changed across runtime states. > - That movement made the most common development actions harder to scan and easier to misclick, especially with multiple services or long URLs. > - The design therefore uses one fixed-geometry, state-aware control bar and keeps service-specific detail behind a compact disclosure. > - This pull request adds that control surface, maps existing runtime data and every pending mutation into it, and integrates it into the execution-workspace header without changing server contracts. > - The benefit is a calmer, predictable service-control loop across stopped, transitional, running, unhealthy, failed, multi-service, and narrow-width states. ## Linked Issues or Issue Description ### Subsystem affected `ui/ — React + Vite board UI` ### Problem or motivation Execution-workspace service actions move and change shape as runtime state changes, while URLs and multi-service status compete for header space. During bulk actions, operators also need every targeted service to show its transitional state immediately. ### Proposed solution Use one fixed-geometry, state-aware service control bar in the workspace header. Map existing runtime records into a stable status, URL, and actions model, and track each in-flight bulk request independently until it settles. ### Alternatives considered Keeping the separate quick-control buttons was rejected because their geometry changes by state. Showing every service inline was rejected because it makes the header too wide; per-service detail remains in a compact disclosure and the Services tab. ### Roadmap alignment Reviewed `ROADMAP.md`; this focused execution-workspace UI improvement does not duplicate a listed roadmap initiative. ### Additional context The published design and state viewer is available at https://pages.paperclip.ing/pap-14233-workspace-service-controls/. ## What Changed - Added `WorkspaceServiceControlBar`, a fixed-geometry responsive control for single- and multi-service runtime states. - Added 15 Storybook states covering running, stopped, transitions, unhealthy, failed, disabled, long-URL, mobile, and multi-service behavior. - Replaced `WorkspaceRuntimeQuickControls` in the execution-workspace header with adapters that map live services and all pending requests into the new control model. - Added focused unit coverage for service-entry construction, bulk pending overlays, request resolution, clipboard feedback, and header integration. ## Verification - `cd ui && NODE_ENV=development pnpm vitest run src/components/WorkspaceServiceControlBar.test.tsx src/components/WorkspaceRuntimeControls.test.tsx src/pages/ExecutionWorkspaceDetail.test.tsx` — 29 tests passed. - `NODE_ENV=development pnpm --dir ui typecheck` — passed. - `pnpm check:token-gates` — all token gates clean. - Reviewed the Storybook captures for all primary states. ## Risks - Low-to-moderate UI risk: service controls depend on adapter mapping from existing runtime records; focused tests cover single-service and bulk-action mapping and integration paths. - Multi-service bulk actions intentionally apply to all eligible services, while per-service actions remain in the disclosure. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.3-codex`; runtime-managed context window; coding/reasoning mode with repository, terminal, Git, GitHub CLI, and test execution tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 Co-authored-by: Paperclip --- .../WorkspaceRuntimeControls.test.tsx | 148 +++++ .../components/WorkspaceRuntimeControls.tsx | 100 ++++ .../WorkspaceServiceControlBar.test.tsx | 108 ++++ .../components/WorkspaceServiceControlBar.tsx | 532 ++++++++++++++++++ .../pages/ExecutionWorkspaceDetail.test.tsx | 6 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 36 +- .../workspace-service-control-bar.stories.tsx | 213 +++++++ 7 files changed, 1135 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/WorkspaceServiceControlBar.test.tsx create mode 100644 ui/src/components/WorkspaceServiceControlBar.tsx create mode 100644 ui/storybook/stories/workspace-service-control-bar.stories.tsx diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index 6442c676a9..9d8bf1f5c0 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildWorkspaceRuntimeControlItems, buildWorkspaceRuntimeControlSections, + buildWorkspaceServiceControlEntries, + resolveWorkspaceServiceControlRequests, WorkspaceRuntimeQuickControls, WorkspaceRuntimeControls, } from "./WorkspaceRuntimeControls"; @@ -487,3 +489,149 @@ describe("WorkspaceRuntimeControls", () => { act(() => root.unmount()); }); }); + +describe("buildWorkspaceServiceControlEntries", () => { + const sections = () => buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + runtimeServices: [ + createRuntimeService({ + id: "service-web", + serviceName: "web", + status: "running", + url: "http://localhost:3100", + port: 3100, + healthStatus: "healthy", + }), + ], + canStartServices: true, + canRunJobs: true, + }); + + it("maps service items to control bar entries and excludes jobs", () => { + const entries = buildWorkspaceServiceControlEntries({ sections: sections() }); + + expect(entries).toEqual([ + expect.objectContaining({ + name: "web", + state: "running", + url: "http://localhost:3100", + port: 3100, + healthStatus: "healthy", + failureDetail: null, + }), + ]); + }); + + it("overlays transitional states from the pending mutation", () => { + const built = sections(); + const entries = buildWorkspaceServiceControlEntries({ + sections: built, + isPending: true, + pendingRequest: { + action: "stop", + workspaceCommandId: built.services[0].workspaceCommandId ?? null, + runtimeServiceId: built.services[0].runtimeServiceId ?? null, + serviceIndex: built.services[0].serviceIndex ?? null, + }, + }); + + expect(entries[0].state).toBe("stopping"); + }); + + it("overlays every service targeted by a bulk mutation", () => { + const built = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "api", name: "api", kind: "service", command: "pnpm api" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + createRuntimeService({ + id: "service-api", + serviceName: "api", + status: "running", + command: "pnpm api", + }), + ], + canStartServices: true, + }); + const pendingRequests = resolveWorkspaceServiceControlRequests(built, "stop", null); + + const entries = buildWorkspaceServiceControlEntries({ sections: built, pendingRequests }); + + expect(entries.map((entry) => entry.state)).toEqual(["stopping", "stopping"]); + }); + + it("builds a failure detail line from the stopped runtime service", () => { + const failed = createRuntimeService({ + id: "service-web", + serviceName: "web", + status: "failed", + stoppedAt: new Date(Date.now() - 60_000), + }); + const built = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] }, + runtimeServices: [failed], + canStartServices: true, + }); + const entries = buildWorkspaceServiceControlEntries({ + sections: built, + runtimeServices: [failed], + }); + + expect(entries[0].state).toBe("failed"); + expect(entries[0].failureDetail).toMatch(/^Service failed · /); + }); +}); + +describe("resolveWorkspaceServiceControlRequests", () => { + const mixedSections = () => buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "api", name: "api", kind: "service", command: "pnpm api" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + ], + canStartServices: true, + }); + + it("targets a single service by key", () => { + const built = mixedSections(); + const requests = resolveWorkspaceServiceControlRequests(built, "stop", built.services[0].key); + + expect(requests).toEqual([ + expect.objectContaining({ action: "stop", workspaceCommandId: "web", runtimeServiceId: "service-web" }), + ]); + }); + + it("stops only active services for the aggregate stop", () => { + const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "stop", null); + + expect(requests).toEqual([expect.objectContaining({ action: "stop", workspaceCommandId: "web" })]); + }); + + it("starts only inactive services for the aggregate start", () => { + const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "start", null); + + expect(requests).toEqual([expect.objectContaining({ action: "start", workspaceCommandId: "api" })]); + }); + + it("restarts active services and starts stopped ones for the aggregate restart", () => { + const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "restart", null); + + expect(requests).toEqual([ + expect.objectContaining({ action: "restart", workspaceCommandId: "web" }), + expect.objectContaining({ action: "start", workspaceCommandId: "api" }), + ]); + }); +}); diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index 575c18fbd4..dd4aff5fd9 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -11,6 +11,11 @@ import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; +import { timeAgo } from "@/lib/timeAgo"; +import type { + WorkspaceServiceControlAction, + WorkspaceServiceControlEntry, +} from "@/components/WorkspaceServiceControlBar"; export type WorkspaceRuntimeAction = "start" | "stop" | "restart" | "run"; @@ -202,6 +207,101 @@ export function getRunningRuntimeServiceUrl( return runningService?.url ?? null; } +function isActiveStatusLabel(statusLabel: string) { + return statusLabel === "running" || statusLabel === "starting"; +} + +/** + * Maps runtime control sections onto the fixed-geometry service control bar + * model. In-flight mutations overlay the transitional states (starting / + * stopping / restarting) that the server status enum does not carry. + */ +export function buildWorkspaceServiceControlEntries(input: { + sections: WorkspaceRuntimeControlSections; + runtimeServices?: WorkspaceRuntimeService[] | null; + isPending?: boolean; + pendingRequest?: WorkspaceRuntimeControlRequest | null; + pendingRequests?: WorkspaceRuntimeControlRequest[]; +}): WorkspaceServiceControlEntry[] { + const runtimeServicesById = new Map( + (input.runtimeServices ?? []).map((runtimeService) => [runtimeService.id, runtimeService]), + ); + const pendingRequests = input.pendingRequests + ?? (input.isPending && input.pendingRequest ? [input.pendingRequest] : []); + + return [...input.sections.services, ...input.sections.otherServices].map((item) => { + let state: WorkspaceServiceControlEntry["state"] = + item.statusLabel === "running" + ? "running" + : item.statusLabel === "starting" + ? "starting" + : item.statusLabel === "failed" + ? "failed" + : "stopped"; + + const pendingRequest = pendingRequests.find((request) => + request.action !== "run" + && (request.workspaceCommandId ?? null) === (item.workspaceCommandId ?? null) + && (request.runtimeServiceId ?? null) === (item.runtimeServiceId ?? null) + && (request.serviceIndex ?? null) === (item.serviceIndex ?? null)); + if (pendingRequest) { + state = pendingRequest.action === "stop" + ? "stopping" + : pendingRequest.action === "restart" + ? "restarting" + : "starting"; + } + + const runtimeService = item.runtimeServiceId ? runtimeServicesById.get(item.runtimeServiceId) ?? null : null; + const failureDetail = state === "failed" + ? `Service failed${runtimeService?.stoppedAt ? ` · ${timeAgo(runtimeService.stoppedAt)}` : ""}` + : null; + + return { + key: item.key, + name: item.title, + state, + healthStatus: item.healthStatus, + url: item.url, + port: item.port, + failureDetail, + canStart: item.canStart, + }; + }); +} + +/** + * Resolves a control-bar action into the runtime control requests to fire. + * A null serviceKey targets every applicable service (the aggregate bar and + * popover bulk actions). + */ +export function resolveWorkspaceServiceControlRequests( + sections: WorkspaceRuntimeControlSections, + action: WorkspaceServiceControlAction, + serviceKey: string | null, +): WorkspaceRuntimeControlRequest[] { + const items = [...sections.services, ...sections.otherServices]; + if (serviceKey !== null) { + const item = items.find((candidate) => candidate.key === serviceKey); + return item ? [buildRequest(item, action)] : []; + } + if (action === "stop") { + return items + .filter((item) => isActiveStatusLabel(item.statusLabel)) + .map((item) => buildRequest(item, "stop")); + } + if (action === "start") { + return items + .filter((item) => !isActiveStatusLabel(item.statusLabel) && item.canStart) + .map((item) => buildRequest(item, "start")); + } + return items.flatMap((item) => { + if (isActiveStatusLabel(item.statusLabel)) return [buildRequest(item, "restart")]; + if (item.canStart) return [buildRequest(item, "start")]; + return []; + }); +} + function requestMatchesPending( pendingRequest: WorkspaceRuntimeControlRequest | null | undefined, nextRequest: WorkspaceRuntimeControlRequest, diff --git a/ui/src/components/WorkspaceServiceControlBar.test.tsx b/ui/src/components/WorkspaceServiceControlBar.test.tsx new file mode 100644 index 0000000000..113f125360 --- /dev/null +++ b/ui/src/components/WorkspaceServiceControlBar.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("WorkspaceServiceControlBar", () => { + let container: HTMLDivElement; + let root: Root; + let writeText: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + }); + + afterEach(async () => { + await act(() => root.unmount()); + document.body.innerHTML = ""; + }); + + async function renderRunningService() { + await act(() => { + root.render( + {}} + />, + ); + }); + return container.querySelector('button[aria-label="Copy URL"]')!; + } + + it("shows success only after the URL reaches the clipboard", async () => { + const copyButton = await renderRunningService(); + + await act(async () => { + copyButton.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("http://127.0.0.1:3100"); + expect(copyButton.getAttribute("aria-label")).toBe("URL copied"); + }); + + it("shows failure when the clipboard rejects the write", async () => { + writeText.mockRejectedValueOnce(new Error("permission denied")); + const copyButton = await renderRunningService(); + + await act(async () => { + copyButton.click(); + await Promise.resolve(); + }); + + expect(copyButton.getAttribute("aria-label")).toBe("Copy failed"); + expect(copyButton.querySelector(".text-destructive")).not.toBeNull(); + }); + + it("reserves the desktop URL segment across service states", async () => { + const renderService = async (state: "stopped" | "running", url: string | null) => { + await act(() => { + root.render( + {}} + />, + ); + }); + + const urlText = state === "running" + ? container.querySelector('a[href="http://127.0.0.1:3100"]') + : Array.from(container.querySelectorAll("span")).find((element) => element.textContent === ":3100"); + return urlText?.parentElement; + }; + + const stoppedSegment = await renderService("stopped", null); + expect(stoppedSegment).not.toBeNull(); + expect(stoppedSegment?.classList.contains("w-56")).toBe(true); + expect(stoppedSegment?.classList.contains("shrink-0")).toBe(true); + + const runningSegment = await renderService("running", "http://127.0.0.1:3100"); + expect(runningSegment).not.toBeNull(); + expect(runningSegment?.className).toBe(stoppedSegment?.className); + }); +}); diff --git a/ui/src/components/WorkspaceServiceControlBar.tsx b/ui/src/components/WorkspaceServiceControlBar.tsx new file mode 100644 index 0000000000..b2336baeca --- /dev/null +++ b/ui/src/components/WorkspaceServiceControlBar.tsx @@ -0,0 +1,532 @@ +import { useEffect, useRef, useState } from "react"; +import { + Check, + ChevronDown, + Copy, + ExternalLink, + Loader2, + Play, + RotateCcw, + Square, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export type WorkspaceServiceControlState = + | "stopped" + | "starting" + | "running" + | "stopping" + | "restarting" + | "failed"; + +export type WorkspaceServiceControlAction = "start" | "stop" | "restart"; + +export type WorkspaceServiceControlEntry = { + key: string; + name: string; + state: WorkspaceServiceControlState; + healthStatus?: "unknown" | "healthy" | "unhealthy" | null; + url?: string | null; + port?: number | null; + /** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */ + failureDetail?: string | null; + canStart?: boolean; +}; + +export type WorkspaceServiceControlBarProps = { + services: WorkspaceServiceControlEntry[]; + /** serviceKey is null when the action targets all services (aggregate bar / popover footer). */ + onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; + onViewLogs?: () => void; + /** Optional link target for "Manage in Services tab" in the multi-service popover. */ + onManageServices?: () => void; + /** Initial open state for the multi-service popover (used by Storybook/static captures). */ + defaultServicesOpen?: boolean; + className?: string; +}; + +const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["starting", "stopping", "restarting"]; + +function isTransitional(state: WorkspaceServiceControlState) { + return TRANSITIONAL_STATES.includes(state); +} + +function formatServiceUrl(url: string | null | undefined) { + if (!url) return null; + return url.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +function statusMeta(entry: WorkspaceServiceControlEntry): { label: string; unhealthy: boolean } { + switch (entry.state) { + case "starting": + return { label: "Starting…", unhealthy: false }; + case "stopping": + return { label: "Stopping…", unhealthy: false }; + case "restarting": + return { label: "Restarting…", unhealthy: false }; + case "failed": + return { label: "Failed", unhealthy: false }; + case "running": + return entry.healthStatus === "unhealthy" + ? { label: "Unhealthy", unhealthy: true } + : { label: "Running", unhealthy: false }; + default: + return { label: "Stopped", unhealthy: false }; + } +} + +function StatusIndicator({ entry, className }: { entry: WorkspaceServiceControlEntry; className?: string }) { + if (isTransitional(entry.state)) { + return ; + } + if (entry.state === "failed") { + return ; + } + const unhealthy = entry.state === "running" && entry.healthStatus === "unhealthy"; + return ( + + ); +} + +function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); + const timeoutRef = useRef | null>(null); + useEffect(() => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, []); + const copyLabel = copyState === "copied" ? "URL copied" : copyState === "failed" ? "Copy failed" : "Copy URL"; + return ( + + ); +} + +function UrlSegment({ entry, compact }: { entry: WorkspaceServiceControlEntry; compact?: boolean }) { + const displayUrl = formatServiceUrl(entry.url) ?? (entry.port ? `:${entry.port}` : null); + const live = entry.state === "running" && Boolean(entry.url); + + if (!displayUrl) { + return no url; + } + return ( + <> + {live ? ( + + {displayUrl} + + ) : ( + + {displayUrl} + + )} + + + + + + ); +} + +function ActionSlots({ + entry, + onAction, +}: { + entry: Pick; + onAction: (action: WorkspaceServiceControlAction) => void; +}) { + const transitional = isTransitional(entry.state); + const canStart = entry.canStart ?? true; + + if (entry.state === "stopped") { + return ( + + ); + } + + if (entry.state === "failed") { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} + +function FailureDetail({ + entry, + onViewLogs, +}: { + entry: WorkspaceServiceControlEntry; + onViewLogs?: () => void; +}) { + if (entry.state !== "failed" || !entry.failureDetail) return null; + return ( +
+ {entry.failureDetail} + {onViewLogs ? ( + <> + · + + + ) : null} +
+ ); +} + +function SingleServiceBar({ + entry, + onAction, + onViewLogs, + className, +}: { + entry: WorkspaceServiceControlEntry; + onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; + onViewLogs?: () => void; + className?: string; +}) { + const meta = statusMeta(entry); + return ( +
+
+
+
+ + {meta.label} +
+
+
+ +
+
+
+ onAction(action, entry.key)} + /> +
+
+
+ +
+
+ +
+ ); +} + +function ServicePopoverRow({ + entry, + onAction, +}: { + entry: WorkspaceServiceControlEntry; + onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; +}) { + const meta = statusMeta(entry); + const displayUrl = formatServiceUrl(entry.url); + const live = entry.state === "running" && Boolean(entry.url); + const secondary = live + ? displayUrl + : entry.state === "starting" && entry.port + ? `starting on :${entry.port}…` + : entry.state === "failed" && entry.failureDetail + ? entry.failureDetail + : `${meta.label.toLowerCase().replace(/…$/, "")}${entry.port ? ` · :${entry.port}` : ""}`; + + return ( +
+ +
+
{entry.name}
+
+ {live && entry.url ? ( + <> + + {displayUrl} + + + + ) : ( + {secondary} + )} +
+
+
+ onAction(action, entry.key)} /> +
+
+ ); +} + +function MultiServiceBar({ + services, + onAction, + onManageServices, + defaultServicesOpen, + className, +}: { + services: WorkspaceServiceControlEntry[]; + onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; + onManageServices?: () => void; + defaultServicesOpen?: boolean; + className?: string; +}) { + const [open, setOpen] = useState(defaultServicesOpen ?? false); + const runningCount = services.filter((entry) => entry.state === "running").length; + const anyTransitional = services.some((entry) => isTransitional(entry.state)); + const anyFailed = services.some((entry) => entry.state === "failed"); + const anyRunning = runningCount > 0; + const primary = services.find((entry) => entry.state === "running" && entry.url) ?? null; + + const aggregateEntry: WorkspaceServiceControlEntry = { + key: "__all__", + name: "All services", + state: anyTransitional + ? "starting" + : anyFailed + ? "failed" + : anyRunning + ? "running" + : "stopped", + healthStatus: services.some((entry) => entry.state === "running" && entry.healthStatus === "unhealthy") + ? "unhealthy" + : "healthy", + }; + + return ( +
+
+
+ + + + + event.preventDefault()}> +
+ Services · {services.length} +
+
+ {services.map((entry) => ( + + ))} +
+
+ + + + {onManageServices ? ( + + ) : null} +
+
+
+
+
+ {primary ? ( + <> + {primary.name} + + + ) : ( + no url + )} +
+
+
+ onAction(action, null)} + /> +
+
+ {primary ? ( +
+ +
+ ) : null} +
+
+ ); +} + +/** + * Segmented control bar for execution-workspace services: status · URL · actions. + * Geometry is identical in every state — transitions are announced by the status + * segment (spinner + label) instead of buttons appearing and disappearing. + */ +export function WorkspaceServiceControlBar({ + services, + onAction, + onViewLogs, + onManageServices, + defaultServicesOpen, + className, +}: WorkspaceServiceControlBarProps) { + if (services.length === 0) return null; + if (services.length === 1) { + return ( + + ); + } + return ( + + ); +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index c96aad1120..ff9100aaaf 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -93,9 +93,13 @@ vi.mock("../components/RoutineRunVariablesDialog", () => ({ })); vi.mock("../components/WorkspaceRuntimeControls", () => ({ buildWorkspaceRuntimeControlSections: () => [], - WorkspaceRuntimeQuickControls: () =>
, + buildWorkspaceServiceControlEntries: () => [], + resolveWorkspaceServiceControlRequests: () => [], WorkspaceRuntimeControls: () =>
, })); +vi.mock("../components/WorkspaceServiceControlBar", () => ({ + WorkspaceServiceControlBar: () =>
, +})); vi.mock("../components/PageTabBar", () => ({ PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index c2cac0b629..4b0af05f48 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -28,10 +28,12 @@ import { } from "../components/RoutineRunVariablesDialog"; import { buildWorkspaceRuntimeControlSections, - WorkspaceRuntimeQuickControls, + buildWorkspaceServiceControlEntries, + resolveWorkspaceServiceControlRequests, WorkspaceRuntimeControls, type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; +import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceControlBar"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; @@ -694,6 +696,7 @@ export function ExecutionWorkspaceDetail() { const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); + const [pendingRuntimeActions, setPendingRuntimeActions] = useState([]); const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; const pluginTabFromSearch = useMemo(() => { const tab = new URLSearchParams(location.search).get("tab"); @@ -804,6 +807,7 @@ export function ExecutionWorkspaceDetail() { setForm(formStateFromWorkspace(workspace)); setErrorMessage(null); setRuntimeActionErrorMessage(null); + setPendingRuntimeActions([]); }, [workspace]); useEffect(() => { @@ -864,6 +868,9 @@ export function ExecutionWorkspaceDetail() { setRuntimeActionMessage(null); setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); }, + onSettled: (_result, _error, request) => { + setPendingRuntimeActions((current) => current.filter((pendingRequest) => pendingRequest !== request)); + }, }); if (workspaceQuery.isLoading) return

Loading workspace…

; @@ -885,6 +892,11 @@ export function ExecutionWorkspaceDetail() { canRunJobs: canRunWorkspaceCommands, }); const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; + const serviceControlEntries = buildWorkspaceServiceControlEntries({ + sections: runtimeControlSections, + runtimeServices: workspace.runtimeServices ?? [], + pendingRequests: pendingRuntimeActions, + }); const pluginSlotContext = { companyId: workspace.companyId, @@ -925,6 +937,12 @@ export function ExecutionWorkspaceDetail() { updateWorkspace.mutate(patch); }; + const runRuntimeControlRequests = (requests: WorkspaceRuntimeControlRequest[]) => { + if (requests.length === 0) return; + setPendingRuntimeActions((current) => [...current, ...requests]); + for (const request of requests) controlRuntimeServices.mutate(request); + }; + return ( <>
@@ -935,11 +953,15 @@ export function ExecutionWorkspaceDetail() {

{workspace.name}

- controlRuntimeServices.mutate(request)} + { + runRuntimeControlRequests( + resolveWorkspaceServiceControlRequests(runtimeControlSections, action, serviceKey), + ); + }} + onViewLogs={() => handleTabChange("runtime_logs")} + onManageServices={() => handleTabChange("services")} />
{runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} @@ -979,7 +1001,7 @@ export function ExecutionWorkspaceDetail() { ? null : "Execution workspaces need a working directory before local commands can run, and services also need runtime config." } - onAction={(request) => controlRuntimeServices.mutate(request)} + onAction={(request) => runRuntimeControlRequests([request])} /> ) : activeTab === "configuration" ? (
diff --git a/ui/storybook/stories/workspace-service-control-bar.stories.tsx b/ui/storybook/stories/workspace-service-control-bar.stories.tsx new file mode 100644 index 0000000000..b259ef0f80 --- /dev/null +++ b/ui/storybook/stories/workspace-service-control-bar.stories.tsx @@ -0,0 +1,213 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + WorkspaceServiceControlBar, + type WorkspaceServiceControlEntry, +} from "@/components/WorkspaceServiceControlBar"; + +const noop = () => {}; + +function entry(overrides: Partial = {}): WorkspaceServiceControlEntry { + return { + key: "svc-dev", + name: "dev", + state: "running", + healthStatus: "healthy", + url: "http://paperclip-dev:45439", + port: 45439, + canStart: true, + ...overrides, + }; +} + +const meta: Meta = { + title: "Workspaces/Service control bar", + component: WorkspaceServiceControlBar, + parameters: { + layout: "padded", + }, + args: { + onAction: noop, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Running: Story = { + args: { services: [entry()] }, +}; + +export const Stopped: Story = { + args: { services: [entry({ state: "stopped" })] }, +}; + +export const Starting: Story = { + args: { services: [entry({ state: "starting" })] }, +}; + +export const Stopping: Story = { + args: { services: [entry({ state: "stopping" })] }, +}; + +export const Restarting: Story = { + args: { services: [entry({ state: "restarting" })] }, +}; + +export const RunningUnhealthy: Story = { + name: "Running · unhealthy", + args: { services: [entry({ healthStatus: "unhealthy" })] }, +}; + +export const Failed: Story = { + args: { + services: [ + entry({ + state: "failed", + failureDetail: "dev exited with code 1, 12s ago", + }), + ], + onViewLogs: noop, + }, +}; + +export const StartDisabled: Story = { + name: "Stopped · start unavailable", + args: { services: [entry({ state: "stopped", canStart: false })] }, +}; + +export const LongUrl: Story = { + name: "Running · long URL truncates", + args: { + services: [ + entry({ + url: "https://pap-14233-execution-workspace-service-start-stop.preview.paperclip.ing/deeply/nested/path", + }), + ], + }, +}; + +const MULTI_SERVICES: WorkspaceServiceControlEntry[] = [ + entry({ key: "svc-web", name: "web" }), + entry({ key: "svc-api", name: "api", state: "starting", url: null, port: 8080 }), + entry({ key: "svc-worker", name: "worker", state: "stopped", url: null, port: null }), +]; + +export const MultiService: Story = { + name: "Multiple services (collapsed)", + args: { services: MULTI_SERVICES }, +}; + +export const MultiServiceOpen: Story = { + name: "Multiple services (popover open)", + args: { + services: MULTI_SERVICES, + defaultServicesOpen: true, + onManageServices: noop, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const HeaderContext: Story = { + name: "In header context", + render: (args) => ( +
+
+
+
+ Execution workspace +
+

+ PAP-14025-skills-need-to-be-organized-in-folders-the-ta… +

+
+ +
+
+ Tasks + Services + Configuration + Runtime logs + Runs +
+
+ ), + args: { services: [entry()] }, +}; + +export const HeaderContextFailed: Story = { + name: "In header context · failed", + render: (args) => ( +
+
+
+
+ Execution workspace +
+

+ PAP-14025-skills-need-to-be-organized-in-folders-the-ta… +

+
+ +
+
+ Tasks + Services + Configuration +
+
+ ), + args: { + services: [ + entry({ + state: "failed", + failureDetail: "dev exited with code 1, 12s ago", + }), + ], + onViewLogs: noop, + }, +}; + +export const MobileWidth: Story = { + name: "Mobile width (two-row card)", + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { services: [entry()] }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + }, +}; + +export const AllStates: Story = { + name: "All states (overview)", + render: () => ( +
+ {( + [ + ["Stopped", entry({ state: "stopped" })], + ["Starting", entry({ state: "starting" })], + ["Running", entry()], + ["Unhealthy", entry({ healthStatus: "unhealthy" })], + ["Stopping", entry({ state: "stopping" })], + ["Restarting", entry({ state: "restarting" })], + ["Failed", entry({ state: "failed", failureDetail: "dev exited with code 1, 12s ago" })], + ] as const + ).map(([label, service]) => ( +
+ {label} + +
+ ))} +
+ ), +};