feat(ui): stabilize workspace service controls (#9705)

## 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 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-16 21:07:04 -05:00 committed by GitHub
parent b07b2994cc
commit 5d42382df4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1135 additions and 8 deletions

View File

@ -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" }),
]);
});
});

View File

@ -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,

View File

@ -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<typeof vi.fn>;
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(
<WorkspaceServiceControlBar
services={[{
key: "web",
name: "Web",
state: "running",
healthStatus: "healthy",
url: "http://127.0.0.1:3100",
}]}
onAction={() => {}}
/>,
);
});
return container.querySelector<HTMLButtonElement>('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(
<WorkspaceServiceControlBar
services={[{
key: "web",
name: "Web",
state,
healthStatus: state === "running" ? "healthy" : null,
url,
port: 3100,
}]}
onAction={() => {}}
/>,
);
});
const urlText = state === "running"
? container.querySelector<HTMLAnchorElement>('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);
});
});

View File

@ -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 <Loader2 className={cn("size-3 shrink-0 animate-spin text-muted-foreground", className)} />;
}
if (entry.state === "failed") {
return <TriangleAlert className={cn("size-3 shrink-0 text-destructive", className)} />;
}
const unhealthy = entry.state === "running" && entry.healthStatus === "unhealthy";
return (
<span
className={cn(
"size-2 shrink-0 rounded-full",
entry.state === "running"
? unhealthy
? "bg-amber-500 ring-2 ring-amber-500/30"
: "bg-emerald-500"
: "border border-muted-foreground/60 bg-transparent",
className,
)}
/>
);
}
function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
const copyLabel = copyState === "copied" ? "URL copied" : copyState === "failed" ? "Copy failed" : "Copy URL";
return (
<Button
variant="ghost"
size="icon-xs"
disabled={disabled}
aria-label={copyLabel}
title={copyLabel}
className="text-muted-foreground hover:text-foreground"
onClick={async () => {
try {
if (!navigator.clipboard) throw new Error("Clipboard API unavailable");
await navigator.clipboard.writeText(url);
setCopyState("copied");
} catch {
setCopyState("failed");
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopyState("idle"), 1500);
}}
>
{copyState === "copied" ? (
<Check className="size-3" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3 text-destructive" />
) : (
<Copy className="size-3" />
)}
<span className="sr-only" aria-live="polite">{copyLabel}</span>
</Button>
);
}
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 <span className="font-mono text-xs text-muted-foreground/70">no url</span>;
}
return (
<>
{live ? (
<a
href={entry.url ?? undefined}
target="_blank"
rel="noreferrer"
title={entry.url ?? undefined}
className={cn("min-w-0 truncate font-mono text-xs text-foreground hover:underline", compact ? "max-w-44" : "max-w-56")}
>
{displayUrl}
</a>
) : (
<span
title={entry.url ?? undefined}
className={cn("min-w-0 truncate font-mono text-xs text-muted-foreground/70", compact ? "max-w-44" : "max-w-56")}
>
{displayUrl}
</span>
)}
<span className={cn("flex items-center", live ? null : "invisible")} aria-hidden={live ? undefined : true}>
<CopyUrlButton url={entry.url ?? ""} disabled={!live} />
<Button
asChild={live}
variant="ghost"
size="icon-xs"
disabled={!live}
className="text-muted-foreground hover:text-foreground"
title="Open in new tab"
>
{live ? (
<a href={entry.url ?? undefined} target="_blank" rel="noreferrer" aria-label="Open in new tab">
<ExternalLink className="size-3" />
</a>
) : (
<ExternalLink className="size-3" />
)}
</Button>
</span>
</>
);
}
function ActionSlots({
entry,
onAction,
}: {
entry: Pick<WorkspaceServiceControlEntry, "state" | "canStart">;
onAction: (action: WorkspaceServiceControlAction) => void;
}) {
const transitional = isTransitional(entry.state);
const canStart = entry.canStart ?? true;
if (entry.state === "stopped") {
return (
<Button
variant="cta"
size="xs"
className="w-13 justify-center"
disabled={!canStart}
onClick={() => onAction("start")}
aria-label="Start"
title="Start"
>
<Play className="size-3" />
Start
</Button>
);
}
if (entry.state === "failed") {
return (
<>
<Button
variant="cta"
size="icon-xs"
disabled={!canStart}
onClick={() => onAction("start")}
aria-label="Start"
title="Start"
>
<Play className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={!canStart}
onClick={() => onAction("restart")}
aria-label="Restart"
title="Restart"
className="border border-border text-foreground"
>
<RotateCcw className="size-3" />
</Button>
</>
);
}
return (
<>
<Button
variant="ghost"
size="icon-xs"
disabled={transitional}
onClick={() => onAction("stop")}
aria-label="Stop"
title="Stop"
className="border border-border text-foreground"
>
<Square className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={transitional || !canStart}
onClick={() => onAction("restart")}
aria-label="Restart"
title="Restart"
className="border border-border text-foreground"
>
<RotateCcw className="size-3" />
</Button>
</>
);
}
function FailureDetail({
entry,
onViewLogs,
}: {
entry: WorkspaceServiceControlEntry;
onViewLogs?: () => void;
}) {
if (entry.state !== "failed" || !entry.failureDetail) return null;
return (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{entry.failureDetail}</span>
{onViewLogs ? (
<>
<span aria-hidden>·</span>
<button
type="button"
onClick={onViewLogs}
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80"
>
View logs
</button>
</>
) : null}
</div>
);
}
function SingleServiceBar({
entry,
onAction,
onViewLogs,
className,
}: {
entry: WorkspaceServiceControlEntry;
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
onViewLogs?: () => void;
className?: string;
}) {
const meta = statusMeta(entry);
return (
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
<div className="rounded-lg border border-border bg-background">
<div className="flex h-9 items-center pl-3 pr-1.5">
<div className="flex items-center gap-2 sm:min-w-24">
<StatusIndicator entry={entry} />
<span className="whitespace-nowrap text-xs font-medium text-foreground">{meta.label}</span>
</div>
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
<div className="hidden w-56 min-w-0 shrink-0 items-center gap-0.5 sm:flex">
<UrlSegment entry={entry} />
</div>
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
<ActionSlots
entry={entry}
onAction={(action) => onAction(action, entry.key)}
/>
</div>
</div>
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
<UrlSegment entry={entry} compact />
</div>
</div>
<FailureDetail entry={entry} onViewLogs={onViewLogs} />
</div>
);
}
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 (
<div className="flex items-center gap-3 py-2.5">
<StatusIndicator entry={entry} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{entry.name}</div>
<div className="flex min-w-0 items-center gap-0.5">
{live && entry.url ? (
<>
<a
href={entry.url}
target="_blank"
rel="noreferrer"
title={entry.url}
className="min-w-0 truncate font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{displayUrl}
</a>
<CopyUrlButton url={entry.url} />
</>
) : (
<span className="min-w-0 truncate text-xs text-muted-foreground">{secondary}</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<ActionSlots entry={entry} onAction={(action) => onAction(action, entry.key)} />
</div>
</div>
);
}
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 (
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
<div className="rounded-lg border border-border bg-background">
<div className="flex h-9 items-center pl-3 pr-1.5">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-full items-center gap-2 rounded-l-lg pr-1 text-xs font-medium text-foreground hover:bg-accent"
aria-label={`${runningCount} of ${services.length} services running — show services`}
>
<StatusIndicator entry={aggregateEntry} />
<span className="whitespace-nowrap">{runningCount}/{services.length} running</span>
<ChevronDown className="size-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96 p-0" onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="px-4 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Services · {services.length}
</div>
<div className="divide-y divide-border px-4">
{services.map((entry) => (
<ServicePopoverRow key={entry.key} entry={entry} onAction={onAction} />
))}
</div>
<div className="flex items-center gap-1 border-t border-border px-4 py-2">
<Button variant="ghost" size="xs" onClick={() => onAction("start", null)}>Start all</Button>
<Button variant="ghost" size="xs" onClick={() => onAction("stop", null)}>Stop all</Button>
<Button variant="ghost" size="xs" onClick={() => onAction("restart", null)}>Restart all</Button>
{onManageServices ? (
<Button
variant="link"
size="xs"
className="ml-auto text-muted-foreground"
onClick={onManageServices}
>
Manage in Services tab
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
<div className="hidden min-w-0 items-center gap-0.5 sm:flex">
{primary ? (
<>
<span className="mr-1 shrink-0 text-xs text-muted-foreground">{primary.name}</span>
<UrlSegment entry={primary} />
</>
) : (
<span className="font-mono text-xs text-muted-foreground/70">no url</span>
)}
</div>
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
<ActionSlots
entry={{ state: aggregateEntry.state, canStart: true }}
onAction={(action) => onAction(action, null)}
/>
</div>
</div>
{primary ? (
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
<UrlSegment entry={primary} compact />
</div>
) : null}
</div>
</div>
);
}
/**
* 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 (
<SingleServiceBar
entry={services[0]}
onAction={onAction}
onViewLogs={onViewLogs}
className={className}
/>
);
}
return (
<MultiServiceBar
services={services}
onAction={onAction}
onManageServices={onManageServices}
defaultServicesOpen={defaultServicesOpen}
className={className}
/>
);
}

View File

@ -93,9 +93,13 @@ vi.mock("../components/RoutineRunVariablesDialog", () => ({
}));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeQuickControls: () => <div data-testid="runtime-quick-controls" />,
buildWorkspaceServiceControlEntries: () => [],
resolveWorkspaceServiceControlRequests: () => [],
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("../components/WorkspaceServiceControlBar", () => ({
WorkspaceServiceControlBar: () => <div data-testid="service-control-bar" />,
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div data-testid="page-tab-bar">

View File

@ -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<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const [pendingRuntimeActions, setPendingRuntimeActions] = useState<WorkspaceRuntimeControlRequest[]>([]);
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 <p className="text-sm text-muted-foreground">Loading workspace</p>;
@ -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 (
<>
<div className="space-y-4 overflow-hidden sm:space-y-6">
@ -935,11 +953,15 @@ export function ExecutionWorkspaceDetail() {
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
<WorkspaceRuntimeQuickControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
onAction={(request) => controlRuntimeServices.mutate(request)}
<WorkspaceServiceControlBar
services={serviceControlEntries}
onAction={(action, serviceKey) => {
runRuntimeControlRequests(
resolveWorkspaceServiceControlRequests(runtimeControlSections, action, serviceKey),
);
}}
onViewLogs={() => handleTabChange("runtime_logs")}
onManageServices={() => handleTabChange("services")}
/>
</div>
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : 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" ? (
<div className="space-y-4 sm:space-y-6">

View File

@ -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> = {}): WorkspaceServiceControlEntry {
return {
key: "svc-dev",
name: "dev",
state: "running",
healthStatus: "healthy",
url: "http://paperclip-dev:45439",
port: 45439,
canStart: true,
...overrides,
};
}
const meta: Meta<typeof WorkspaceServiceControlBar> = {
title: "Workspaces/Service control bar",
component: WorkspaceServiceControlBar,
parameters: {
layout: "padded",
},
args: {
onAction: noop,
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceServiceControlBar>;
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) => (
<div className="flex min-h-96 justify-end pr-4">
<Story />
</div>
),
],
};
export const HeaderContext: Story = {
name: "In header context",
render: (args) => (
<div className="max-w-5xl rounded-xl border border-border bg-background p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Execution workspace
</div>
<h1 className="mt-1 truncate text-2xl font-bold text-foreground">
PAP-14025-skills-need-to-be-organized-in-folders-the-ta
</h1>
</div>
<WorkspaceServiceControlBar {...args} />
</div>
<div className="mt-8 flex gap-6 border-b border-border pb-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Tasks</span>
<span>Services</span>
<span>Configuration</span>
<span>Runtime logs</span>
<span>Runs</span>
</div>
</div>
),
args: { services: [entry()] },
};
export const HeaderContextFailed: Story = {
name: "In header context · failed",
render: (args) => (
<div className="max-w-5xl rounded-xl border border-border bg-background p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Execution workspace
</div>
<h1 className="mt-1 truncate text-2xl font-bold text-foreground">
PAP-14025-skills-need-to-be-organized-in-folders-the-ta
</h1>
</div>
<WorkspaceServiceControlBar {...args} />
</div>
<div className="mt-8 flex gap-6 border-b border-border pb-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Tasks</span>
<span>Services</span>
<span>Configuration</span>
</div>
</div>
),
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) => (
<div className="w-80 rounded-xl border border-dashed border-border p-3">
<Story />
</div>
),
],
args: { services: [entry()] },
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
export const AllStates: Story = {
name: "All states (overview)",
render: () => (
<div className="flex max-w-2xl flex-col items-end gap-3">
{(
[
["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]) => (
<div key={label} className="flex w-full items-center justify-between gap-6">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<WorkspaceServiceControlBar services={[service]} onAction={noop} onViewLogs={noop} />
</div>
))}
</div>
),
};