From 83f5f5984230a0cc86c024a4173b22b4d88813ed Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:48:33 -0500 Subject: [PATCH] [codex] Hide goals sidebar link behind experiment (#9189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board sidebar is the primary navigation surface for operators scanning companies, projects, tasks, agents, and related control-plane tools. > - Goals still has a route and product surface, but keeping the top-level sidebar link always visible makes it part of the default navigation whether or not that surface is ready for every operator. > - Instance experimental settings already provide a controlled place to expose optional UI surfaces while they are being evaluated. > - This pull request adds a dedicated experimental setting for restoring the Goals sidebar link. > - The benefit is a quieter default sidebar with an explicit escape hatch for operators who still need the Goals entry point. ## Linked Issues or Issue Description No public GitHub issue exists for this internal task, so the feature request is described inline. **Subsystem affected** Cross-cutting: `ui/`, `server/`, and `packages/shared`. **Problem or motivation** The Goals route remains available, but the top-level Goals sidebar entry makes that surface part of the default operator navigation. While the goals surface is still being evaluated, operators need a quieter default sidebar without losing an escape hatch for teams that still rely on the link. **Proposed solution** Add a boolean instance experimental setting, `enableGoalsSidebarLink`, default it to `false`, and render the Goals sidebar link only when the setting is enabled. Expose the toggle in Instance Experimental Settings so operators can restore the link without changing routes or rebuilding the app. **Alternatives considered** - Remove the Goals route entirely: rejected because this task only asks to hide the sidebar entry point and preserve access for teams evaluating goals. - Keep the sidebar link always visible: rejected because it does not provide the requested quieter default navigation. - Hard-code a local UI flag: rejected because instance experimental settings already provide the expected operator-controlled pattern. **Roadmap alignment** Checked `ROADMAP.md`; no overlapping goals/sidebar/experimental roadmap entry was found. **Additional context** The `/goals` route is preserved. This PR only gates the sidebar navigation item. ## What Changed - Added `enableGoalsSidebarLink` to the shared instance experimental settings type and validator, defaulting to `false`. - Normalized the new setting in the server instance settings service. - Hid the Goals sidebar nav item unless the new setting is enabled. - Added a Goals Sidebar Link toggle to the Instance Experimental Settings page. - Updated shared, server, sidebar, and settings page tests for the new setting. ## Verification - `pnpm exec vitest run packages/shared/src/validators/instance.test.ts server/src/__tests__/instance-settings-service.test.ts server/src/__tests__/instance-settings-routes.test.ts ui/src/components/Sidebar.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` - `git diff --check origin/master...HEAD` - `git merge-tree --write-tree HEAD origin/master` - Searched for duplicate/related PRs by title and `enableGoalsSidebarLink`; none found. - Checked `ROADMAP.md` for overlapping goals/sidebar/experimental entries; none found. ## Risks Low risk. The main behavior shift is that operators who depended on the sidebar Goals link need to enable the new experimental toggle. The `/goals` route itself is not removed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based Paperclip CodexCoder session with repository tool access and command execution. Exact API model identifier and context window were not exposed by the Paperclip harness. ## 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 (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 --- packages/shared/src/types/instance.ts | 1 + .../shared/src/validators/instance.test.ts | 16 +++++++ packages/shared/src/validators/instance.ts | 1 + .../instance-settings-routes.test.ts | 25 ++++++++++ .../instance-settings-service.test.ts | 10 ++++ server/src/services/instance-settings.ts | 2 + ui/src/components/Sidebar.test.tsx | 48 +++++++++++++++++-- ui/src/components/Sidebar.tsx | 12 ++++- .../InstanceExperimentalSettings.test.tsx | 25 ++++++++++ ui/src/pages/InstanceExperimentalSettings.tsx | 18 +++++++ 10 files changed, 154 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 038fac8068..f03ac7df6f 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableGoalsSidebarLink: boolean; enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index f754afea62..d77a00bcfb 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -17,6 +17,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableWorkspaceBranchReconcileForward).toBe(false); }); + it("defaults the goals sidebar link off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableGoalsSidebarLink).toBe(false); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -36,4 +42,14 @@ describe("instance experimental settings validators", () => { enableWorkspaceBranchReconcileForward: true, }); }); + + it("accepts goals sidebar link patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableGoalsSidebarLink: true, + }), + ).toEqual({ + enableGoalsSidebarLink: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index c405f217a5..18ea416118 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index b9fa242b16..2c717b557e 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -81,6 +81,8 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -103,6 +105,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -123,6 +126,8 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableCloudSync: true, + enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -150,6 +155,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -205,6 +211,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -302,6 +309,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update the goals sidebar link", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableGoalsSidebarLink: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableGoalsSidebarLink: true, + }); + }); + it("allows local board users to update the server info debug view", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 1ac0eeb4fb..3fbed144ba 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, @@ -27,6 +28,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, @@ -60,6 +62,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableGoalsSidebarLink to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableGoalsSidebarLink).toBe(false); + expect(normalizeExperimentalSettings({}).enableGoalsSidebarLink).toBe(false); + expect( + normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableGoalsSidebarLink, + ).toBe(false); + }); + it("defaults enableWorkspaceBranchReconcileForward to false for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(false); expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(false); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index f61c966cb3..cdb0ef3047 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, @@ -74,6 +75,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 077141c53a..8007cb2848 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -177,7 +177,7 @@ describe("Sidebar", () => { const workSectionContainer = workSection?.parentElement?.parentElement; expect(workSectionContainer?.textContent).toContain("Work"); expect(workSectionContainer?.textContent).toContain("Tasks"); - expect(workSectionContainer?.textContent).toContain("Goals"); + expect(workSectionContainer?.textContent).not.toContain("Goals"); flushSync(() => { root.unmount(); @@ -294,10 +294,8 @@ describe("Sidebar", () => { expect(artifactsLink?.getAttribute("href")).toBe("/artifacts"); const navText = container.querySelector("nav")?.textContent ?? ""; - expect(navText).toContain("Goals"); expect(navText).toContain("Artifacts"); expect(navText).toContain("Skills"); - expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts")); expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Skills")); const sections = [...container.querySelectorAll("nav > div")]; @@ -311,6 +309,50 @@ describe("Sidebar", () => { }); }); + it("hides the Goals nav item by default", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableGoalsSidebarLink: false, + }); + const root = await renderSidebar(); + + expect([...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim())).not.toContain("Goals"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("reserves the Goals nav slot while experimental settings are loading", async () => { + mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); + const root = await renderSidebar(); + + expect([...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim())).not.toContain("Goals"); + expect(container.querySelector('[data-testid="sidebar-goals-placeholder"]')).not.toBeNull(); + + flushSync(() => { + root.unmount(); + }); + }); + + it("shows the Goals nav item when the experimental setting is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableGoalsSidebarLink: true, + }); + const root = await renderSidebar(); + + const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Goals"); + expect(link?.getAttribute("href")).toBe("/goals"); + + const navText = container.querySelector("nav")?.textContent ?? ""; + expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts")); + + flushSync(() => { + root.unmount(); + }); + }); + it("places Timeline in the Company section", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); const root = await renderSidebar(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 46d86fad47..d8f630d102 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -60,6 +60,8 @@ export function Sidebar() { const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; const showPipelines = experimentalSettings?.enablePipelines === true; + const goalsLinkPending = experimentalSettings === undefined; + const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true; // Streamlined left navigation (top-level Projects link + starred children) is // now the standard product sidebar (PAP-12472). The former experimental // opt-out was retired; classic per-project collapsible mode is no longer @@ -181,7 +183,15 @@ export function Sidebar() { {showPipelines ? ( ) : null} - + {showGoalsLink ? ( + + ) : goalsLinkPending ? ( + +
+
+
+

Goals Sidebar Link

+

+ Restore the Goals item in the main sidebar while the goals surface is being evaluated. +

+
+ toggleMutation.mutate({ enableGoalsSidebarLink: !enableGoalsSidebarLink })} + disabled={toggleMutation.isPending} + aria-label="Toggle goals sidebar link experimental setting" + /> +
+
+