[codex] Hide goals sidebar link behind experiment (#9189)

## 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
This commit is contained in:
Dotta 2026-07-07 16:48:33 -05:00 committed by GitHub
parent c07e650cd7
commit 83f5f59842
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 154 additions and 4 deletions

View File

@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
enableExternalObjects: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;

View File

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

View File

@ -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),

View File

@ -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",

View File

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

View File

@ -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,

View File

@ -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();

View File

@ -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 ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
) : null}
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
{showGoalsLink ? (
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
) : goalsLinkPending ? (
<div
data-testid="sidebar-goals-placeholder"
className="h-9 pointer-coarse:h-8"
aria-hidden="true"
/>
) : null}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
{showWorkspacesLink ? (

View File

@ -44,6 +44,8 @@ const STREAMLINED_TOGGLE_SELECTOR =
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
'button[aria-label="Toggle task watchdogs experimental setting"]';
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
'button[aria-label="Toggle goals sidebar link experimental setting"]';
const SERVER_INFO_TOGGLE_SELECTOR =
'button[aria-label="Toggle server info debug view experimental setting"]';
@ -57,6 +59,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableGoalsSidebarLink: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
enableServerInfoDebugView: false,
@ -197,6 +200,28 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
});
});
it("renders and patches the Goals Sidebar Link experimental toggle", async () => {
await renderPage();
expect(container.textContent).toContain("Goals Sidebar Link");
expect(container.textContent).toContain(
"Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
);
const toggle = container.querySelector<HTMLButtonElement>(GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableGoalsSidebarLink: true,
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("renders and patches the Server Info Debug View experimental toggle", async () => {
await renderPage();

View File

@ -242,6 +242,7 @@ export function InstanceExperimentalSettings() {
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
@ -373,6 +374,23 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Goals Sidebar Link</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Restore the Goals item in the main sidebar while the goals surface is being evaluated.
</p>
</div>
<ToggleSwitch
checked={enableGoalsSidebarLink}
onCheckedChange={() => toggleMutation.mutate({ enableGoalsSidebarLink: !enableGoalsSidebarLink })}
disabled={toggleMutation.isPending}
aria-label="Toggle goals sidebar link experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">