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 dc22b26cc0..fc574dc4aa 100644
--- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx
@@ -17,7 +17,6 @@ const state = {
],
},
appSidebar: { settingsMode: false },
- toggleAppSidebarSettingsMode: mocks.toggleSettingsMode,
};
let officeEnabled = false;
@@ -64,7 +63,7 @@ import { AppSidebarFooter } from "./app-sidebar-footer";
function renderFooter() {
return render(
-
+
,
);
}
diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx
index 1df28655b5..8e5b67a83d 100644
--- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx
@@ -32,6 +32,7 @@ import {
type AppSidebarFooterProps = {
collapsed: boolean;
+ onToggleSettingsMode: () => void;
};
type FooterIconButtonProps = {
@@ -95,7 +96,7 @@ function FooterIconButton({
);
}
-export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
+export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) {
const router = useRouter();
const workspaces = useAppStore((s) => s.workspaces);
const workspaceId = workspaces.activeId;
@@ -105,7 +106,6 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
? resolveLastKanbanWorkspace(workspaces.items)
: resolveLastOfficeWorkspace(workspaces.items);
const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
- const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
const officeEnabled = useFeature("office");
const releaseNotes = useReleaseNotes();
const [improveOpen, setImproveOpen] = useState(false);
@@ -121,7 +121,7 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
icon={IconSettings}
label={settingsMode ? "Close settings" : "Settings"}
collapsed={collapsed}
- onClick={toggleSettingsMode}
+ onClick={onToggleSettingsMode}
active={settingsMode}
testId="sidebar-settings-gear"
/>
diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx
index 655b263f1e..2355b92f9e 100644
--- a/apps/web/components/app-sidebar/app-sidebar.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar.tsx
@@ -103,6 +103,7 @@ export function AppSidebar() {
const storedWidth = useAppStore((s) => s.appSidebar.width);
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar);
+ const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
const setSettingsMode = useAppStore((s) => s.setAppSidebarSettingsMode);
const setWidth = useAppStore((s) => s.setAppSidebarWidth);
const pathname = usePathname();
@@ -140,6 +141,14 @@ export function AppSidebar() {
);
const expandedWidth = Math.max(APP_SIDEBAR_EXPANDED_WIDTH, storedWidth);
+ const settingsModeTogglePathnameRef = useRef(null);
+
+ const handleToggleSettingsMode = useCallback(() => {
+ // History updates before React renders the new pathname; remember which
+ // route the user actually clicked on so delayed route sync cannot undo it.
+ settingsModeTogglePathnameRef.current = window.location.pathname;
+ toggleSettingsMode();
+ }, [toggleSettingsMode]);
// Keep the transient settings takeover aligned with route ownership. It is
// intentionally not persisted, so direct reloads on `/settings/...` need to
@@ -150,10 +159,11 @@ export function AppSidebar() {
if (!pathname || prevPathnameRef.current === pathname) return;
prevPathnameRef.current = pathname;
if (isSettingsRoute(pathname)) {
+ settingsModeTogglePathnameRef.current = null;
if (!settingsMode) setSettingsMode(true);
return;
}
- if (settingsMode) {
+ if (settingsMode && settingsModeTogglePathnameRef.current !== pathname) {
setSettingsMode(false);
}
}, [pathname, settingsMode, setSettingsMode]);
@@ -192,7 +202,7 @@ export function AppSidebar() {
>
-
+
{!collapsed && }
);
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 91c83605aa..a95eb58519 100644
--- a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
@@ -72,4 +72,23 @@ test.describe("Settings sidebar takeover", () => {
await expect(sidebar).toHaveAttribute("data-collapsed", "false");
await expect(takeover).toBeVisible();
});
+
+ test("a route change cannot undo the first gear click", async ({ testPage }) => {
+ await testPage.goto("/");
+
+ const takeover = testPage.getByTestId("app-sidebar-settings-mode");
+ await expect(testPage.getByTestId("sidebar-settings-gear")).toBeVisible();
+
+ // Keep both actions in one browser task so the route-sync effect is still
+ // pending when the user action arrives. The click must win.
+ await testPage.evaluate(() => {
+ window.history.pushState({}, "", "/tasks");
+ window.dispatchEvent(new Event("kandev:navigation"));
+ const gear = document.querySelector('[data-testid="sidebar-settings-gear"]');
+ if (!gear) throw new Error("sidebar-settings-gear not found");
+ gear.click();
+ });
+
+ await expect(takeover).toBeVisible();
+ });
});