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}
); }