Files
PaperClipAI/ui/src/components/WorkspaceServiceControlBar.test.tsx
T
5d42382df4 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>
2026-07-16 21:07:04 -05:00

109 lines
3.4 KiB
TypeScript

// @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);
});
});