fix(web): navigate to Settings on the first gear click from a session view (#1775)

This commit is contained in:
Clément Fiere
2026-07-18 19:25:10 +02:00
committed by GitHub
parent 4684390bc6
commit 2586c564b1
5 changed files with 83 additions and 6 deletions
@@ -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();
});
});
@@ -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"
/>
@@ -0,0 +1,3 @@
export function isSettingsRoute(pathname: string | null): boolean {
return pathname === "/settings" || Boolean(pathname?.startsWith("/settings/"));
}
@@ -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;
@@ -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("/");