feat(ui): add shared workspace concurrency select to workspace policy editor (#10771)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The project workspace policy editor sets how agent runs share a project's execution workspace. > - The server now has a `sharedWorkspaceConcurrency` policy (Refs #10759), but the UI had no control for it. > - Users could not choose the concurrency mode without editing the API directly. > - This pull request adds a 3-option select (Auto / Serialize / Allow) to the policy editor. > - The benefit is that users set shared-workspace concurrency in the UI, with clear helper text for each mode. ## Linked Issues or Issue Description Refs #10759 (server contract this UI drives). **Feature request** - **Is your feature request related to a problem? Please describe.** The `sharedWorkspaceConcurrency` policy field shipped on the server, but the project workspace policy editor had no control to set it. Users could not pick a concurrency mode from the UI. - **Describe the solution you would like.** Add a 3-option select (Auto / Serialize / Allow) to the execution-workspace policy editor, with helper text that explains each mode. An unset value must show as Auto. - **Describe alternatives you have considered.** A set of radio buttons was considered. A select matches the compact style of the other controls in the same editor (environment, base ref). ## What Changed - Added a "Shared workspace concurrency" select to the project execution-workspace policy editor (`ui/src/components/ProjectProperties.tsx`). - The select offers three options with helper text: - **Auto** (default): "Concurrent runs on local/SSH runners; runs take turns in cloud sandboxes." - **Serialize**: "Runs always take turns in the shared project workspace." - **Allow**: "Runs never wait for the workspace; concurrent edits are possible." - An unset or absent value shows as **Auto**. The UI writes a value only after the user picks one, so the policy round-trips as Auto until then. - Added a `SharedWorkspaceConcurrency` type import and a new `execution_workspace_shared_concurrency` save-state key. - Added a stateful Storybook story so the controlled select can be exercised. ### Screenshots **Before** (light / dark) — the editor had no concurrency control:   **After** (light / dark) — the select shows Auto by default:   **Helper text updates per option** (Serialize / Allow):   ## Verification - `pnpm --filter @paperclipai/ui typecheck` passes. - `pnpm --filter @paperclipai/shared build` passes. - Rendered the editor in Storybook (light and dark). The select shows Auto when the policy is unset. Selecting Serialize or Allow updates the helper text and the stored value. ## Risks - Low risk. UI-only change. The control is additive and only appears when isolated task checkouts are enabled. An unset value keeps the current Auto behavior, so existing projects are unaffected. ## Model Used - Claude Opus 4.8 (claude-opus-4-8), extended thinking, tool use / code execution. ## 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 - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] 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 Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
af6b32d82a
commit
18f391ef02
|
|
@ -0,0 +1,129 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import type { ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProjectProperties } from "./ProjectProperties";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(() => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
const noop = vi.hoisted(() => () => undefined);
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
|
||||
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
||||
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
|
||||
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({ enableIsolatedWorkspaces: true }) } }));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Heavy children that are unrelated to this control.
|
||||
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
|
||||
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
|
||||
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
urlKey: "project-1",
|
||||
name: "Test project",
|
||||
description: "",
|
||||
status: "in_progress",
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
env: null,
|
||||
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
|
||||
primaryWorkspace: null,
|
||||
workspaces: [],
|
||||
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
|
||||
...overrides,
|
||||
} as unknown as Project;
|
||||
}
|
||||
|
||||
function primedClient() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(project: Project, onFieldUpdate: (field: string, data: Record<string, unknown>) => void) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={primedClient()}>
|
||||
<TooltipProvider>
|
||||
<ProjectProperties project={project} onFieldUpdate={onFieldUpdate} getFieldSaveState={() => "idle"} onArchive={noop} />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function concurrencySelect(): HTMLSelectElement {
|
||||
const el = container.querySelector<HTMLSelectElement>('select[aria-label="Shared workspace concurrency"]');
|
||||
if (!el) throw new Error("Shared workspace concurrency select not found");
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("ProjectProperties — shared workspace concurrency select", () => {
|
||||
it("defaults to Auto when the policy has no sharedWorkspaceConcurrency", () => {
|
||||
render(makeProject(), vi.fn());
|
||||
expect(concurrencySelect().value).toBe("auto");
|
||||
expect(container.textContent).toContain("Concurrent runs on local/SSH runners");
|
||||
});
|
||||
|
||||
it("reflects an existing serialize value and shows its helper text", () => {
|
||||
render(
|
||||
makeProject({
|
||||
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize" },
|
||||
} as Partial<Project>),
|
||||
vi.fn(),
|
||||
);
|
||||
expect(concurrencySelect().value).toBe("serialize");
|
||||
expect(container.textContent).toContain("Runs always take turns in the shared project workspace");
|
||||
});
|
||||
|
||||
it("writes the picked value onto executionWorkspacePolicy when changed", () => {
|
||||
const onFieldUpdate = vi.fn();
|
||||
render(makeProject(), onFieldUpdate);
|
||||
const select = concurrencySelect();
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")!.set!;
|
||||
setter.call(select, "allow");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onFieldUpdate).toHaveBeenCalledWith(
|
||||
"execution_workspace_shared_concurrency",
|
||||
expect.objectContaining({
|
||||
executionWorkspacePolicy: expect.objectContaining({ sharedWorkspaceConcurrency: "allow" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { cn, formatDate } from "../lib/utils";
|
||||
import { environmentsApi } from "../api/environments";
|
||||
|
|
@ -50,6 +50,7 @@ export type ProjectConfigFieldKey =
|
|||
| "env"
|
||||
| "execution_workspace_enabled"
|
||||
| "execution_workspace_default_mode"
|
||||
| "execution_workspace_shared_concurrency"
|
||||
| "execution_workspace_environment"
|
||||
| "execution_workspace_base_ref"
|
||||
| "execution_workspace_branch_template"
|
||||
|
|
@ -58,6 +59,28 @@ export type ProjectConfigFieldKey =
|
|||
| "execution_workspace_runtime_provision_command"
|
||||
| "execution_workspace_teardown_command";
|
||||
|
||||
const SHARED_WORKSPACE_CONCURRENCY_OPTIONS: {
|
||||
value: SharedWorkspaceConcurrency;
|
||||
label: string;
|
||||
help: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
help: "Concurrent runs on local/SSH runners; runs take turns in cloud sandboxes.",
|
||||
},
|
||||
{
|
||||
value: "serialize",
|
||||
label: "Serialize",
|
||||
help: "Runs always take turns in the shared project workspace.",
|
||||
},
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allow",
|
||||
help: "Runs never wait for the workspace; concurrent edits are possible.",
|
||||
},
|
||||
];
|
||||
|
||||
function SaveIndicator({ state }: { state: ProjectFieldSaveState }) {
|
||||
if (state === "saving") {
|
||||
return (
|
||||
|
|
@ -305,6 +328,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const executionWorkspaceDefaultMode =
|
||||
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
||||
// Absent/unset round-trips as "auto" — we only write a value once the user picks one.
|
||||
const executionWorkspaceSharedConcurrency: SharedWorkspaceConcurrency =
|
||||
executionWorkspacePolicy?.sharedWorkspaceConcurrency ?? "auto";
|
||||
const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? "";
|
||||
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
||||
type: "git_worktree",
|
||||
|
|
@ -995,6 +1021,46 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span>Shared workspace concurrency</span>
|
||||
<SaveIndicator state={fieldState("execution_workspace_shared_concurrency")} />
|
||||
</label>
|
||||
</div>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
aria-label="Shared workspace concurrency"
|
||||
value={executionWorkspaceSharedConcurrency}
|
||||
onChange={(e) =>
|
||||
commitField(
|
||||
"execution_workspace_shared_concurrency",
|
||||
updateExecutionWorkspacePolicy({
|
||||
sharedWorkspaceConcurrency: e.target.value as SharedWorkspaceConcurrency,
|
||||
})!,
|
||||
)}
|
||||
>
|
||||
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-xs">
|
||||
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
|
||||
(option) => option.value === executionWorkspaceSharedConcurrency,
|
||||
)?.label}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
|
||||
(option) => option.value === executionWorkspaceSharedConcurrency,
|
||||
)?.help}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -56,3 +56,36 @@ export const IsolatedStrategy: Story = {
|
|||
</Hydrate>
|
||||
),
|
||||
};
|
||||
|
||||
// Stateful wrapper so the (controlled) shared workspace concurrency select reflects
|
||||
// the picked option — in the real app the value round-trips through a server refetch.
|
||||
function StatefulConcurrency() {
|
||||
const [project, setProject] = useState<Project>(editableProject);
|
||||
return (
|
||||
<div className="max-w-2xl rounded-lg border border-border bg-background p-4">
|
||||
<ProjectProperties
|
||||
project={project}
|
||||
onFieldUpdate={(_field, data) => {
|
||||
const nextPolicy = data.executionWorkspacePolicy;
|
||||
if (nextPolicy && typeof nextPolicy === "object") {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
executionWorkspacePolicy: nextPolicy as Project["executionWorkspacePolicy"],
|
||||
}));
|
||||
}
|
||||
}}
|
||||
getFieldSaveState={fieldState}
|
||||
onArchive={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SharedWorkspaceConcurrency: Story = {
|
||||
name: "Shared workspace concurrency select",
|
||||
render: () => (
|
||||
<Hydrate>
|
||||
<StatefulConcurrency />
|
||||
</Hydrate>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue