mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## 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>
109 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|