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
1 change: 1 addition & 0 deletions apps/webapp/app/components/navigation/DashboardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ function DashboardChildMenuItem({
activeIconColor={activeIconColor}
inactiveIconColor={inactiveIconColor}
to={item.path}
matchPrefix={true}
isCollapsed={isCollapsed}
disableIconHover
yieldActiveToFavorite
Expand Down
10 changes: 9 additions & 1 deletion apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,10 @@ export function SideMenu({
data-action="tasks"
isCollapsed={isCollapsed}
yieldActiveToFavorite
nestedPaths={[
`${v3EnvironmentPath(organization, project, environment)}/tasks`,
`${v3EnvironmentPath(organization, project, environment)}/agents`,
]}
Comment on lines +948 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.

🟡 Sidebar fix absent from release notes

When this webapp-only sidebar fix ships, users receive no release note for it. The required .server-changes/ entry is missing.

Learn more

The repository requires a .server-changes/ entry for a user-facing fix confined to server apps (release-note rule). This change makes sidebar highlights persist on nested pages, but the PR adds no release note. Customers therefore cannot discover the fix in the release summary.

Example: A user who previously lost the Runs highlight on a run detail page receives the fix, but sees no entry announcing it.

Recommended fix: Add a new .server-changes/ Markdown file with area: webapp, type: fix, and one sentence describing the sidebar behavior for users.

Devin Review


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

/>
<SideMenuItem
name="Runs"
Expand All @@ -955,6 +959,7 @@ export function SideMenu({
data-action="runs"
isCollapsed={isCollapsed}
yieldActiveToFavorite
matchPrefix
/>
<SideMenuItem
name="Sessions"
Expand All @@ -965,6 +970,7 @@ export function SideMenu({
data-action="sessions"
isCollapsed={isCollapsed}
yieldActiveToFavorite
matchPrefix
/>
{(isManagedCloud ||
user.admin ||
Expand All @@ -980,6 +986,7 @@ export function SideMenu({
badge={<BetaBadge />}
isCollapsed={isCollapsed}
yieldActiveToFavorite
matchPrefix
/>
)}
</div>
Expand Down Expand Up @@ -1141,7 +1148,8 @@ function CustomizableSideMenuSection({
activeIconColor={item.activeIconColor}
inactiveIconColor={item.inactiveIconColor ?? "text-text-dimmed"}
to={item.to}
matchPrefix={item.matchPrefix}
matchPrefix={item.matchPrefix ?? (item.id !== "dashboards")}
nestedPaths={item.nestedPaths}
data-action={item.dataAction}
badge={item.badge}
isCollapsed={isCollapsed}
Expand Down
103 changes: 99 additions & 4 deletions apps/webapp/app/components/navigation/SideMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,93 @@ export function SideMenuLabel({
);
}

/**
* Normalizes a URL path by stripping any hash, query parameters, and trailing slashes.
*/
export function normalizePath(path: string): string {
const [clean = ""] = path.split("#")[0].split("?");
if (clean.length > 1 && clean.endsWith("/")) {
return clean.slice(0, -1);
}
return clean;
}

/**
* Checks whether currentPath matches targetPath, optionally checking nested paths
* while respecting segment boundaries.
*/
export function matchesPath(currentPath: string, targetPath: string, matchPrefix = false): boolean {
const normalizedCurrent = normalizePath(currentPath);
const normalizedTarget = normalizePath(targetPath);

if (normalizedCurrent === normalizedTarget) {
return true;
}

if (matchPrefix) {
return normalizedCurrent.startsWith(`${normalizedTarget}/`);
}

return false;
}

export type IsSideMenuItemActiveOptions = {
currentPath: string;
targetPath: string;
matchPrefix?: boolean;
nestedPaths?: string[];
isActiveOverride?: boolean;
yieldActiveToFavorite?: boolean;
activeFavoriteId?: string;
};

/**
* Determines whether a sidebar navigation item should be marked active.
*
* Respects:
* - Direct isActive overrides (e.g. from favorites).
* - Yielding active highlight to a matching active favorite.
* - Exact path matching.
* - Segment-boundary-aware prefix matching (e.g. /concurrency does NOT match /concurrency-limits).
* - Nested paths for parent items whose subpages live under distinct paths (e.g. Tasks).
*/
export function isSideMenuItemActive({
currentPath,
targetPath,
matchPrefix = false,
nestedPaths,
isActiveOverride,
yieldActiveToFavorite = false,
activeFavoriteId,
}: IsSideMenuItemActiveOptions): boolean {
if (isActiveOverride !== undefined) {
return isActiveOverride;
}

if (yieldActiveToFavorite && activeFavoriteId !== undefined) {
return false;
}

if (matchesPath(currentPath, targetPath, matchPrefix)) {
return true;
}

if (nestedPaths && nestedPaths.length > 0) {
const normalizedTarget = normalizePath(targetPath);
for (const nested of nestedPaths) {
const resolved =
nested.startsWith("/") && !nested.startsWith(normalizedTarget)
? `${normalizedTarget}${nested}`
: nested;
if (matchesPath(currentPath, resolved, true)) {
Comment on lines +138 to +142

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.

🔍 Clarify nested path input semantics

The Tasks caller supplies full paths under its target, so they resolve as intended. A full path outside that target instead gets concatenated to it. Define whether nestedPaths accepts relative paths, absolute paths, or both.

Devin Review


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

return true;
}
}
}

return false;
}

export function SideMenuItem({
icon,
activeIconColor,
Expand All @@ -80,6 +167,7 @@ export function SideMenuItem({
isActive: isActiveOverride,
yieldActiveToFavorite = false,
matchPrefix = false,
nestedPaths,
"data-action": dataAction,
}: {
icon?: RenderIcon;
Expand All @@ -101,6 +189,8 @@ export function SideMenuItem({
/** Overrides the default pathname === to active check (e.g. favorites match on full URL). */
isActive?: boolean;
matchPrefix?: boolean;
/** Additional subpaths (e.g. /tasks, /agents) that should activate this parent item. */
nestedPaths?: 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 @@ -114,10 +204,15 @@ export function SideMenuItem({
// Only the user's OWN favorites own a view (via the marker param); markers from shared links
// don't count (see useActiveFavoriteId).
const activeFavoriteId = useActiveFavoriteId();
const isActive =
isActiveOverride ??
((pathName === to || (matchPrefix && pathName.startsWith(`${to}/`))) &&
(!yieldActiveToFavorite || activeFavoriteId === undefined));
const isActive = isSideMenuItemActive({
currentPath: pathName,
targetPath: to,
matchPrefix,
nestedPaths,
isActiveOverride,
yieldActiveToFavorite,
activeFavoriteId,
});

const isIndented = indented && !isCollapsed;

Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/components/navigation/sideMenuSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type SideMenuItemConfig = {
inactiveIconColor?: string;
to: string;
matchPrefix?: boolean;
nestedPaths?: string[];
dataAction?: string;
badge?: ReactNode;
trailingIconClassName?: string;
Expand Down
Loading