From 794b09f8341394cbd66fbb572e00fab2bad55b71 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 16:35:59 -0700 Subject: [PATCH] fix(ui): alphabetize experimental settings and hide empty groups (#13905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path Paperclip operators use Experimental settings to find and manage optional controls. New cards have accumulated outside alphabetical order, and operator-hidden cards leave empty developer and legacy sections. Sort the displayed controls within their existing groups and remove groups with no visible controls. ## Related Issues **What existing behavior does this improve?** The Instance Settings → Experimental page. **Current behavior** Agent Chat follows Cases, MCP aggregators precedes External Objects, and hidden developer/legacy controls leave empty headings. The worktree execution card also ignores its operator visibility key. **Proposed behavior** Cards appear alphabetically by their displayed title within each section. Empty developer and legacy sections disappear. The worktree execution card follows the same operator visibility policy as other experimental controls. **Reason and benefit** Operators can scan the list predictably. Hosted installations show only the controls their operator permits, without empty sections or a worktree-only exception. No matching sorting PR was found in the duplicate search. This is a small improvement to an existing settings page and does not add a roadmap feature. ## What Changed - Reorder existing cards without changing their values or mutation handlers. - Hide empty developer/legacy sections and respect the hidden worktree-execution key. - Cover alphabetical ordering, conditional isolated-workspace controls, a restricted three-control policy, and partly visible sections. - Document sorting and operator visibility. ## Verification - `pnpm --dir ui exec vitest run src/pages/InstanceExperimentalSettings.test.tsx`: 45 tests passed. - `pnpm check:token-gates`: passed. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - `pnpm --filter @paperclipai/ui typecheck`: passed. - All PR CI checks passed on `88011049be`, including the chat integration shards, full typecheck, build, browser tests, and policy checks. Greptile is 5/5 with no review threads. - Local `pnpm test:run` reported eight failures in unchanged chat, company-skills, email-channel, and workspace exposure suites. Stopped the remaining local run after CI completed successfully. Isolated chat rechecks were skipped by the host database support gate and do not count as passes. All matching CI shards passed; the full local suite is not claimed as green. - Frozen installation is blocked on the base branch by existing overrides/patch configuration drift from the lockfile. Local validation uses `pnpm@9.15.4 install --no-frozen-lockfile`; the original lockfile and manifests are unchanged in this PR. - Diff reviewed for secrets, private references, and run artifacts. ## Risks Settings only change position or visibility. Existing values, managed locks, API contracts, and feature dependencies are unchanged. Each section keeps its own alphabetical list. Reverting this change restores the previous presentation. ## 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 described the issue in-PR following the enhancement 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 the relevant 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: Paperclip --- .../board-operator/experimental-features.md | 3 + .../InstanceExperimentalSettings.test.tsx | 55 ++- ui/src/pages/InstanceExperimentalSettings.tsx | 448 +++++++++--------- 3 files changed, 288 insertions(+), 218 deletions(-) diff --git a/docs/guides/board-operator/experimental-features.md b/docs/guides/board-operator/experimental-features.md index 32602a7545c..485f9eb94ed 100644 --- a/docs/guides/board-operator/experimental-features.md +++ b/docs/guides/board-operator/experimental-features.md @@ -18,6 +18,9 @@ If you need stable behavior for an important workflow, do not rely on an experim ## Where you enable them Board operators enable or disable experiments from **Instance Settings > Experimental** in the app. +Controls are listed alphabetically within each section. Hosting operators can hide +controls they manage; empty developer and legacy sections are omitted. Hidden +controls retain their configured values. The CLI exposes the same surface: diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 0fe57b827f0..c965a20878e 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { INSTANCE_FEATURE_KEYS } from "@paperclipai/shared"; import type { InstanceExperimentalSettings as InstanceExperimentalSettingsPayload, InstanceExperimentalSettingsWithManaged, @@ -968,6 +969,17 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)" expect(sections.at(-1)?.textContent).toContain("Goals Sidebar Link"); }); + it("alphabetizes cards by their displayed title within each section", async () => { + setWorktreeRuntimeMeta(true); + await renderPage({ ...defaultExperimentalSettings(), enableIsolatedWorkspaces: true }); + + for (const section of container.querySelectorAll("section")) { + const titles = [...section.querySelectorAll("h3")].map((heading) => heading.textContent ?? ""); + expect(titles.length).toBeGreaterThan(1); + expect(titles).toEqual([...titles].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))); + } + }); + it("renders setting cards without a background color", async () => { await renderPage(defaultExperimentalSettings()); @@ -1001,11 +1013,15 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { root = null; queryClient?.clear(); container.remove(); + setWorktreeRuntimeMeta(false); vi.clearAllMocks(); }); - async function renderPage(hiddenSettings?: string[]) { - mockInstanceSettingsApi.getExperimental.mockResolvedValue(defaultExperimentalSettings()); + async function renderPage( + hiddenSettings?: string[], + settings: InstanceExperimentalSettingsWithManaged = defaultExperimentalSettings(), + ) { + mockInstanceSettingsApi.getExperimental.mockResolvedValue(settings); root = createRoot(container); queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { @@ -1030,6 +1046,41 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { expect(container.textContent).not.toContain("Show the Apps navigation"); }); + it("keeps only permitted controls in alphabetical order, including when hidden features are enabled", async () => { + setWorktreeRuntimeMeta(true); + const visible = new Set(["enableExternalObjects", "enableMcpAggregators", "enableSimplifiedEnglishInteractions"]); + await renderPage( + INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`), + { + ...defaultExperimentalSettings(), + enableIsolatedWorkspaces: true, + enableIsolatedWorkspacesByDefault: true, + enablePaperclipDeveloperMode: true, + managedKeys: { + enableIsolatedWorkspacesByDefault: { managed: true, managedBy: "paperclip-cloud" }, + }, + }, + ); + + expect([...container.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ + "Enable External Objects", + "MCP aggregators", + "Simplified English Interactions", + ]); + expect([...container.querySelectorAll("section h2")].map((heading) => heading.textContent)).toEqual([ + "Experimental features", + ]); + expect(container.querySelectorAll('button[role="switch"]')).toHaveLength(3); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); + }); + + it("retains a section when one of its controls is visible", async () => { + const visible = new Set(["enablePaperclipDeveloperMode", "enableGoalsSidebarLink"]); + await renderPage(INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`)); + expect(container.querySelector('[aria-labelledby="developer-mode-heading"] h3')?.textContent).toBe("Paperclip Developer Mode"); + expect(container.querySelector('[aria-labelledby="legacy-heading"] h3')?.textContent).toBe("Goals Sidebar Link"); + }); + it("shows every toggle when nothing is hidden", async () => { await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 45b8eb019c9..b334c783d54 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -121,6 +121,7 @@ function ExperimentalToggleCard({ export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const { hidden: hiddenSettings } = useHiddenSettings(); const [actionError, setActionError] = useState(null); useEffect(() => { @@ -237,6 +238,17 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableFirstTaskPlanProposal === true; const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; + const isVisible = (key: InstanceFeatureKey) => !hiddenSettings.has(experimentalSettingKey(key)); + const showWorktreeRunExecution = inWorktree && isVisible("enableWorktreeRunExecution"); + const showDeveloperSection = showWorktreeRunExecution || ([ + "autoRestartDevServerWhenIdle", + "enableManagedSandboxOnly", + "enablePaperclipDeveloperMode", + "enableServerInfoDebugView", + "enableSmokeLab", + "enableIssuePlanDecompositions", + ] satisfies InstanceFeatureKey[]).some(isVisible); + const showLegacySection = isVisible("enableClassicTaskInterface") || isVisible("enableGoalsSidebarLink"); return (
@@ -281,6 +293,18 @@ export function InstanceExperimentalSettings() {

+ toggleMutation.mutate({ enableAgentChat: checked })} + disabled={toggleMutation.isPending} + settingKey="enableAgentChat" + managed={managedKeys.enableAgentChat} + ariaLabel="Toggle agent chat experimental setting" + /> + - toggleMutation.mutate({ enableAgentChat: checked })} - disabled={toggleMutation.isPending} - settingKey="enableAgentChat" - managed={managedKeys.enableAgentChat} - ariaLabel="Toggle agent chat experimental setting" - /> - toggleMutation.mutate({ enableMcpAggregators: checked })} - disabled={toggleMutation.isPending} - settingKey="enableMcpAggregators" - managed={managedKeys.enableMcpAggregators} - ariaLabel="Toggle MCP aggregators experimental setting" - /> - {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? ( - {enableIsolatedWorkspaces && ( - - toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableIsolatedWorkspacesByDefault" - managed={managedKeys.enableIsolatedWorkspacesByDefault} - ariaLabel="Toggle isolated workspaces by default experimental setting" - /> - )} - + + toggleMutation.mutate({ enableFirstTaskPlanProposal: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableFirstTaskPlanProposal" + managed={managedKeys.enableFirstTaskPlanProposal} + ariaLabel="Toggle first task plan proposal experimental setting" + /> + + toggleMutation.mutate({ enableMcpAggregators: checked })} + disabled={toggleMutation.isPending} + settingKey="enableMcpAggregators" + managed={managedKeys.enableMcpAggregators} + ariaLabel="Toggle MCP aggregators experimental setting" + /> + - - toggleMutation.mutate({ enableFirstTaskPlanProposal: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableFirstTaskPlanProposal" - managed={managedKeys.enableFirstTaskPlanProposal} - ariaLabel="Toggle first task plan proposal experimental setting" - /> - + {enableIsolatedWorkspaces && ( + + toggleMutation.mutate({ enableIsolatedWorkspacesByDefault: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIsolatedWorkspacesByDefault" + managed={managedKeys.enableIsolatedWorkspacesByDefault} + ariaLabel="Toggle isolated workspaces by default experimental setting" + /> + )} -
-
-

- Paperclip Developer Mode -

-

- Internal tools for developing, testing, and debugging Paperclip. -

-
+ {showDeveloperSection ? ( +
+
+

+ Paperclip Developer Mode +

+

+ Internal tools for developing, testing, and debugging Paperclip. +

+
- - toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enablePaperclipDeveloperMode" - managed={managedKeys.enablePaperclipDeveloperMode} - ariaLabel="Toggle Paperclip developer mode experimental setting" - /> + + toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked }) + } + disabled={toggleMutation.isPending} + settingKey="autoRestartDevServerWhenIdle" + managed={managedKeys.autoRestartDevServerWhenIdle} + ariaLabel="Toggle guarded dev-server auto-restart" + /> - - toggleMutation.mutate({ enableManagedSandboxOnly: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableManagedSandboxOnly" - managed={managedKeys.enableManagedSandboxOnly} - ariaLabel="Toggle managed environment only experimental setting" - /> + + toggleMutation.mutate({ enableManagedSandboxOnly: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableManagedSandboxOnly" + managed={managedKeys.enableManagedSandboxOnly} + ariaLabel="Toggle managed environment only experimental setting" + /> - {inWorktree ? ( - -
-
-
-
-

Run tasks in this worktree

- {worktreeRunExecutionManaged ? : null} -
-

- This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs - here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay - parked. Toggling off and on resets the cutoff. -

-
- { - if (worktreeRunExecutionManaged) return; - toggleMutation.mutate({ enableWorktreeRunExecution: checked }); - }} - disabled={toggleMutation.isPending || worktreeRunExecutionManaged} - aria-label="Toggle worktree run execution setting" - /> -
+ + toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enablePaperclipDeveloperMode" + managed={managedKeys.enablePaperclipDeveloperMode} + ariaLabel="Toggle Paperclip developer mode experimental setting" + /> - {worktreeRunExecutionState.kind === "armed" ? ( -
- - - Running tasks created after{" "} - - {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} - - . - -
- ) : null} - - {worktreeRunExecutionState.kind === "fail_closed" ? ( -
- -
-

Execution is suppressed — effectively off.

-

- {worktreeRunExecutionState.reason === "instance_mismatch" - ? "This setting was armed in a different instance and copied here, so no tasks run automatically." - : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} - Toggle it off and back on to arm execution for tasks created here. + {showWorktreeRunExecution ? ( + +

+
+
+
+

Run tasks in this worktree

+ {worktreeRunExecutionManaged ? : null} +
+

+ This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs + here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay + parked. Toggling off and on resets the cutoff.

+ { + if (worktreeRunExecutionManaged) return; + toggleMutation.mutate({ enableWorktreeRunExecution: checked }); + }} + disabled={toggleMutation.isPending || worktreeRunExecutionManaged} + aria-label="Toggle worktree run execution setting" + />
- ) : null} -
- - ) : null} - - toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked }) - } - disabled={toggleMutation.isPending} - settingKey="autoRestartDevServerWhenIdle" - managed={managedKeys.autoRestartDevServerWhenIdle} - ariaLabel="Toggle guarded dev-server auto-restart" - /> + {worktreeRunExecutionState.kind === "armed" ? ( +
+ + + Running tasks created after{" "} + + {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} + + . + +
+ ) : null} - - toggleMutation.mutate({ enableServerInfoDebugView: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableServerInfoDebugView" - managed={managedKeys.enableServerInfoDebugView} - ariaLabel="Toggle server info debug view experimental setting" - /> + {worktreeRunExecutionState.kind === "fail_closed" ? ( +
+ +
+

Execution is suppressed — effectively off.

+

+ {worktreeRunExecutionState.reason === "instance_mismatch" + ? "This setting was armed in a different instance and copied here, so no tasks run automatically." + : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} + Toggle it off and back on to arm execution for tasks created here. +

+
+
+ ) : null} +
+ + ) : null} - toggleMutation.mutate({ enableSmokeLab: checked })} - disabled={toggleMutation.isPending} - settingKey="enableSmokeLab" - managed={managedKeys.enableSmokeLab} - ariaLabel="Toggle smoke lab experimental setting" - /> + + toggleMutation.mutate({ enableServerInfoDebugView: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableServerInfoDebugView" + managed={managedKeys.enableServerInfoDebugView} + ariaLabel="Toggle server info debug view experimental setting" + /> - - toggleMutation.mutate({ enableIssuePlanDecompositions: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableIssuePlanDecompositions" - managed={managedKeys.enableIssuePlanDecompositions} - ariaLabel="Toggle task plan decomposition panel experimental setting" - /> -
+ toggleMutation.mutate({ enableSmokeLab: checked })} + disabled={toggleMutation.isPending} + settingKey="enableSmokeLab" + managed={managedKeys.enableSmokeLab} + ariaLabel="Toggle smoke lab experimental setting" + /> -
-
-

- Legacy -

-

These features are going to be removed.

-
+ + toggleMutation.mutate({ enableIssuePlanDecompositions: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIssuePlanDecompositions" + managed={managedKeys.enableIssuePlanDecompositions} + ariaLabel="Toggle task plan decomposition panel experimental setting" + /> +
+ ) : null} - - toggleMutation.mutate({ enableClassicTaskInterface: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableClassicTaskInterface" - managed={managedKeys.enableClassicTaskInterface} - ariaLabel="Toggle classic task interface experimental setting" - /> + {showLegacySection ? ( +
+
+

+ Legacy +

+

These features are going to be removed.

+
- - toggleMutation.mutate({ enableGoalsSidebarLink: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableGoalsSidebarLink" - managed={managedKeys.enableGoalsSidebarLink} - ariaLabel="Toggle goals sidebar link experimental setting" - /> -
+ + toggleMutation.mutate({ enableClassicTaskInterface: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableClassicTaskInterface" + managed={managedKeys.enableClassicTaskInterface} + ariaLabel="Toggle classic task interface experimental setting" + /> + + + toggleMutation.mutate({ enableGoalsSidebarLink: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableGoalsSidebarLink" + managed={managedKeys.enableGoalsSidebarLink} + ariaLabel="Toggle goals sidebar link experimental setting" + /> +
+ ) : null}
); }