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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { openExternalUrl } from "../../lib/openExternal";
import { isWebClientMode } from "../../lib/webClientMode";
import { docs } from "../../onboarding/docsLinks";
import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition";
import { settingsRouteFor } from "../settings/settingsManifest";

const REPO_BRIDGE_DISMISS_KEY = "ade.account.repoBridgeDismissed.v1";
const MACHINES_REFRESH_MS = 30_000;
Expand Down Expand Up @@ -1247,7 +1248,7 @@ export function AccountPage() {
</div>
<button
type="button"
onClick={() => navigate("/settings?tab=general#github-connection")}
onClick={() => navigate(settingsRouteFor("integrations.github"))}
style={outlineButton({ height: 30, fontSize: 12, padding: "0 12px", flexShrink: 0 })}
>
Connect GitHub
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
githubAccountIssueCopy,
githubRepoIssueCopy,
} from "../../lib/githubIntegrationStatus";
import { settingsRouteFor } from "../settings/settingsManifest";
import { useBannerDismissals } from "../../lib/bannerDismiss";
import { openExternalUrl } from "../../lib/openExternal";
import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens";
Expand Down Expand Up @@ -55,8 +56,10 @@ export type IntegrationBannerHostProps = {
navigate: NavigateFunction;
};

const GITHUB_CONNECTION_SETTINGS_ROUTE = "/settings?tab=general#github-connection";
const AI_SETTINGS_ROUTE = "/settings?tab=ai";
// Derived from the settings manifest, never hand-written: these CTAs must land
// on the card that actually owns the setting, wherever the manifest has moved it.
const GITHUB_CONNECTION_SETTINGS_ROUTE = settingsRouteFor("integrations.github");
const AI_SETTINGS_ROUTE = settingsRouteFor("agents.providers");
const MAX_VISIBLE_BANNERS = 2;
const SEVERITY_RANK: Record<BannerSeverity, number> = { error: 0, warning: 1, info: 2 };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from "../../lib/launchedLanesHighlight";
import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard";
import { announceWorkChatSessionCreated } from "../../lib/chatSessionEvents";
import { settingsRouteFor } from "../settings/settingsManifest";

const INITIAL_VISIBILITY_CHECK_DELAY_MS = 2_000;
const VISIBILITY_RETRY_INTERVAL_MS = 3_000;
Expand Down Expand Up @@ -169,7 +170,7 @@ export function LinearQuickViewButton({

const openLinearSettings = useCallback(() => {
setConnectionPrompt(null);
window.location.hash = "#/settings?tab=general#linear-connection";
window.location.hash = `#${settingsRouteFor("integrations.linear")}`;
}, []);

const handleQuickViewRequest = useCallback((request: LinearIssueQuickViewRequest) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import { useAppStore } from "../../state/appStore";
import { expectNoJargon, JARGON_PATTERN } from "../../../test/jargonGuard";
import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen";
import { settingsRouteFor } from "../settings/settingsManifest";

const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn() }));
vi.mock("react-router-dom", () => ({ useNavigate: () => navigateMock }));
Expand Down Expand Up @@ -206,7 +207,9 @@ describe("ProjectRecoveryScreen", () => {
// The takeover must exit (clear the error) before navigating, or
// ProjectTabHost keeps rendering this screen and Settings never shows.
expect(clear).toHaveBeenCalled();
expect(navigateMock).toHaveBeenCalledWith("/settings?tab=storage");
// Route comes from the settings manifest, so this assertion follows the
// storage card if it ever moves tabs instead of pinning a stale literal.
expect(navigateMock).toHaveBeenCalledWith(settingsRouteFor("storage.usage"));
});

it("clears the transition error when Back is pressed", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../../../shared/types/recovery";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useAppStore } from "../../state/appStore";
import { settingsRouteFor } from "../settings/settingsManifest";

/**
* Codes we can attempt an automatic repair for when a live diagnosis isn't
Expand Down Expand Up @@ -344,7 +345,7 @@ export function ProjectRecoveryScreen() {
// this screen and the route change alone would never reveal
// Settings.
clearProjectTransitionError();
navigate("/settings?tab=storage");
navigate(settingsRouteFor("storage.usage"));
}}
className="inline-flex h-9 items-center justify-center rounded-lg border border-border/80 bg-fg/[0.03] px-4 text-[13px] font-medium text-fg/75 transition-colors hover:bg-fg/[0.07]"
>
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/renderer/components/app/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ describe("SettingsPage", () => {
expect(await screen.findByRole("heading", { name: "Integrations" })).toBeTruthy();
});

it("follows the hash's owning tab when ?tab= disagrees with it", async () => {
// Links written before GitHub moved out of General still say
// `?tab=general#github-connection`. The hash names one exact card, so it
// wins: landing on General (where the card no longer is) is what made the
// "Set up ADE GitHub App" banner look like it did nothing.
renderSettings("/settings?tab=general#github-connection");

expect(await screen.findByRole("heading", { name: "Integrations" })).toBeTruthy();
await waitFor(() => {
expect(screen.getByTestId("location").textContent).toBe("?tab=integrations#github-connection");
});
});

it("falls back to General for a tab id it has never shipped", async () => {
renderSettings("/settings?tab=not-a-real-tab");
expect(await screen.findByRole("heading", { name: "General" })).toBeTruthy();
Expand Down
33 changes: 29 additions & 4 deletions apps/desktop/src/renderer/components/app/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ function CrossTabResults({
export function SettingsPage({ active = true }: { active?: boolean } = {}) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Read-only: every write to the settings URL goes through `navigate` so the
// hash survives alongside the search params.
const [searchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
// Machine-scoped settings write to the machine the active project tab is
// bound to, so on web they exist only while one is open. The manifest is what
Expand Down Expand Up @@ -331,7 +333,24 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) {
// so it falls through to the first tab this renderer does serve.
const tabs = useMemo(() => availableSettingsTabs(), [machineBound]);
const defaultTab = tabs[0]?.id ?? "general";
const requestedTab = resolveSettingsTab(tabParam);
// A `#hash` names one specific setting, so it is strictly more precise than
// the `?tab=` next to it. When the two disagree — an older link that still
// says `?tab=general#github-connection` after GitHub moved to Integrations —
// follow the hash, which is the tab that actually contains the card we were
// asked to show. Without this the URL lands on the named tab and the scroll
// effect below silently no-ops, which is exactly how the GitHub App banner
// used to dump people on General.
const hashEntryTab = useMemo(() => {
if (!location.hash) return null;
let raw = location.hash.slice(1);
try {
raw = decodeURIComponent(raw);
} catch {
// A malformed hash should never break tab resolution.
}
return resolveSettingsHash(raw)?.tab ?? null;
}, [location.hash]);
const requestedTab = hashEntryTab ?? resolveSettingsTab(tabParam);
const resolvedTab = requestedTab && tabs.some((tab) => tab.id === requestedTab)
? requestedTab
: requestedTab
Expand All @@ -357,8 +376,14 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) {
if (!tabParam || !resolvedTab || tabParam === resolvedTab) return;
const nextParams = new URLSearchParams(searchParams);
nextParams.set("tab", resolvedTab);
setSearchParams(nextParams, { replace: true });
}, [active, resolvedTab, searchParams, setSearchParams, tabParam]);
// Navigate rather than setSearchParams: the latter drops the hash, which
// would throw away the very anchor that selected this tab and leave the
// scroll effect below with nothing to scroll to.
navigate(
{ pathname: location.pathname, search: `?${nextParams.toString()}`, hash: location.hash },
{ replace: true },
);
}, [active, location.hash, location.pathname, navigate, resolvedTab, searchParams, tabParam]);

// `?integration=github|linear|cli` predates the manifest.
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiskPressureSnapshot, DiskPressureState } from "../../../shared/types/storage";
import { StoragePressureIndicator } from "./StoragePressureIndicator";
import { settingsRouteFor } from "../settings/settingsManifest";

function snapshot(state: DiskPressureState): DiskPressureSnapshot {
return {
Expand Down Expand Up @@ -89,6 +90,6 @@ describe("StoragePressureIndicator", () => {
render(<StoragePressureIndicator enabled />);

fireEvent.click(await screen.findByRole("status"));
expect(window.location.hash).toBe("#/settings?tab=storage");
expect(window.location.hash).toBe(`#${settingsRouteFor("storage.usage")}`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HardDrive } from "@phosphor-icons/react";
import { isUrgentDiskPressure, type DiskPressureSnapshot } from "../../../shared/types/storage";
import { SmartTooltip } from "../ui/SmartTooltip";
import { cn } from "../ui/cn";
import { settingsRouteFor } from "../settings/settingsManifest";

const STORAGE_PRESSURE_SAMPLE_MS = 30_000;
const WARNING_DESCRIPTION = "Storage is running low — ADE and your active projects may create more files while agents work. Click to review ADE storage.";
Expand Down Expand Up @@ -80,7 +81,7 @@ export function StoragePressureIndicator({ enabled }: { enabled: boolean }) {
outline: "none",
}}
onClick={() => {
window.location.hash = "#/settings?tab=storage";
window.location.hash = `#${settingsRouteFor("storage.usage")}`;
}}
>
<HardDrive size={14} weight="fill" />
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/app/TopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2134,7 +2134,7 @@ describe("TopBar", () => {

expect(await screen.findByText("Connect Linear to open ADE-125")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /open linear settings/i }));
expect(window.location.hash).toBe("#/settings?tab=general#linear-connection");
expect(window.location.hash).toBe("#/settings?tab=integrations#linear-connection");
});

it("offers the project picker when a Linear issue deeplink opens without an ADE project", async () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/components/app/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
ADE_BROWSER_VIEW_OCCLUSION_END_EVENT,
ADE_BROWSER_VIEW_OCCLUSION_START_EVENT,
} from "../../lib/workSidebarBrowserResize";
import { settingsRouteFor } from "../settings/settingsManifest";

// Hosted-client only: kept out of the desktop bundle's critical path, and out
// of the desktop bundle's dependency graph for the sync client entirely.
Expand Down Expand Up @@ -330,7 +331,7 @@ function ResourcePressureIndicator({ usage }: { usage: AppResourceUsageSnapshot
outline: "none",
}}
onClick={() => {
window.location.hash = "#/settings?tab=storage#diagnostics";
window.location.hash = `#${settingsRouteFor("storage.diagnostics")}`;
}}
>
<WarningCircle size={14} weight="fill" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { GitHubTriggerFilters } from "../GitHubTriggerFilters";
import { LinearTriggerFilters } from "../LinearTriggerFilters";
import { ScheduleEditor } from "./ScheduleEditor";
import { settingsRouteFor } from "../../settings/settingsManifest";

function SmallField({
label,
Expand Down Expand Up @@ -104,13 +105,13 @@ function TriggerDeliveryCallout({
let action = null;
if (deliveryKey === "github" || deliveryKey === "githubWebhook") {
action = (
<CalloutActionButton label="Open GitHub settings" onClick={() => navigate("/settings?tab=general#github-connection")} />
<CalloutActionButton label="Open GitHub settings" onClick={() => navigate(settingsRouteFor("integrations.github"))} />
);
} else if (deliveryKey === "linear") {
action = linearApi?.setup ? (
<CalloutActionButton label="Connect Linear" disabled={linearPending} onClick={() => void setupLinear()} />
) : (
<CalloutActionButton label="Open Linear settings" onClick={() => navigate("/settings?tab=general#linear-connection")} />
<CalloutActionButton label="Open Linear settings" onClick={() => navigate(settingsRouteFor("integrations.linear"))} />
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
ComposerPromptStash,
type ComposerPromptStashHandle,
} from "./ComposerPromptStash";
import { settingsRouteFor } from "../settings/settingsManifest";

const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80;
Expand Down Expand Up @@ -5054,7 +5055,7 @@ export function AgentChatComposer({
type="button"
onClick={() => {
// HashRouter deep-link to the voice-input card under General.
window.location.hash = "#/settings?tab=general#voice-input";
window.location.hash = `#${settingsRouteFor("agents.dictation")}`;
}}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-fg/30 transition-all hover:bg-[color:color-mix(in_srgb,var(--chat-accent)_10%,transparent)] hover:text-[var(--chat-accent)] active:scale-[0.97]"
aria-label="Set up voice input"
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ import {
releaseRetainedChatSession,
retainChatSession,
} from "./chatSessionRetention";
import { settingsRouteFor } from "../settings/settingsManifest";
import { ClaudeLoginPromptButton, createClaudeLoginTerminalInWork } from "../work/ClaudeLoginPromptButton";
import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard";
import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore";
Expand Down Expand Up @@ -3356,13 +3357,13 @@ export function AgentChatPane({
}, [crossMachineLanesByMachineId, laneCacheByProject, lanes, openProjectBindings, projectBinding]);
const navigate = useNavigate();
const openAiProvidersSettings = useCallback(() => {
navigate("/settings?tab=ai#ai-providers");
navigate(settingsRouteFor("agents.providers"));
}, [navigate]);
const openLinearSettings = useCallback(() => {
navigate("/settings?tab=general#linear-connection");
navigate(settingsRouteFor("integrations.linear"));
}, [navigate]);
const openLaunchPromptClipboardSettings = useCallback(() => {
navigate("/settings?tab=general#chat-launch-clipboard");
navigate(settingsRouteFor("general.launch-prompt"));
}, [navigate]);
const copyPromptForLaunch = useCallback(async (promptText: string) => {
if (!launchPromptClipboardEnabled) return;
Expand Down Expand Up @@ -12551,7 +12552,7 @@ export function AgentChatPane({
<button
type="button"
className="rounded-md px-2 py-0.5 text-[length:calc(var(--chat-font-size)*10.5/14)] font-medium text-fg/65 transition-colors hover:bg-white/10 hover:text-fg/85"
onClick={() => navigate("/settings?tab=background-jobs")}
onClick={() => navigate(settingsRouteFor("agents.background-jobs"))}
>
Settings
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getModelById, modelSupportsFastMode, selectSupportedReasoningEffort } from "../../../shared/modelRegistry";
import { deriveConfiguredModelIds } from "../../lib/modelOptions";
import { settingsRouteFor } from "../settings/settingsManifest";

export type CtoModelSelection = {
provider: string;
Expand Down Expand Up @@ -74,7 +75,7 @@ export function useCtoModelOptions(): {
}, []);

const openProviderSettings = useCallback(() => {
navigate("/settings?tab=ai#ai-providers");
navigate(settingsRouteFor("agents.providers"));
}, [navigate]);

return { availableModelIds, loadingModels, openProviderSettings };
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/components/lanes/LanesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import type {
import { eventMatchesBinding, getEffectiveBinding } from "../../lib/keybindings";
import { SmartTooltip } from "../ui/SmartTooltip";
import { docs } from "../../onboarding/docsLinks";
import { settingsRouteFor } from "../settings/settingsManifest";

type RebaseScopePromptState = {
laneId: string;
Expand Down Expand Up @@ -2002,7 +2003,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) {
}
};

const openAutoRebaseSettings = useCallback(() => { navigate("/settings?tab=lane-templates"); }, [navigate]);
const openAutoRebaseSettings = useCallback(() => { navigate(settingsRouteFor("lanes-git.lane-templates")); }, [navigate]);
const openRebaseDetails = useCallback((laneId?: string | null) => {
const trimmedLaneId = typeof laneId === "string" ? laneId.trim() : "";
if (trimmedLaneId.length) {
Expand Down Expand Up @@ -3656,8 +3657,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) {
prefill={createPrefill}
onCreated={handleLaneCreated}
onBusyChange={(busy) => { createBusyRef.current = busy; }}
onOpenLinearSettings={() => navigate("/settings?tab=general#linear-connection")}
onNavigateToTemplates={() => navigate("/settings?tab=lane-templates")}
onOpenLinearSettings={() => navigate(settingsRouteFor("integrations.linear"))}
onNavigateToTemplates={() => navigate(settingsRouteFor("lanes-git.lane-templates"))}
/>

{rebaseScopePrompt ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
import { GitHubTabView } from "./GitHubTabView";
import { branchNameFromRef } from "./githubPrBranch";
import { useGitHubTabListModel } from "./useGitHubTabListModel";
import { settingsRouteFor } from "../../settings/settingsManifest";

export type GitHubTabProps = {
lanes: LaneSummary[];
Expand Down Expand Up @@ -943,7 +944,7 @@ export function GitHubTab({
syncedAt: snapshot?.syncedAt ?? null,
onSync: () => { void handleSync(); },
error,
onConnectGitHub: () => navigate("/settings?tab=general#github-connection"),
onConnectGitHub: () => navigate(settingsRouteFor("integrations.github")),
}}
list={{
parentRef: listRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe("AiFeaturesSection", () => {
fireEvent.click(screen.getByRole("button", { name: "Set up ai-feature-chat-auto-title" }));

await waitFor(() => {
expect(screen.getByTestId("location").textContent).toBe("/settings?tab=ai#ai-providers");
expect(screen.getByTestId("location").textContent).toBe("/settings?tab=agents#ai-providers");
});
});

Expand Down
Loading
Loading