Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/components/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -302,7 +302,9 @@ function serializeProjectRoute(location: ReturnType<typeof useLocation>): string

function serializeStoredProjectRoute(location: ReturnType<typeof useLocation>): 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");
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -226,6 +228,7 @@ function serializeLocationRoute(location: ReturnType<typeof useLocation>): strin
if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) {
return null;
}
if (isMachineScopedSettingsRoute(route)) return null;
return route;
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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)}
/>
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/renderer/components/app/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@ export function TopBar({
accountRouteActive = false,
hubRouteActive = false,
settingsRouteActive = false,
projectSidebarLocked = false,
onOpenActivityPane,
onNavigate,
}: {
Expand All @@ -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;
Expand Down Expand Up @@ -2051,7 +2054,9 @@ export function TopBar({
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<WorkToolPickerBackdrop theme={theme} field="window" />
</div>
{projectSurfaceVisible ? <ProjectSidebarToggle shortcut={sidebarToggleShortcut} /> : null}
{projectSurfaceVisible ? (
<ProjectSidebarToggle shortcut={sidebarToggleShortcut} locked={projectSidebarLocked} />
) : null}

{/* Branding */}
<img
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* @vitest-environment jsdom */

import { afterEach, describe, expect, it } from "vitest";
import { readStoredProjectRoute, writeStoredProjectRoute } from "./projectRouteStorage";

const BINDING_KEY = "local:/Users/arul/ADE";
const STORAGE_KEY = `ade:project-route:${BINDING_KEY}`;

describe("project route memory", () => {
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");
});
});
11 changes: 11 additions & 0 deletions apps/desktop/src/renderer/components/app/projectRouteStorage.ts
Original file line number Diff line number Diff line change
@@ -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:<bindingKey>` 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",
Expand All @@ -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}/`),
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,44 @@ 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 (
<SmartTooltip
content={{
label: "Toggle sidebar",
description: hidden ? "Show the project sidebar." : "Hide the project sidebar.",
shortcut: shortcut ?? undefined,
description,
// 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}
>
<button
type="button"
className="ade-shell-control inline-flex h-[24px] w-[24px] shrink-0 items-center justify-center"
className="ade-shell-control relative inline-flex h-[24px] w-[24px] shrink-0 items-center justify-center aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
data-variant="ghost"
data-collapsed={collapsed ? "true" : undefined}
aria-label="Toggle sidebar"
aria-pressed={!hidden}
onClick={toggleProjectSidebarHidden}
aria-pressed={!collapsed}
aria-disabled={locked || undefined}
onClick={locked ? undefined : toggleProjectSidebarHidden}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SidebarSimple size={16} weight="regular" />
{collapsed ? (
<span aria-hidden className="absolute right-[3px] top-[3px] h-1.5 w-1.5 rounded-full bg-accent" />
) : null}
</button>
</SmartTooltip>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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;
Expand All @@ -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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions docs/features/onboarding-and-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading