[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:
parent
c07e650cd7
commit
83f5f59842
|
|
@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
enableGoalsSidebarLink: boolean;
|
||||
enableServerInfoDebugView: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue