Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .server-changes/fix-side-menu-active-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
area: webapp
type: fix
---

Keep side menu items highlighted when viewing detail pages and sub-routes within a section.

Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export function OrganizationSettingsSideMenu({
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3PrivateConnectionsPath(organization)}
matchPrefix
data-action="private-connections"
/>
)}
Expand Down
14 changes: 12 additions & 2 deletions apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,8 @@ export function SideMenu({
? "hidden"
: "shown";

const environmentPath = v3EnvironmentPath(organization, project, environment);

// The customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. The
// user's saved order/hidden preferences are applied at render below.
const staticSections = buildSideMenuSections({
Expand Down Expand Up @@ -941,7 +943,12 @@ export function SideMenu({
icon={TasksIcon}
activeIconColor="text-tasks"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentPath(organization, project, environment)}
to={environmentPath}
activePathPrefix={[
`${environmentPath}/tasks`,
`${environmentPath}/agents`,
`${environmentPath}/schedules`,
]}
Comment on lines +947 to +951

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Task testing pages remain unhighlighted

The Tasks list links to /test/tasks/... and /playground/..., outside activePathPrefix. These pages remain unhighlighted; decide whether testing belongs under Tasks.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

data-action="tasks"
isCollapsed={isCollapsed}
yieldActiveToFavorite
Expand All @@ -952,6 +959,7 @@ export function SideMenu({
activeIconColor="text-runs"
inactiveIconColor="text-text-dimmed"
to={v3RunsPath(organization, project, environment)}
matchPrefix
data-action="runs"
isCollapsed={isCollapsed}
yieldActiveToFavorite
Expand All @@ -962,6 +970,7 @@ export function SideMenu({
activeIconColor="text-sessions"
inactiveIconColor="text-text-dimmed"
to={v3SessionsPath(organization, project, environment)}
matchPrefix
data-action="sessions"
isCollapsed={isCollapsed}
yieldActiveToFavorite
Expand All @@ -976,6 +985,7 @@ export function SideMenu({
activeIconColor="text-webhooks"
inactiveIconColor="text-text-dimmed"
to={v3WebhooksPath(organization, project, environment)}
matchPrefix
data-action="webhooks"
badge={<BetaBadge />}
isCollapsed={isCollapsed}
Expand Down Expand Up @@ -1141,7 +1151,7 @@ function CustomizableSideMenuSection({
activeIconColor={item.activeIconColor}
inactiveIconColor={item.inactiveIconColor ?? "text-text-dimmed"}
to={item.to}
matchPrefix={item.matchPrefix}
matchPrefix={item.matchPrefix ?? true}
data-action={item.dataAction}
badge={item.badge}
isCollapsed={isCollapsed}
Expand Down
49 changes: 48 additions & 1 deletion apps/webapp/app/components/navigation/SideMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,50 @@ export function SideMenuLabel({
);
}

function matchesPathPrefix(pathName: string, prefix: string): boolean {
const normalized = prefix.replace(/\/+$/, "");
if (normalized.length === 0) {
return false;
}
return pathName === normalized || pathName.startsWith(`${normalized}/`);
}

/**
* Returns whether a side menu item should appear active for `pathName`.
* Matches `to` exactly, `to` on a `/` boundary when `matchPrefix` is true, or
* any prefix in `activePathPrefix` (for items whose `to` is a shared root such
* as the environment URL while its sub-pages live under dedicated segments).
*/
export function isSideMenuPathActive({
pathName,
to,
matchPrefix = false,
activePathPrefix,
}: {
pathName: string;
to: string;
matchPrefix?: boolean;
activePathPrefix?: string | readonly string[];
}): boolean {
if (pathName === to) {
return true;
}

const prefixes: string[] = [];
if (matchPrefix) {
prefixes.push(to);
}
if (activePathPrefix !== undefined) {
if (typeof activePathPrefix === "string") {
prefixes.push(activePathPrefix);
} else {
prefixes.push(...activePathPrefix);
}
}

return prefixes.some((prefix) => matchesPathPrefix(pathName, prefix));
}

export function SideMenuItem({
icon,
activeIconColor,
Expand All @@ -80,6 +124,7 @@ export function SideMenuItem({
isActive: isActiveOverride,
yieldActiveToFavorite = false,
matchPrefix = false,
activePathPrefix,
"data-action": dataAction,
}: {
icon?: RenderIcon;
Expand All @@ -101,6 +146,8 @@ export function SideMenuItem({
/** Overrides the default pathname === to active check (e.g. favorites match on full URL). */
isActive?: boolean;
matchPrefix?: boolean;
/** Additional path prefixes (matched on a `/` boundary) that activate this item. */
activePathPrefix?: string | readonly string[];
/**
* In menus that render the Favorites section (the main project menu), an active favorite owns
* the highlight, so the plain item yields its active state to it. Menus without a favorites
Expand All @@ -116,7 +163,7 @@ export function SideMenuItem({
const activeFavoriteId = useActiveFavoriteId();
const isActive =
isActiveOverride ??
((pathName === to || (matchPrefix && pathName.startsWith(`${to}/`))) &&
(isSideMenuPathActive({ pathName, to, matchPrefix, activePathPrefix }) &&
(!yieldActiveToFavorite || activeFavoriteId === undefined));

const isIndented = indented && !isCollapsed;
Expand Down
15 changes: 11 additions & 4 deletions apps/webapp/app/components/navigation/sideMenuSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,7 @@ export function buildSideMenuSections({
id: "metrics",
title: "Observability",
items: [
...(alertsInObservability
? [{ ...alertsItem, matchPrefix: true, badge: <NewBadge /> }]
: []),
...(alertsInObservability ? [{ ...alertsItem, badge: <NewBadge /> }] : []),
...(isAdmin || featureFlags.hasLogsPageAccess
? [
{
Expand Down Expand Up @@ -206,6 +204,7 @@ export function buildSideMenuSections({
icon: ChartBarIcon,
activeIconColor: "text-metrics",
to: v3DashboardsLandingPath(organization, project, environment),
matchPrefix: false,
dataAction: "dashboards-landing",
action: dashboards?.action,
after: dashboards?.after,
Expand Down Expand Up @@ -355,5 +354,13 @@ export function buildSideMenuSections({
],
});

return staticSections;
// Stay highlighted on detail/sub-pages unless an item explicitly opts out (e.g. Dashboards,
// whose child routes are rendered as their own menu items).
return staticSections.map((section) => ({
...section,
items: section.items.map((item) => ({
...item,
matchPrefix: item.matchPrefix ?? true,
})),
}));
}
85 changes: 85 additions & 0 deletions apps/webapp/test/sideMenuPathActive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { isSideMenuPathActive } from "~/components/navigation/SideMenuItem";

describe("isSideMenuPathActive", () => {
// Catches: prefix-matched items losing active state on detail pages or matching sibling routes with a shared prefix
it("matches exact paths and sub-paths on a '/' boundary without bleeding into sibling routes", () => {
const to = "/orgs/org-1/projects/proj-1/env/dev/concurrency";

expect(isSideMenuPathActive({ pathName: to, to, matchPrefix: true })).toBe(true);
expect(isSideMenuPathActive({ pathName: `${to}/queues`, to, matchPrefix: true })).toBe(true);
expect(
isSideMenuPathActive({
pathName: "/orgs/org-1/projects/proj-1/env/dev/concurrency-limits",
to,
matchPrefix: true,
})
).toBe(false);
expect(isSideMenuPathActive({ pathName: `${to}/queues`, to, matchPrefix: false })).toBe(false);
});

// Catches: Tasks activePathPrefix failing to match task, agent, and schedule detail pages or matching unrelated env sections
it("matches Tasks across its dedicated sub-path prefixes without matching sibling environment routes", () => {
const envPath = "/orgs/org-1/projects/proj-1/env/dev";
const activePathPrefix = [`${envPath}/tasks`, `${envPath}/agents`, `${envPath}/schedules`];

expect(isSideMenuPathActive({ pathName: envPath, to: envPath, activePathPrefix })).toBe(true);
expect(
isSideMenuPathActive({
pathName: `${envPath}/tasks/standard/my-task`,
to: envPath,
activePathPrefix,
})
).toBe(true);
expect(
isSideMenuPathActive({
pathName: `${envPath}/agents/my-agent`,
to: envPath,
activePathPrefix,
})
).toBe(true);
expect(
isSideMenuPathActive({
pathName: `${envPath}/schedules/sched_123`,
to: envPath,
activePathPrefix,
})
).toBe(true);

expect(
isSideMenuPathActive({
pathName: `${envPath}/runs/run_123`,
to: envPath,
activePathPrefix,
})
).toBe(false);
expect(
isSideMenuPathActive({
pathName: `${envPath}/webhooks/wh_123`,
to: envPath,
activePathPrefix,
})
).toBe(false);
});

// Catches: root "/" or trailing-slash prefixes matching unrelated routes or failing to match exact normalized paths
it("ignores empty or root prefixes while normalizing trailing slashes", () => {
expect(isSideMenuPathActive({ pathName: "/orgs/org-1", to: "/", matchPrefix: true })).toBe(
false
);
expect(
isSideMenuPathActive({
pathName: "/orgs/org-1/settings/private-connections",
to: "/orgs/org-1/settings/private-connections/",
matchPrefix: true,
})
).toBe(true);
expect(
isSideMenuPathActive({
pathName: "/orgs/org-1/settings/private-connections/new",
to: "/orgs/org-1/settings/private-connections/",
matchPrefix: true,
})
).toBe(true);
});
});
15 changes: 14 additions & 1 deletion apps/webapp/test/sideMenuSections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,20 @@ describe("buildSideMenuSections alerts placement", () => {
expect(ids.indexOf("alerts")).toBe(ids.indexOf("bulk-actions") + 1);
const alerts = manage.items.find((i) => i.id === "alerts")!;
expect(alerts.badge).toBeUndefined();
expect(alerts.matchPrefix).toBeUndefined();
expect(alerts.matchPrefix).toBe(true);
});

// Catches: section items losing active highlight on detail pages or Dashboards overriding child dashboard links
it("defaults section items to matchPrefix: true while keeping Dashboards exact", () => {
const items = build(true).flatMap((s) => s.items);
const dashboards = items.find((i) => i.id === "dashboards")!;
expect(dashboards.matchPrefix).toBe(false);

const otherItems = items.filter((i) => i.id !== "dashboards");
expect(otherItems.length).toBeGreaterThan(0);
for (const item of otherItems) {
expect(item.matchPrefix, `expected ${item.id} to match prefix`).toBe(true);
}
});

it("moves Alerts into Observability when the alerts flag is on", () => {
Expand Down