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" + /> +
+
+