diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index fc574dc4aa..5a14a68319 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -20,9 +20,11 @@ const state = { }; let officeEnabled = false; +let pathname = "/tasks/session-1"; vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mocks.routerPush }), + usePathname: () => pathname, })); vi.mock("@/components/state-provider", () => ({ @@ -71,6 +73,7 @@ function renderFooter() { describe("AppSidebarFooter", () => { beforeEach(() => { officeEnabled = false; + pathname = "/tasks/session-1"; state.workspaces.activeId = "kanban-1"; state.workspaces.items = [ { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, @@ -162,4 +165,36 @@ describe("AppSidebarFooter", () => { expect(document.cookie).toContain("office-active-workspace=office-2"); expect(mocks.routerPush).toHaveBeenCalledWith("/?workspaceId=kanban-1"); }); + it("navigates to /settings when the gear opens settings mode from a non-settings route", () => { + pathname = "/tasks/session-1"; + state.appSidebar.settingsMode = false; + + renderFooter(); + fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + + expect(mocks.routerPush).toHaveBeenCalledWith("/settings"); + expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); + }); + + it("does not navigate when the gear reopens settings mode while already on a settings route", () => { + pathname = "/settings/agents"; + state.appSidebar.settingsMode = false; + + renderFooter(); + fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); + }); + + it("does not navigate when the gear closes an already-open settings mode", () => { + pathname = "/settings"; + state.appSidebar.settingsMode = true; + + renderFooter(); + fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 8e5b67a83d..2133a28885 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter } from "@/lib/routing/client-router"; +import { useRouter, usePathname } from "@/lib/routing/client-router"; import { IconBuildings, IconChartBar, @@ -29,6 +29,7 @@ import { resolveLastKanbanWorkspace, workspaceHomeHref, } from "./app-sidebar-workspace-navigation"; +import { isSettingsRoute } from "./app-sidebar-route"; type AppSidebarFooterProps = { collapsed: boolean; @@ -98,6 +99,7 @@ function FooterIconButton({ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) { const router = useRouter(); + const pathname = usePathname(); const workspaces = useAppStore((s) => s.workspaces); const workspaceId = workspaces.activeId; const activeWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId); @@ -106,6 +108,17 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar ? resolveLastKanbanWorkspace(workspaces.items) : resolveLastOfficeWorkspace(workspaces.items); const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); + const enterSettings = () => { + // The gear only swaps the sidebar's own content; without also + // navigating, the main panel keeps showing whatever the user was on + // (e.g. a task session), so the first click looks like a no-op and a + // second click (on a tree leaf) is what actually reaches Settings. + // Match the Stats/Office buttons: one click gets you there. + if (!settingsMode && !isSettingsRoute(pathname)) { + router.push("/settings"); + } + onToggleSettingsMode(); + }; const officeEnabled = useFeature("office"); const releaseNotes = useReleaseNotes(); const [improveOpen, setImproveOpen] = useState(false); @@ -121,7 +134,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar icon={IconSettings} label={settingsMode ? "Close settings" : "Settings"} collapsed={collapsed} - onClick={onToggleSettingsMode} + onClick={enterSettings} active={settingsMode} testId="sidebar-settings-gear" /> diff --git a/apps/web/components/app-sidebar/app-sidebar-route.ts b/apps/web/components/app-sidebar/app-sidebar-route.ts new file mode 100644 index 0000000000..b3f4b82195 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-route.ts @@ -0,0 +1,3 @@ +export function isSettingsRoute(pathname: string | null): boolean { + return pathname === "/settings" || Boolean(pathname?.startsWith("/settings/")); +} diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 7c3fab06fa..e0e3f3e6ab 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePathname } from "@/lib/routing/client-router"; +import { isSettingsRoute } from "./app-sidebar-route"; import { useAppStore } from "@/components/state-provider"; import { useEnsureWorkspaceWorkflows } from "@/hooks/use-workflows"; import { useInOffice } from "@/hooks/use-in-office"; @@ -40,10 +41,6 @@ const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean { id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") }, ]; -function isSettingsRoute(pathname: string | null): boolean { - return pathname === "/settings" || Boolean(pathname?.startsWith("/settings/")); -} - type AppSidebarNavigationProps = { collapsed: boolean; inOffice: boolean; diff --git a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts index a95eb58519..0cfd87e049 100644 --- a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts +++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; // Settings has no nav "Settings" section; direct settings routes keep the // gear-gated tree open so a hard refresh preserves settings navigation context. @@ -73,6 +74,34 @@ test.describe("Settings sidebar takeover", () => { await expect(takeover).toBeVisible(); }); + test("the gear navigates to Settings in one click from a task session view", async ({ + testPage, + apiClient, + seedData, + }) => { + // Regression: from a session view the gear used to only swap the + // sidebar's own tree — the main panel kept showing the task, so the + // click looked like a no-op. A second click (on a tree leaf) was + // needed to actually reach Settings. The gear must now behave like + // the sibling Stats/Office footer buttons and navigate in one click. + const task = await apiClient.createTask(seedData.workspaceId, "Gear Nav Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const takeover = testPage.getByTestId("app-sidebar-settings-mode"); + await expect(takeover).toHaveCount(0); + + await testPage.getByTestId("sidebar-settings-gear").click(); + + await expect(testPage).toHaveURL(/\/settings$/); + await expect(takeover).toBeVisible(); + }); + test("a route change cannot undo the first gear click", async ({ testPage }) => { await testPage.goto("/");