From 1a31400e73e4ab5ee66de7790cd50f22ef3f4b6b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:25:39 -0400 Subject: [PATCH 1/2] fix(shell): settings always keeps the sidebar visible and never becomes a project's start page Settings pages could collapse the project sidebar, so a user who opened Diagnostics (a This computer page) had no visible way out except the collapsed toggle. Machine-scoped settings pages were also saved as the project's remembered route, so the project kept reopening on them. - Settings holds the sidebar open: the toggle is disabled there and Mod+B does nothing. The saved show/hide preference is not changed, so leaving Settings restores the sidebar the way the user had it. - A collapsed sidebar's toggle is drawn in the accent colour with a dot. - Machine-scoped settings routes (the "This computer" group) are never stored as a project's route or remembered as its last settings section. A stored one restores to /work. Correctness fix, no perf claim. Co-Authored-By: Claude Haiku 5.5 --- .../src/renderer/components/app/App.tsx | 6 ++-- .../src/renderer/components/app/AppShell.tsx | 5 ++++ .../src/renderer/components/app/TopBar.tsx | 7 ++++- .../app/projectRouteStorage.test.ts | 30 +++++++++++++++++++ .../components/app/projectRouteStorage.ts | 11 +++++++ .../projectSidebar/ProjectSidebar.test.tsx | 10 +++---- .../app/projectSidebar/ProjectSidebar.tsx | 5 +++- .../projectSidebar/ProjectSidebarToggle.tsx | 25 ++++++++++++---- .../app/projectSidebar/settingsReturnRoute.ts | 11 +++++-- .../useProjectSidebarShortcuts.ts | 8 +++-- .../components/settings/settingsManifest.ts | 29 ++++++++++++++++++ apps/desktop/src/renderer/index.css | 6 ++++ .../onboarding-and-settings/README.md | 15 ++++++++++ 13 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/projectRouteStorage.test.ts diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index e86bddd82a..4ce8b9a2f9 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -15,7 +15,7 @@ import { shallow } from "zustand/shallow"; import { useShallow } from "zustand/react/shallow"; import { AppShell } from "./AppShell"; -import { ACCOUNT_SETTINGS_ROUTE, resolveSettingsTab } from "../settings/settingsManifest"; +import { ACCOUNT_SETTINGS_ROUTE, isMachineScopedSettingsRoute, resolveSettingsTab } from "../settings/settingsManifest"; import { InboundDeeplinkModal, type InboundDeeplinkDispatchOptions, @@ -302,7 +302,9 @@ function serializeProjectRoute(location: ReturnType): string function serializeStoredProjectRoute(location: ReturnType): string | null { const route = serializeProjectRoute(location); - if (!route || location.pathname !== "/files") return route; + // A machine page is never the project's remembered route; the last project route stays. + if (!route || isMachineScopedSettingsRoute(route)) return null; + if (location.pathname !== "/files") return route; const params = new URLSearchParams(location.search ?? ""); if (!params.has("externalPath") && !params.has("externalOpen")) return route; params.delete("externalPath"); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 368116a8bc..141cb67b70 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -10,6 +10,8 @@ import { isCssZoomedBrowserSurface } from "../../lib/webClientMode"; import { TopBar } from "./TopBar"; import { memoWithLatestHandlers } from "../../lib/stableIdentity"; import { useProjectSidebarShortcuts } from "./projectSidebar/useProjectSidebarShortcuts"; +import { isSettingsRoute } from "./projectSidebar/settingsReturnRoute"; +import { isMachineScopedSettingsRoute } from "../settings/settingsManifest"; import { useProjectTabShortcuts } from "./useProjectTabShortcuts"; import { ProjectTransitionErrorAlert } from "./ProjectTransitionErrorAlert"; import { TabBackground } from "../ui/TabBackground"; @@ -226,6 +228,7 @@ function serializeLocationRoute(location: ReturnType): strin if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) { return null; } + if (isMachineScopedSettingsRoute(route)) return null; return route; } @@ -998,6 +1001,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { projectRoot: currentProjectRoot, keybindings, navigate, + sidebarLocked: isSettingsRoute(location.pathname), }); /** `⌃Tab` with nothing to cycle: open the palette on its project picker. */ @@ -1089,6 +1093,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { musicRouteActive={isMusicRoute} accountRouteActive={isAccountRoute} settingsRouteActive={location.pathname === "/settings"} + projectSidebarLocked={isSettingsRoute(location.pathname)} onNavigate={(path, opts) => navigate(path, opts)} onOpenActivityPane={() => setActivityPaneOpen(true)} /> diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 8285e5ac0d..6a621d1cda 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -708,6 +708,7 @@ export function TopBar({ accountRouteActive = false, hubRouteActive = false, settingsRouteActive = false, + projectSidebarLocked = false, onOpenActivityPane, onNavigate, }: { @@ -720,6 +721,8 @@ export function TopBar({ hubRouteActive?: boolean; /** The `#/settings` route is in front. Drives the standalone Settings tab. */ settingsRouteActive?: boolean; + /** Settings is in front and holds the project sidebar open, so its toggle is disabled. */ + projectSidebarLocked?: boolean; onNavigate?: (path: string, opts?: { replace?: boolean }) => void; /** Raises the shell's Activity pane over whatever tab is in front. */ onOpenActivityPane?: () => void; @@ -2051,7 +2054,9 @@ export function TopBar({
- {projectSurfaceVisible ? : null} + {projectSurfaceVisible ? ( + + ) : null} {/* Branding */} { + afterEach(() => { + window.localStorage.clear(); + }); + + it("never remembers a machine-scoped settings page, and restores a stale one to the default", () => { + writeStoredProjectRoute(BINDING_KEY, "/lanes"); + writeStoredProjectRoute(BINDING_KEY, "/settings?tab=storage#diagnostics"); + // `#diagnostics` names a This computer card, so it wins over `?tab=chat`. + writeStoredProjectRoute(BINDING_KEY, "/settings?tab=chat#diagnostics"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("/lanes"); + + // A project settings page is still remembered. + writeStoredProjectRoute(BINDING_KEY, "/settings?tab=secrets"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("/settings?tab=secrets"); + + // A value stored before machine pages were excluded restores to the default and is replaced. + window.localStorage.setItem(STORAGE_KEY, "/settings?tab=storage#diagnostics"); + expect(readStoredProjectRoute(BINDING_KEY)).toBe("/work"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("/work"); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/projectRouteStorage.ts b/apps/desktop/src/renderer/components/app/projectRouteStorage.ts index f18ff370e1..8670888f04 100644 --- a/apps/desktop/src/renderer/components/app/projectRouteStorage.ts +++ b/apps/desktop/src/renderer/components/app/projectRouteStorage.ts @@ -1,7 +1,11 @@ +import { isMachineScopedSettingsRoute } from "../settings/settingsManifest"; + /// Per-surface route memory: each open project tab remembers its last route /// under `ade:project-route:` so tab switches replay where the /// user left off. Shared by ProjectTabHost (read/write on tab swaps) and /// TopBar (read when routing back to a project from the Chats machine tab). +/// Machine-scoped settings pages (This computer) are never remembered: they +/// describe one computer, not the project, so they are not a start page. const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; const STORED_PROJECT_ROUTE_ROOTS = [ "/lanes", @@ -26,6 +30,11 @@ export function readStoredProjectRoute(bindingKey: string): string | null { writeStoredProjectRoute(bindingKey, "/work"); return "/work"; } + // Stored before machine pages were excluded: restore the default instead. + if (isMachineScopedSettingsRoute(value)) { + writeStoredProjectRoute(bindingKey, "/work"); + return "/work"; + } const pathname = value.split(/[?#]/, 1)[0] ?? ""; const isSupported = STORED_PROJECT_ROUTE_ROOTS.some( (root) => pathname === root || pathname.startsWith(`${root}/`), @@ -47,6 +56,8 @@ export function readStoredProjectSettingsRoute(bindingKey: string): string | nul } export function writeStoredProjectRoute(bindingKey: string, route: string): void { + // A machine page is not the project's start page, so the project keeps the one it had. + if (isMachineScopedSettingsRoute(route)) return; try { window.localStorage.setItem(projectRouteStorageKey(bindingKey), route); } catch { diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx index 5f5c24be94..4b67636fc4 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx @@ -73,10 +73,10 @@ describe("ProjectSidebar footer", () => { }); it("reopens the project's stored settings page", () => { - window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=appearance#theme"); + window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=chat"); renderSidebar("/work"); fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByTestId("location").textContent).toBe("/settings?tab=appearance#theme"); + expect(screen.getByTestId("location").textContent).toBe("/settings?tab=chat"); }); it("returns to where you were when the cog is clicked again", () => { @@ -89,12 +89,12 @@ describe("ProjectSidebar footer", () => { }); it("reopens the last settings section used in this session before the stored one", () => { - window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=appearance#theme"); - renderSidebar("/settings?tab=providers"); + window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=secrets"); + renderSidebar("/settings?tab=chat"); cleanup(); renderSidebar("/lanes"); fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByTestId("location").textContent).toBe("/settings?tab=providers"); + expect(screen.getByTestId("location").textContent).toBe("/settings?tab=chat"); }); it("keeps the page under CTO selected and turns the footer into one Back button", () => { diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx index fc63abf166..0bd6437a79 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx @@ -33,7 +33,7 @@ export { projectSidebarTabForPath }; */ export function ProjectSidebar({ route, heldRoute = null }: { route: string; heldRoute?: string | null }) { const navigate = useNavigate(); - const { width, hidden } = useProjectSidebarPrefs(); + const { width, hidden: hiddenByPreference } = useProjectSidebarPrefs(); const setSlotTarget = useProjectSidebarSlotTarget(); // Select scalars only: the sidebar sits beside every tab page, and a wide // selector here would re-render it on every unrelated store write. @@ -47,6 +47,9 @@ export function ProjectSidebar({ route, heldRoute = null }: { route: string; hel const activeTab = projectSidebarTabForPath(pathname); const heldTab = heldRoute ? projectSidebarTabForPath(heldRoute.split(/[?#]/, 1)[0] || "/work") : null; const settingsActive = pathname === "/settings" || pathname.startsWith("/settings/"); + // Settings keeps the sidebar open. The saved preference is left alone, so + // leaving Settings restores the sidebar the way the user had it. + const hidden = hiddenByPreference && !settingsActive; const settingsVisible = !isWebClientMode() || WEB_CLIENT_TAB_PATHS.has("/settings"); const routeKey = bindingKey ?? (projectRoot ? `local:${projectRoot}` : null); // The star pill is a desktop-only nudge, shown under the Work session list. diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx index b8195e522a..89aa2bba70 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx @@ -6,28 +6,43 @@ import { toggleProjectSidebarHidden, useProjectSidebarHidden } from "./projectSi /** * The top bar's sidebar toggle. It lives in its own component so only this * button, not the whole top bar, re-renders while the sidebar is resized. + * + * `locked` is set on Settings, which holds the sidebar open, so the toggle is + * disabled there. A collapsed sidebar shows the toggle in the accent colour with + * a dot, so it is easy to find. */ -export function ProjectSidebarToggle({ shortcut }: { shortcut: string | null }) { +export function ProjectSidebarToggle({ shortcut, locked = false }: { shortcut: string | null; locked?: boolean }) { const hidden = useProjectSidebarHidden(); + const collapsed = hidden && !locked; + const description = locked + ? "Settings keeps the sidebar open." + : collapsed + ? "Show the project sidebar." + : "Hide the project sidebar."; return ( ); diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts b/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts index 6c5940f877..fbe0a46c11 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts +++ b/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts @@ -1,7 +1,11 @@ +import { isMachineScopedSettingsRoute } from "../../settings/settingsManifest"; + /** * Settings is a place you visit and leave. For each project this remembers * where you were before Settings (so the cog and the back button can return * you there) and the last Settings section you used (so the cog reopens it). + * A machine-scoped section (This computer) is not remembered: the cog opens + * Settings' default page instead. * * The project sidebar records every route it sees. Memory only: a restart * returns to Work, which is also where the app opens. @@ -30,8 +34,11 @@ function isOverlayRoute(route: string): boolean { /** Records the route on screen for a project. */ export function rememberProjectRoute(projectKey: string | null, route: string): void { if (!projectKey || !route || isOverlayRoute(route)) return; - if (isSettingsRoute(route)) settingsRouteByProject.set(projectKey, route); - else returnRouteByProject.set(projectKey, route); + if (isSettingsRoute(route)) { + if (!isMachineScopedSettingsRoute(route)) settingsRouteByProject.set(projectKey, route); + } else { + returnRouteByProject.set(projectKey, route); + } } /** Where to go when you leave Settings. */ diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.ts b/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.ts index 2680da762d..69d45a8b65 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.ts @@ -30,14 +30,17 @@ function keystrokeIsClaimed(target: EventTarget | null): boolean { * shows or hides it. They follow the command palette's rule and work while a * text field has focus, but a handler that already used the chord (bold in the * PR markdown editor) wins because it calls `preventDefault` first. + * `sidebarLocked` is set on Settings, which keeps the sidebar open, so Mod+B + * is left alone there. */ export function useProjectSidebarShortcuts(args: { enabled: boolean; projectRoot: string | null; keybindings: KeybindingsSnapshot | null; navigate: (path: string) => void; + sidebarLocked?: boolean; }): void { - const { enabled, projectRoot, keybindings, navigate } = args; + const { enabled, projectRoot, keybindings, navigate, sidebarLocked = false } = args; const bindings = useMemo(() => { const tabs = visibleProjectSidebarTabs(PROJECT_SIDEBAR_TABS).flatMap((tab) => @@ -59,6 +62,7 @@ export function useProjectSidebarShortcuts(args: { if (event.defaultPrevented || event.isComposing) return; if (keystrokeIsClaimed(event.target)) return; if (eventMatchesBinding(event, bindings.toggle)) { + if (sidebarLocked) return; event.preventDefault(); if (!event.repeat) toggleProjectSidebarHidden(); return; @@ -71,5 +75,5 @@ export function useProjectSidebarShortcuts(args: { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [bindings, enabled, navigate, projectRoot]); + }, [bindings, enabled, navigate, projectRoot, sidebarLocked]); } diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.ts index f87a0160a9..0529b8da95 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.ts @@ -1431,6 +1431,35 @@ export function resolveSettingsHash(hash: string | null | undefined): SettingEnt return aliased ? (ENTRIES_BY_ID.get(aliased) ?? null) : null; } +/** + * The tab a `/settings` route opens on, resolved the way `SettingsPage` + * resolves it: a `#hash` names one card and wins over `?tab=`, and a missing or + * unknown tab opens on `DEFAULT_SETTINGS_TAB`. + */ +export function settingsTabForRoute(route: string): SettingsTabId { + const url = new URL(route, "http://settings.invalid"); + let hash = url.hash; + try { + hash = decodeURIComponent(hash); + } catch { + // A malformed escape is literal text, as it is on the settings page. + } + const linked = resolveSettingsHash(hash); + if (linked) return linked.tab; + return resolveSettingsTab(url.searchParams.get("tab")) ?? DEFAULT_SETTINGS_TAB; +} + +/** + * Whether a route is a machine-scoped settings page: one of the This computer + * pages (General, Diagnostics, Providers and the rest). Those describe one + * computer, so a project must never remember one as its place to return to. + */ +export function isMachineScopedSettingsRoute(route: string): boolean { + const pathname = route.split(/[?#]/, 1)[0] ?? ""; + if (pathname !== "/settings" && !pathname.startsWith("/settings/")) return false; + return isMachineSettingsTab(settingsTabForRoute(route)); +} + /** The route a palette entry or deeplink should navigate to. */ /** * The scope for one card's anchor, or null when the anchor is not a manifest diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index dc3fcd91c8..00691865a2 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -282,6 +282,12 @@ h6 { box-shadow: inset 0 0 0 1px var(--shell-control-focus-ring); } +/* A collapsed project sidebar: its toggle takes the accent, and the dot the + toggle renders marks it (see ProjectSidebarToggle). */ +.ade-shell-control[data-collapsed="true"] { + color: var(--color-accent); +} + .ade-shell-control-kbd { color: var(--shell-control-kbd-fg); background: var(--shell-control-kbd-bg); diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 2155b1630e..d717a568ce 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -2052,6 +2052,21 @@ the new-project screen. `DEFAULT_SETTINGS_TAB` names where Settings opens. It used to be `tabs[0]`, so reordering the sidebar silently moved the landing page. +### Settings holds the project sidebar open + +On any Settings page the project sidebar is shown: its toggle is disabled (the +tooltip says why) and Mod+B does nothing. The saved show/hide preference is not +changed, so leaving Settings restores the sidebar the way you had it. While the +sidebar is collapsed, its toggle is drawn in the accent colour with a dot. + +A Machines page (General, Appearance, Apple devices, Providers, Lanes, +Integrations, Diagnostics, Archive) describes one computer, so a project never +remembers one as its start page. The project returns to its last page that is +not a Machines page, and a stored Machines page from before this rule restores +to Work. The cog reopens the project's last Account or Project section, or +Settings' default page. `isMachineScopedSettingsRoute` in `settingsManifest.ts` +decides which routes count. + The pages themselves: | Tab | Section file | What lives here | From 519e11345c6d514c18b0a160305c98c5c253fc1a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:14:37 -0400 Subject: [PATCH 2/2] fix(shell): the sidebar toggle doesn't advertise its shortcut on Settings, where it does nothing Co-Authored-By: Claude Opus 5.5 --- .../components/app/projectSidebar/ProjectSidebarToggle.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx index 89aa2bba70..eeb09bf2f7 100644 --- a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx @@ -24,7 +24,8 @@ export function ProjectSidebarToggle({ shortcut, locked = false }: { shortcut: s content={{ label: "Toggle sidebar", description, - shortcut: shortcut ?? undefined, + // The shortcut does nothing on Settings, so the hint is not offered there. + shortcut: locked ? undefined : shortcut ?? undefined, }} wrapperStyle={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} >