fix(ui): hide workspace isolation controls for managed hosts (#13907)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - Workspace isolation keeps task checkouts separate.
> - Managed hosts can enable isolation and hide its experimental
toggles.
> - Project, task, and routine forms still expose choices that override
that policy.
> - This pull request adds an operator visibility key for those
controls.
> - Workspace access stays available, and execution keeps its existing
policy.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Operator control over workspace isolation settings in the UI. This
follows the settings list cleanup in #13905.

**Current behavior**

Hiding the experimental isolation toggles leaves project policy editors,
task selectors, routine and pipeline overrides, recovery actions, and
workspace configuration visible.

**Proposed behavior**

Set `PAPERCLIP_HIDDEN_SETTINGS=workspaces.isolation` to hide these
controls. Keep workspace navigation, status, files, and runtime access.
Hide experimental toggles separately. Instances that do not set this key
keep their controls.

**Reason and benefit**

Users on managed hosts should use the host's isolation default. A hidden
form must not submit a stale draft that overrides it.

## What Changed

- Add the UI-only `workspaces.isolation` key to the shared visibility
registry.
- Hide project workspace policy, task and subtask selectors, routine and
pipeline overrides, and isolated re-issue actions.
- Hide the workspace Configuration tab and redirect direct links to
workspace issues.
- Omit hidden new-task and routine overrides. Keep explicit task/subtask
workspace launch context, saved policies, and automatic branch values
for workspace routine runs.
- Wait for the health visibility policy before showing controls. Keep
workspace access and all execution APIs available.
- Document the key and test visibility, form payloads, deep links, and
unchanged workspace access.

## Verification

- All 52 CI checks pass on `50cc770d33` (two expected skips). The branch
is mergeable. Greptile is 5/5 with no unresolved comments.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm check:token-gates` passed.
- Targeted UI checks passed: 346 tests across 14 suites, including
hidden project/task controls, stale task drafts, routine branch
defaults, recovery actions, configuration deep links, and workspace
access.
- Shared settings-visibility tests passed: 11 tests.
- `pnpm test:run` was run and stopped after reproducing five failures in
unchanged server tests: two `chat-channels.integration` cases
(linked-request provenance and direct external-chat finals) and three
`company-skills-service` cases (runtime refresh, concurrent download,
and explicit update). The earlier local run for #13905 showed the same
failures. The full local suite is not claimed green. Targeted UI/shared
checks pass. All PR CI shards, including the affected chat and skills
suites, pass.
- Reviewed the diff for secrets, private links, and run artifacts.

## Risks

This key changes UI visibility only. It does not reject API calls or
change feature values. Operators must enable isolation and its default
through their existing policy mechanism. Older app versions ignore the
new key until upgraded. Removing the key restores the controls. No
schema changes.

## Model Used

OpenAI GPT-6 (Codex), with reasoning, repository tools, and test
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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally; targeted checks pass (full-suite
limitation documented above)
- [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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-23 19:38:07 -07:00
1 parent 8ee8f1fd6e
commit 94a0aa7726
21 files changed
+336 -84

No files matched your search

+10
View File
@@ -114,6 +114,16 @@ Daytona snapshot for future leases.
while the rest of the page stays up. UI-visibility only; the secret
provider-config and proposal APIs stay live for agents and integrations.
- `workspaces.isolation` hides project execution-workspace policy, task and
routine workspace selectors, pipeline workspace overrides, isolated re-issue
actions, and the execution-workspace Configuration tab (including direct
links). Workspace navigation, files, status, and runtime access stay available.
This key only controls UI visibility: it does not disable isolation, change
saved policies, or block APIs used by agents. New tasks and routine runs omit
hidden draft overrides so the server applies the existing defaults. Tasks
launched from a workspace or parent task keep that explicit context. Hide the two
experimental isolation toggles separately when the operator manages them.
Unknown keys are logged and ignored, so one list can be rolled across a fleet
of mixed app versions, and retired keys (like `instance.heartbeats`, whose
page was removed) can stay in an operator list without breaking older or
+2
View File
@@ -2680,6 +2680,7 @@ export {
HIDEABLE_GENERAL_SECTIONS,
HIDEABLE_INSTANCE_PAGES,
HIDEABLE_SETTING_KEYS,
HIDEABLE_WORKSPACE_SECTIONS,
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
UI_ONLY_GENERAL_SECTIONS,
experimentalSettingKey,
@@ -2695,6 +2696,7 @@ export {
type HideableGeneralSection,
type HideableInstancePage,
type HideableSettingKey,
type HideableWorkspaceSection,
type ParsedHiddenSettings,
} from "./settings-visibility.js";
export {
@@ -49,6 +49,11 @@ describe("hideable setting keys", () => {
});
describe("parseHiddenSettingsList", () => {
it("accepts workspace controls independently of experimental flags", () => {
expect(parseHiddenSettingsList("workspaces.isolation")).toEqual({ hidden: ["workspaces.isolation"], unknown: [] });
expect(hidesExperimentalSetting(new Set(["workspaces.isolation"]), "enableIsolatedWorkspaces")).toBe(false);
});
it("returns nothing hidden for undefined or empty input", () => {
expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] });
expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] });
@@ -99,7 +99,12 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim
return `instance.experimental.${key}`;
}
/** Workspace policy editors and selectors. UI-only; execution and APIs stay active. */
export const HIDEABLE_WORKSPACE_SECTIONS = ["workspaces.isolation"] as const;
export type HideableWorkspaceSection = (typeof HIDEABLE_WORKSPACE_SECTIONS)[number];
export type HideableSettingKey =
| HideableWorkspaceSection
| HideableInstancePage
| HideableCompanyPage
| HideableCompanySection
@@ -108,6 +113,7 @@ export type HideableSettingKey =
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
...HIDEABLE_WORKSPACE_SECTIONS,
...HIDEABLE_INSTANCE_PAGES,
...HIDEABLE_COMPANY_PAGES,
...HIDEABLE_COMPANY_SECTIONS,
+17 -1
View File
@@ -438,12 +438,13 @@ function createExecutionState(overrides: Partial<IssueExecutionState> = {}): Iss
};
}
function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>, hiddenSettings: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
const root = createRoot(container);
act(() => {
root.render(
@@ -3451,6 +3452,21 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("hides workspace selection but keeps the bound workspace accessible", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } })]);
const onUpdate = vi.fn();
const { root } = renderPropertiesWithQueryClient(container, {
issue: createIssue({ projectId: "project-1", executionWorkspaceId: "workspace-1", currentExecutionWorkspace: createExecutionWorkspace() }),
childIssues: [], onUpdate, inline: true,
}, ["workspaces.isolation"]);
await flush();
expect(container.querySelector('[data-property-label="Execution"]')).toBeNull();
expect(container.querySelector('a[href="/execution-workspaces/workspace-1"]')).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("shows the workspace picker with no bound workspace", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({
@@ -13,6 +13,10 @@ vi.mock("@/lib/router", () => ({
),
}));
const visibility = vi.hoisted(() => ({ visible: true, loaded: true }));
vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility }));
beforeEach(() => { visibility.visible = true; visibility.loaded = true; });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -458,6 +462,15 @@ describe("IssueRecoveryActionCard workspace_validation divergence", () => {
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).toBeNull();
});
it("hides the isolated re-issue action under operator visibility policy", () => {
visibility.visible = false;
const onReissueIsolated = vi.fn();
const node = render(<IssueRecoveryActionCard action={buildWorkspaceValidationAction()} onReissueIsolated={onReissueIsolated} />);
expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).toBeNull();
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(onReissueIsolated).not.toHaveBeenCalled();
});
it("offers the re-issue action and passes the live branch as the base ref", () => {
const onReissueIsolated = vi.fn();
const node = render(
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import { useMemo, useState } from "react";
import type {
@@ -993,6 +994,7 @@ export function IssueRecoveryActionCard({
variant = "full",
className,
}: IssueRecoveryActionCardProps) {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const liveness = useMemo(() => ({ scheduledRetry }), [scheduledRetry]);
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action, liveness);
const tone = STATE_TONE[cardState];
@@ -1065,6 +1067,7 @@ export function IssueRecoveryActionCard({
});
const reissueBaseRef = divergence?.reissueBaseRef ?? null;
const showReissueAction =
workspaceIsolationControlsVisible &&
onReissueIsolated !== undefined &&
cardState !== "resolved" &&
divergence !== null &&
@@ -1096,7 +1099,7 @@ export function IssueRecoveryActionCard({
divergence !== null &&
divergence.cleanliness === "dirty";
const repairDisabledReason = repairContention
? `Held by ${contentionLabel(repairContention)} — re-issue on an isolated workspace instead.`
? `Held by ${contentionLabel(repairContention)}${showReissueAction ? " — re-issue on an isolated workspace instead." : "."}`
: null;
// When contended, the re-issue is the recommended path, so it takes the primary emphasis and a
// "Recommended" hint while the repair button is disabled.
@@ -35,6 +35,10 @@ vi.mock("@/lib/router", () => ({
),
}));
const visibility = vi.hoisted(() => ({ visible: true, loaded: true }));
vi.mock("@/hooks/useWorkspaceIsolationControls", () => ({ useWorkspaceIsolationControls: () => visibility }));
beforeEach(() => { visibility.visible = true; visibility.loaded = true; });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -146,6 +150,31 @@ describe("IssueWorkspaceCard", () => {
container.remove();
});
it("keeps workspace details and files while suppressing editing and draft writes", () => {
visibility.visible = false;
useQueryMock.mockImplementation((options: { queryKey: unknown[] }) => ({
data: options.queryKey[0] === "instance" ? { enableIsolatedWorkspaces: true } : [],
}));
const root = createRoot(container);
const onUpdate = vi.fn();
const onDraftChange = vi.fn();
const onBrowseFiles = vi.fn();
act(() => root.render(<IssueWorkspaceCard
issue={createIssue({ currentExecutionWorkspace: createExecutionWorkspace() })}
project={{ id: "project-1", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } }}
initialEditing livePreview onUpdate={onUpdate} onDraftChange={onDraftChange} onBrowseFiles={onBrowseFiles}
/>));
expect(container.querySelector("select")).toBeNull();
expect(container.textContent).not.toContain("Save");
expect(container.textContent).toContain("View workspace details");
const browse = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Browse files"));
act(() => browse!.click());
expect(onBrowseFiles).toHaveBeenCalledOnce();
expect(onUpdate).not.toHaveBeenCalled();
expect(onDraftChange).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("clears the legacy issue environment override when reusing a workspace", () => {
const root = createRoot(container);
const onUpdate = vi.fn();
+36 -31
View File
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import type { Issue, ExecutionWorkspace } from "@paperclipai/shared";
@@ -193,6 +194,7 @@ export function IssueWorkspaceCard({
onBrowseFiles,
onOpenFileByPath,
}: IssueWorkspaceCardProps) {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const { selectedCompanyId } = useCompany();
const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(initialEditing);
@@ -236,7 +238,7 @@ export function IssueWorkspaceCard({
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing,
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing && workspaceIsolationControlsVisible,
});
const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? [];
@@ -306,15 +308,15 @@ export function IssueWorkspaceCard({
]);
useEffect(() => {
if (!onDraftChange) return;
if (!onDraftChange || !workspaceIsolationControlsVisible) return;
onDraftChange(buildWorkspaceDraftUpdate(), {
canSave: canSaveWorkspaceConfig,
workspaceBranchName: draftWorkspaceBranchName,
});
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]);
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange, workspaceIsolationControlsVisible]);
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
if (!canSaveWorkspaceConfig || !workspaceIsolationControlsVisible) return;
const update = buildWorkspaceDraftUpdate();
if (!update) return;
onUpdate(update);
@@ -322,6 +324,7 @@ export function IssueWorkspaceCard({
}, [
buildWorkspaceDraftUpdate,
canSaveWorkspaceConfig,
workspaceIsolationControlsVisible,
onUpdate,
]);
@@ -333,7 +336,7 @@ export function IssueWorkspaceCard({
if (!policyEnabled || !project) return null;
const showEditingControls = livePreview || editing;
const showEditingControls = workspaceIsolationControlsVisible && (livePreview || editing);
return (
<div className="rounded-lg border border-border p-3 space-y-2">
@@ -346,37 +349,39 @@ export function IssueWorkspaceCard({
: configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)}
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
</div>
<div className="flex items-center gap-1">
{showEditingControls ? (
<>
{workspaceIsolationControlsVisible && (
<div className="flex items-center gap-1">
{showEditingControls ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleCancel}
>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
disabled={!canSaveWorkspaceConfig}
>
Save
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleCancel}
onClick={() => setEditing(true)}
>
<X className="h-3 w-3 mr-1" />Cancel
<Pencil className="h-3 w-3 mr-1" />Edit
</Button>
<Button
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
disabled={!canSaveWorkspaceConfig}
>
Save
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => setEditing(true)}
>
<Pencil className="h-3 w-3 mr-1" />Edit
</Button>
)}
</div>
)}
</div>
)}
</div>
{/* Read-only info */}
@@ -448,7 +453,7 @@ export function IssueWorkspaceCard({
)}
{/* Editing controls */}
{editing && (
{editing && workspaceIsolationControlsVisible && (
<div className="space-y-2 pt-1">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
+49 -1
View File
@@ -5,6 +5,7 @@ import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { NewIssueDialog } from "./NewIssueDialog";
const dialogState = vi.hoisted(() => ({
@@ -297,13 +298,14 @@ async function waitForAssertion(assertion: () => void, attempts = 20) {
throw lastError;
}
function renderDialog(container: HTMLDivElement) {
function renderDialog(container: HTMLDivElement, hiddenSettings: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
const root = createRoot(container);
act(() => {
root.render(
@@ -713,6 +715,52 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("hides isolation choices and omits stale workspace draft overrides", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([{
id: "project-1", name: "Alpha", workspaces: [],
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" },
}]);
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
title: "Draft task", description: "", status: "todo", priority: "medium", assigneeValue: "",
reviewerValue: "", approverValue: "", projectId: "project-1",
selectedExecutionWorkspaceId: "stale-workspace", executionWorkspaceMode: "reuse_existing",
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false, workMode: "standard",
}));
const { root } = renderDialog(container, ["workspaces.isolation"]);
await flush();
expect(container.textContent).not.toContain("Execution workspace");
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
await typeTextareaValue(container.querySelector('textarea[placeholder="Task title"]')!, "Managed task");
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Create Task"));
act(() => create!.click());
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
const payload = mockIssuesApi.create.mock.calls[0][1];
expect(payload).not.toHaveProperty("executionWorkspacePreference");
expect(payload).not.toHaveProperty("executionWorkspaceSettings");
expect(payload).not.toHaveProperty("executionWorkspaceId");
act(() => root.unmount());
});
it.each([false, true])("keeps explicit workspace launch context when isolation controls are hidden (subtask: %s)", async (subtask) => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
projectId: "project-1", executionWorkspaceId: "workspace-context",
...(subtask ? { parentId: "parent-task", parentIdentifier: "TEST-1" } : {}),
};
const { root } = renderDialog(container, ["workspaces.isolation"]);
await flush();
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
await typeTextareaValue(container.querySelector('textarea[placeholder="Task title"]')!, "Context task");
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes(subtask ? "Create Sub-Task" : "Create Task"));
act(() => create!.click());
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({
executionWorkspaceId: "workspace-context", executionWorkspacePreference: "reuse_existing",
});
act(() => root.unmount());
});
it("applies project and execution workspace defaults for normal new issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{
+14 -4
View File
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { AgentAvatar } from "@/components/AgentAvatar";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
@@ -460,6 +461,7 @@ const IssueDescriptionEditor = memo(function IssueDescriptionEditor({
});
export function NewIssueDialog() {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const visualViewportLayout = useVisualViewportLayout(newIssueOpen);
const dialogBodyRef = useRef<HTMLDivElement>(null);
@@ -551,7 +553,7 @@ export function NewIssueDialog() {
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && Boolean(projectId),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && Boolean(projectId) && workspaceIsolationControlsVisible,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -1031,8 +1033,9 @@ export function NewIssueDialog() {
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
// Hidden selectors must not submit a restored draft over the managed default.
const executionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
workspaceIsolationControlsVisible && experimentalSettings?.enableIsolatedWorkspaces === true
? selectedProject?.executionWorkspacePolicy ?? null
: null;
const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find(
@@ -1045,6 +1048,12 @@ export function NewIssueDialog() {
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? { mode: requestedExecutionWorkspaceMode }
: null;
// A task launched from a workspace (or its parent task) keeps that explicit
// context. Draft-only choices are ignored while the selector is hidden.
const contextualWorkspaceId = !workspaceIsolationControlsVisible
&& newIssueDefaults.projectId === projectId
? newIssueDefaults.executionWorkspaceId
: undefined;
const executionPolicy = buildExecutionPolicy({
reviewerValues: reviewerValue ? [reviewerValue] : [],
approverValues: approverValue ? [approverValue] : [],
@@ -1065,10 +1074,11 @@ export function NewIssueDialog() {
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
...(executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
...(workspaceIsolationControlsVisible && executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
...(contextualWorkspaceId ? { executionWorkspaceId: contextualWorkspaceId, executionWorkspacePreference: "reuse_existing" } : {}),
...(executionPolicy ? { executionPolicy } : {}),
...(watchdogAgentId
? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } }
@@ -1842,7 +1852,7 @@ export function NewIssueDialog() {
</div>
) : null}
{currentProject && currentProjectSupportsExecutionWorkspace && (
{workspaceIsolationControlsVisible && currentProject && currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 space-y-2">
<div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div>
@@ -53,8 +53,9 @@ function makeProject(overrides: Partial<Project> = {}): Project {
} as unknown as Project;
}
function primedClient() {
function primedClient(hiddenSettings: string[] = []) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.health, { hiddenSettings });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
return client;
}
@@ -74,10 +75,10 @@ afterEach(() => {
vi.clearAllMocks();
});
function render(project: Project, onFieldUpdate: (field: string, data: Record<string, unknown>) => void) {
function render(project: Project, onFieldUpdate: (field: string, data: Record<string, unknown>) => void, hiddenSettings: string[] = []) {
act(() => {
root.render(
<QueryClientProvider client={primedClient()}>
<QueryClientProvider client={primedClient(hiddenSettings)}>
<TooltipProvider>
<ProjectProperties project={project} onFieldUpdate={onFieldUpdate} getFieldSaveState={() => "idle"} onArchive={noop} />
</TooltipProvider>
@@ -128,3 +129,12 @@ describe("ProjectProperties — shared workspace concurrency select", () => {
);
});
});
it("hides project isolation controls without rewriting its policy", () => {
const onFieldUpdate = vi.fn();
render(makeProject(), onFieldUpdate, ["workspaces.isolation"]);
expect(container.textContent).not.toContain("Execution Workspaces");
expect(container.textContent).not.toContain("Enable isolated task checkouts");
expect(container.querySelector('select[aria-label="Shared workspace concurrency"]')).toBeNull();
expect(onFieldUpdate).not.toHaveBeenCalled();
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useState, type ReactNode } from "react";
import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment";
import { Link } from "@/lib/router";
@@ -202,6 +203,7 @@ function ArchiveDangerZone({
}
export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
@@ -670,7 +672,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
)}
</div>}
{isolatedWorkspacesEnabled ? (
{isolatedWorkspacesEnabled && workspaceIsolationControlsVisible ? (
<>
<Separator className="my-4" />
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, ExecutionWorkspace, Project, RoutineVariable } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
let issueWorkspaceDraftCalls = 0;
@@ -175,14 +176,16 @@ function createExecutionWorkspace(): ExecutionWorkspace {
};
}
function createQueryClient() {
return new QueryClient({
function createQueryClient(hiddenSettings: string[] = []) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
client.setQueryData(queryKeys.health, { hiddenSettings });
return client;
}
async function renderRoutineRunDialog(container: HTMLDivElement, props: {
@@ -249,13 +252,7 @@ describe("RoutineRunVariablesDialog", () => {
it("does not loop when the workspace card reports the same draft repeatedly", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = createQueryClient();
await flushUi(() => {
root.render(
@@ -286,6 +283,31 @@ describe("RoutineRunVariablesDialog", () => {
});
});
it("hides workspace overrides while retaining an automatic workspace branch", async () => {
const root = createRoot(container);
const queryClient = createQueryClient(["workspaces.isolation"]);
const onSubmit = vi.fn();
const workspace = createExecutionWorkspace();
await flushUi(() => root.render(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog open onOpenChange={() => {}} companyId="company-1"
projects={[createProject()]} agents={[createAgent()]} defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1" defaultExecutionWorkspace={workspace}
variables={[{ name: "workspaceBranch", required: true } as RoutineVariable]}
isPending={false} onSubmit={onSubmit} />
</QueryClientProvider>,
));
await flushUi(() => {});
expect(document.body.textContent).not.toContain("Workspace card");
expect(findRunButton()?.disabled).toBe(false);
await flushUi(() => findRunButton()?.click());
expect(onSubmit).toHaveBeenCalledWith({
variables: { workspaceBranch: workspace.branchName }, assigneeAgentId: "agent-1", projectId: "project-1",
});
expect(issueWorkspaceDraftCalls).toBe(0);
await flushUi(() => root.unmount());
});
it("keeps the run disabled while a reusable workspace selection is incomplete", async () => {
issueWorkspaceDraft = null;
issueWorkspaceCanSave = false;
@@ -322,13 +344,7 @@ describe("RoutineRunVariablesDialog", () => {
it("keeps the mobile dialog bounded with an internal form scroll region", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = createQueryClient();
await flushUi(() => {
root.render(
@@ -394,13 +410,7 @@ describe("RoutineRunVariablesDialog", () => {
issueWorkspaceBranchName = "pap-1634-routine-branch";
const onSubmit = vi.fn();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = createQueryClient();
await flushUi(() => {
root.render(
@@ -481,13 +491,7 @@ describe("RoutineRunVariablesDialog", () => {
issueWorkspaceBranchName = workspace.branchName;
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const queryClient = createQueryClient();
await flushUi(() => {
root.render(
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
@@ -254,7 +255,8 @@ export function RoutineRunVariablesDialog({
retry: false,
});
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const workspaceSelectionEnabled = workspaceIsolationControlsVisible && supportsRoutineRunWorkspaceSelection(
selectedProject,
experimentalSettings?.enableIsolatedWorkspaces === true,
);
@@ -272,9 +274,11 @@ export function RoutineRunVariablesDialog({
setWorkspaceBranchName(defaultExecutionWorkspace?.branchName ?? null);
}, [defaultAssigneeAgentId, defaultExecutionWorkspace, defaultProjectId, open, projects, variables]);
const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName
const workspaceBranchAutoValue = workspaceSelectionEnabled
? workspaceBranchName
: null;
: defaultExecutionWorkspace?.projectId === selection.projectId
? defaultExecutionWorkspace?.branchName ?? null
: null;
const isAutoWorkspaceBranchVariable = useCallback(
(variable: RoutineVariable) =>
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { AgentIdentity } from "@/components/AgentIdentity";
import { AgentAvatar } from "@/components/AgentAvatar";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
@@ -530,7 +531,8 @@ export function IssueProperties({
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const issueProject = issue.project ?? currentProject;
const workspacePickerEligible = experimentalSettings?.enableIsolatedWorkspaces === true
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const workspacePickerEligible = workspaceIsolationControlsVisible && experimentalSettings?.enableIsolatedWorkspaces === true
&& Boolean(issueProject?.executionWorkspacePolicy?.enabled);
const {
data: reusableExecutionWorkspaces,
@@ -1998,8 +2000,8 @@ export function IssueProperties({
projectId: option.project.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(option.project),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: option.project.executionWorkspacePolicy?.enabled
executionWorkspacePreference: workspaceIsolationControlsVisible ? defaultMode : null,
executionWorkspaceSettings: workspaceIsolationControlsVisible && option.project.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { useWorkspaceIsolationControls } from "./useWorkspaceIsolationControls";
const getHealth = vi.hoisted(() => vi.fn());
vi.mock("@/api/health", () => ({ healthApi: { get: getHealth } }));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function Control() {
const { visible } = useWorkspaceIsolationControls();
return visible ? <button>Choose isolation</button> : null;
}
describe("workspace isolation visibility", () => {
it("stays hidden until health loads and responds to operator policy changes without fetching", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const container = document.createElement("div");
const root = createRoot(container);
const setPolicy = async (hiddenSettings: string[]) => {
await act(async () => {
client.setQueryData(queryKeys.health, { hiddenSettings });
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
try {
await act(async () => root.render(<QueryClientProvider client={client}><Control /></QueryClientProvider>));
expect(container.querySelector("button")).toBeNull();
await setPolicy(["workspaces.isolation"]);
expect(container.querySelector("button")).toBeNull();
await setPolicy([]);
expect(container.querySelector("button")).not.toBeNull();
// Hiding an experimental toggle alone does not disable its product controls.
await setPolicy(["instance.experimental.enableIsolatedWorkspaces"]);
expect(container.querySelector("button")).not.toBeNull();
await setPolicy(["workspaces.isolation"]);
expect(container.querySelector("button")).toBeNull();
expect(getHealth).not.toHaveBeenCalled();
} finally {
await act(async () => root.unmount());
client.clear();
}
});
});
@@ -0,0 +1,7 @@
import { useHiddenSettings } from "./useHiddenSettings";
/** Visibility only: hiding controls must never turn off workspace isolation. */
export function useWorkspaceIsolationControls() {
const { hidden, loaded } = useHiddenSettings();
return { visible: loaded && !hidden.has("workspaces.isolation"), loaded };
}
+16 -1
View File
@@ -5,6 +5,7 @@ import type { ExecutionWorkspace, Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -240,8 +241,9 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
mockRouteLocation.search = "";
});
async function render() {
async function render(hiddenSettings: string[] = []) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
await act(async () => {
root = createRoot(container);
root.render(
@@ -255,6 +257,19 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
});
}
it("redirects a hidden configuration deep link to the workspace", async () => {
mockRouteLocation.pathname = "/execution-workspaces/workspace-1/configuration";
await render(["workspaces.isolation"]);
expect(container.querySelector('[data-testid="navigate"]')?.textContent).toBe("/execution-workspaces/workspace-1/issues");
expect(container.textContent).not.toContain("Workspace settings");
});
it("hides configuration while keeping workspace access", async () => {
await render(["workspaces.isolation"]);
expect(container.textContent).not.toContain("Configuration");
expect(container.textContent).toContain("Services");
});
it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => {
await render();
+13 -3
View File
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -773,6 +774,7 @@ function ExecutionWorkspaceRoutinesList({
}
export function ExecutionWorkspaceDetail() {
const { visible: workspaceIsolationControlsVisible, loaded: workspaceVisibilityLoaded } = useWorkspaceIsolationControls();
const { workspaceId } = useParams<{ workspaceId: string }>();
const location = useLocation();
const navigate = useNavigate();
@@ -856,8 +858,11 @@ export function ExecutionWorkspaceDetail() {
[workspacePluginDetailSlots],
);
const workspaceTabItems = useMemo(
() => orderExecutionWorkspaceTabItems([...EXECUTION_WORKSPACE_BASE_TAB_ITEMS, ...workspacePluginTabItems]),
[workspacePluginTabItems],
() => orderExecutionWorkspaceTabItems([
...EXECUTION_WORKSPACE_BASE_TAB_ITEMS.filter((item) => item.value !== "configuration" || workspaceIsolationControlsVisible),
...workspacePluginTabItems,
]),
[workspacePluginTabItems, workspaceIsolationControlsVisible],
);
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
@@ -1006,6 +1011,11 @@ export function ExecutionWorkspaceDetail() {
};
const activePluginTab = workspacePluginTabItems.find((item) => item.value === activeTab) ?? null;
if (activeTab === "configuration" && !workspaceVisibilityLoaded) return null;
if (workspaceId && activeTab === "configuration" && !workspaceIsolationControlsVisible) {
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
if (workspaceId && activeTab === null) {
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
@@ -1440,7 +1450,7 @@ export function ExecutionWorkspaceDetail() {
</DetailRow>
<DetailRow label="Derived from">
{derivedWorkspace ? (
<Link to={executionWorkspaceTabPath(derivedWorkspace.id, "configuration")} className="hover:underline">
<Link to={executionWorkspaceTabPath(derivedWorkspace.id, workspaceIsolationControlsVisible ? "configuration" : "issues")} className="hover:underline">
{derivedWorkspace.name}
</Link>
) : workspace.derivedFromExecutionWorkspaceId ? (
+6 -2
View File
@@ -1,3 +1,4 @@
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -1559,7 +1560,9 @@ export function PipelineSettings() {
?? null,
[selectedAutomationProject, stageProjectWorkspaceId],
);
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const selectedProjectSupportsExecutionWorkspace =
workspaceIsolationControlsVisible &&
experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true
&& Boolean(selectedAutomationProject?.executionWorkspacePolicy?.enabled);
const reusableExecutionWorkspacesQuery = useQuery({
@@ -1705,11 +1708,12 @@ export function PipelineSettings() {
if (!stageProjectWorkspaceId) {
setStageProjectWorkspaceId(defaultProjectWorkspaceIdForProject(selectedAutomationProject));
}
if (!stageExecutionWorkspacePreference) {
if (workspaceIsolationControlsVisible && !stageExecutionWorkspacePreference) {
setStageExecutionWorkspacePreference(defaultExecutionWorkspaceModeForProject(selectedAutomationProject));
}
}, [
selectedAutomationProject,
workspaceIsolationControlsVisible,
stageExecutionWorkspacePreference,
stageProjectId,
stageProjectWorkspaceId,
@@ -2082,7 +2086,7 @@ export function PipelineSettings() {
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
setStageProjectId(nextProjectId);
setStageProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setStageExecutionWorkspacePreference(nextProject ? defaultExecutionWorkspaceModeForProject(nextProject) : "");
setStageExecutionWorkspacePreference(nextProject && workspaceIsolationControlsVisible ? defaultExecutionWorkspaceModeForProject(nextProject) : "");
setStageExecutionWorkspaceId("");
setStageExecutionWorkspaceSettings(null);
};