diff --git a/ui/src/components/ProjectProperties.concurrency.test.tsx b/ui/src/components/ProjectProperties.concurrency.test.tsx
new file mode 100644
index 0000000000..a300480e36
--- /dev/null
+++ b/ui/src/components/ProjectProperties.concurrency.test.tsx
@@ -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 }) =>
{value}
}));
+vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
+
+function makeProject(overrides: Partial = {}): 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) => void) {
+ act(() => {
+ root.render(
+
+
+ "idle"} onArchive={noop} />
+
+ ,
+ );
+ });
+}
+
+function concurrencySelect(): HTMLSelectElement {
+ const el = container.querySelector('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),
+ 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" }),
+ }),
+ );
+ });
+});
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx
index 4719af6266..16361f1c4b 100644
--- a/ui/src/components/ProjectProperties.tsx
+++ b/ui/src/components/ProjectProperties.tsx
@@ -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
/>
+
+
+
+
+ {onUpdate || onFieldUpdate ? (
+
+ ) : (
+
+ {SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
+ (option) => option.value === executionWorkspaceSharedConcurrency,
+ )?.label}
+
+ )}
+
+ {SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
+ (option) => option.value === executionWorkspaceSharedConcurrency,
+ )?.help}
+
+
+