From 94a0aa77261682ce49ea3c57e2ee080e99e5e3d0 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 19:38:07 -0700 Subject: [PATCH] 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 --- docs/deploy/environment-variables.md | 10 +++ packages/shared/src/index.ts | 2 + .../shared/src/settings-visibility.test.ts | 5 ++ packages/shared/src/settings-visibility.ts | 6 ++ ui/src/components/IssueProperties.test.tsx | 18 ++++- .../IssueRecoveryActionCard.test.tsx | 13 ++++ ui/src/components/IssueRecoveryActionCard.tsx | 5 +- ui/src/components/IssueWorkspaceCard.test.tsx | 29 ++++++++ ui/src/components/IssueWorkspaceCard.tsx | 67 ++++++++++--------- ui/src/components/NewIssueDialog.test.tsx | 50 +++++++++++++- ui/src/components/NewIssueDialog.tsx | 18 +++-- .../ProjectProperties.concurrency.test.tsx | 16 ++++- ui/src/components/ProjectProperties.tsx | 4 +- .../RoutineRunVariablesDialog.test.tsx | 64 +++++++++--------- .../components/RoutineRunVariablesDialog.tsx | 10 ++- .../issue-properties/IssueProperties.tsx | 8 ++- .../useWorkspaceIsolationControls.test.tsx | 47 +++++++++++++ ui/src/hooks/useWorkspaceIsolationControls.ts | 7 ++ .../pages/ExecutionWorkspaceDetail.test.tsx | 17 ++++- ui/src/pages/ExecutionWorkspaceDetail.tsx | 16 ++++- ui/src/pages/PipelineSettings.tsx | 8 ++- 21 files changed, 336 insertions(+), 84 deletions(-) create mode 100644 ui/src/hooks/useWorkspaceIsolationControls.test.tsx create mode 100644 ui/src/hooks/useWorkspaceIsolationControls.ts diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b2985bd7a9..286f42f8f6 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -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 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 085fb0411a..8260662b3d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 { diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 0184922a9d..0a657065fa 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -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: [] }); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 6729d4d062..3b1e104739 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -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, diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6b3492230e..6d4ef3258f 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -438,12 +438,13 @@ function createExecutionState(overrides: Partial = {}): Iss }; } -function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps) { +function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps, 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({ diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index b8ba6701bb..d8480fb51c 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -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(); + 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( diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 4f4736f263..8e7b7047be 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -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. diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 3e744ebef6..7d88e8ddfa 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -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()); + 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(); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 8f82c5360f..372fec857c 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -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 (
@@ -346,37 +349,39 @@ export function IssueWorkspaceCard({ : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} {workspace ? statusBadge(workspace.status) : statusBadge("idle")}
-
- {showEditingControls ? ( - <> + {workspaceIsolationControlsVisible && ( +
+ {showEditingControls ? ( + <> + + + + ) : ( - - - ) : ( - - )} -
+ )} +
+ )} {/* Read-only info */} @@ -448,7 +453,7 @@ export function IssueWorkspaceCard({ )} {/* Editing controls */} - {editing && ( + {editing && workspaceIsolationControlsVisible && (