diff --git a/.server-changes/fix-side-menu-active-state.md b/.server-changes/fix-side-menu-active-state.md
new file mode 100644
index 00000000000..e8a474c6017
--- /dev/null
+++ b/.server-changes/fix-side-menu-active-state.md
@@ -0,0 +1,7 @@
+---
+area: webapp
+type: fix
+---
+
+Keep side menu items highlighted when viewing detail pages and sub-routes within a section.
+
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index 64bae92c2cd..450c417d0c8 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -154,6 +154,7 @@ export function OrganizationSettingsSideMenu({
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3PrivateConnectionsPath(organization)}
+ matchPrefix
data-action="private-connections"
/>
)}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index a5bfe8d6bf9..201f800735e 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -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({
@@ -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`,
+ ]}
data-action="tasks"
isCollapsed={isCollapsed}
yieldActiveToFavorite
@@ -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
@@ -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
@@ -976,6 +985,7 @@ export function SideMenu({
activeIconColor="text-webhooks"
inactiveIconColor="text-text-dimmed"
to={v3WebhooksPath(organization, project, environment)}
+ matchPrefix
data-action="webhooks"
badge={}
isCollapsed={isCollapsed}
@@ -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}
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index 6b959783c01..3cbc8121c66 100644
--- a/apps/webapp/app/components/navigation/SideMenuItem.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx
@@ -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,
@@ -80,6 +124,7 @@ export function SideMenuItem({
isActive: isActiveOverride,
yieldActiveToFavorite = false,
matchPrefix = false,
+ activePathPrefix,
"data-action": dataAction,
}: {
icon?: RenderIcon;
@@ -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
@@ -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;
diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx
index 9e17aa3fbdf..4cb3baf24d2 100644
--- a/apps/webapp/app/components/navigation/sideMenuSections.tsx
+++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx
@@ -154,9 +154,7 @@ export function buildSideMenuSections({
id: "metrics",
title: "Observability",
items: [
- ...(alertsInObservability
- ? [{ ...alertsItem, matchPrefix: true, badge: }]
- : []),
+ ...(alertsInObservability ? [{ ...alertsItem, badge: }] : []),
...(isAdmin || featureFlags.hasLogsPageAccess
? [
{
@@ -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,
@@ -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,
+ })),
+ }));
}
diff --git a/apps/webapp/test/sideMenuPathActive.test.ts b/apps/webapp/test/sideMenuPathActive.test.ts
new file mode 100644
index 00000000000..ffbaaa32997
--- /dev/null
+++ b/apps/webapp/test/sideMenuPathActive.test.ts
@@ -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);
+ });
+});
diff --git a/apps/webapp/test/sideMenuSections.test.ts b/apps/webapp/test/sideMenuSections.test.ts
index ee8019dbccc..29ce5950a3e 100644
--- a/apps/webapp/test/sideMenuSections.test.ts
+++ b/apps/webapp/test/sideMenuSections.test.ts
@@ -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", () => {