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 ? (
+ Restore the Goals item in the main sidebar while the goals surface is being evaluated. +
+