From c00e8e4a7cf95c0a6f4597eb82622f8d2536f4ec Mon Sep 17 00:00:00 2001 From: Ashik9500 Date: Wed, 4 Nov 2026 14:31:54 +0530 Subject: [PATCH] fix(webapp): retain sidebar item active highlight on nested detail pages Fixes the issue where sidebar navigation items lost active highlight when navigating from section list pages to nested detail pages (e.g. Tasks and Runs). Supports nestedPaths for Tasks, boundary-safe prefix matching, and preserves dashboard and favorite isolation. --- .../components/navigation/DashboardList.tsx | 1 + .../app/components/navigation/SideMenu.tsx | 10 +- .../components/navigation/SideMenuItem.tsx | 103 ++- .../navigation/sideMenuSections.tsx | 1 + apps/webapp/test/sideMenuNavigation.test.ts | 639 ++++++++++++++++++ 5 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/test/sideMenuNavigation.test.ts diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx index 25de0b4734a..5704334fd67 100644 --- a/apps/webapp/app/components/navigation/DashboardList.tsx +++ b/apps/webapp/app/components/navigation/DashboardList.tsx @@ -180,6 +180,7 @@ function DashboardChildMenuItem({ activeIconColor={activeIconColor} inactiveIconColor={inactiveIconColor} to={item.path} + matchPrefix={true} isCollapsed={isCollapsed} disableIconHover yieldActiveToFavorite diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a5bfe8d6bf9..662cf0e9ca2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -945,6 +945,10 @@ export function SideMenu({ data-action="tasks" isCollapsed={isCollapsed} yieldActiveToFavorite + nestedPaths={[ + `${v3EnvironmentPath(organization, project, environment)}/tasks`, + `${v3EnvironmentPath(organization, project, environment)}/agents`, + ]} /> {(isManagedCloud || user.admin || @@ -980,6 +986,7 @@ export function SideMenu({ badge={} isCollapsed={isCollapsed} yieldActiveToFavorite + matchPrefix /> )} @@ -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} diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 6b959783c01..bf70fcf85a9 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -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)) { + return true; + } + } + } + + return false; +} + export function SideMenuItem({ icon, activeIconColor, @@ -80,6 +167,7 @@ export function SideMenuItem({ isActive: isActiveOverride, yieldActiveToFavorite = false, matchPrefix = false, + nestedPaths, "data-action": dataAction, }: { icon?: RenderIcon; @@ -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 @@ -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; diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx index 0d4a2562289..d01b8a8138d 100644 --- a/apps/webapp/app/components/navigation/sideMenuSections.tsx +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -59,6 +59,7 @@ type SideMenuItemConfig = { inactiveIconColor?: string; to: string; matchPrefix?: boolean; + nestedPaths?: string[]; dataAction?: string; badge?: ReactNode; trailingIconClassName?: string; diff --git a/apps/webapp/test/sideMenuNavigation.test.ts b/apps/webapp/test/sideMenuNavigation.test.ts new file mode 100644 index 00000000000..9b7d52a38f3 --- /dev/null +++ b/apps/webapp/test/sideMenuNavigation.test.ts @@ -0,0 +1,639 @@ +import { describe, expect, it } from "vitest"; +import { + isSideMenuItemActive, + matchesPath, + normalizePath, +} from "~/components/navigation/SideMenuItem"; + +describe("Sidebar Navigation Route Matching", () => { + const envPath = "/orgs/acme/projects/rocket/env/dev"; + const orgPath = "/orgs/acme"; + + describe("normalizePath", () => { + it("strips trailing slashes, search params, and hashes", () => { + expect(normalizePath(`${envPath}/`)).toBe(envPath); + expect(normalizePath(`${envPath}?fav=123`)).toBe(envPath); + expect(normalizePath(`${envPath}#section`)).toBe(envPath); + expect(normalizePath(`${envPath}/runs/?filter=failed#top`)).toBe(`${envPath}/runs`); + expect(normalizePath("/")).toBe("/"); + }); + }); + + describe("matchesPath", () => { + it("matches exact paths", () => { + expect(matchesPath(`${envPath}/runs`, `${envPath}/runs`)).toBe(true); + expect(matchesPath(`${envPath}/runs/`, `${envPath}/runs`)).toBe(true); + expect(matchesPath(`${envPath}/runs`, `${envPath}/runs/`)).toBe(true); + expect(matchesPath(`${envPath}/runs`, `${envPath}/sessions`)).toBe(false); + }); + + it("matches subpaths only when matchPrefix is true", () => { + expect(matchesPath(`${envPath}/runs/run_123`, `${envPath}/runs`, false)).toBe(false); + expect(matchesPath(`${envPath}/runs/run_123`, `${envPath}/runs`, true)).toBe(true); + }); + + it("respects segment boundaries when matchPrefix is true", () => { + // /concurrency must not match /concurrency-limits + expect(matchesPath(`${envPath}/concurrency-limits`, `${envPath}/concurrency`, true)).toBe(false); + expect(matchesPath(`${envPath}/concurrency/queue_1`, `${envPath}/concurrency`, true)).toBe(true); + + // /billing must not match /billing-limits + expect(matchesPath(`${orgPath}/settings/billing-limits`, `${orgPath}/settings/billing`, true)).toBe(false); + expect(matchesPath(`${orgPath}/settings/billing/invoices`, `${orgPath}/settings/billing`, true)).toBe(true); + }); + }); + + describe("1. Main section routes", () => { + it("highlights the exact section route when visited", () => { + // Tasks main route + expect( + isSideMenuItemActive({ + currentPath: envPath, + targetPath: envPath, + nestedPaths: [`${envPath}/tasks`, `${envPath}/agents`], + }) + ).toBe(true); + + // Runs main route + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs`, + targetPath: `${envPath}/runs`, + matchPrefix: true, + }) + ).toBe(true); + + // Sessions main route + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/sessions`, + targetPath: `${envPath}/sessions`, + matchPrefix: true, + }) + ).toBe(true); + + // Concurrency main route + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/concurrency`, + targetPath: `${envPath}/concurrency`, + matchPrefix: true, + }) + ).toBe(true); + + // Deployments main route + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/deployments`, + targetPath: `${envPath}/deployments`, + matchPrefix: true, + }) + ).toBe(true); + }); + }); + + describe("2. Nested detail routes", () => { + it("keeps parent navigation items highlighted on detail routes", () => { + // Sessions detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/sessions/session_123`, + targetPath: `${envPath}/sessions`, + matchPrefix: true, + }) + ).toBe(true); + + // Webhooks endpoint detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/webhooks/endpoints/ep_abc`, + targetPath: `${envPath}/webhooks`, + matchPrefix: true, + }) + ).toBe(true); + + // Webhooks delivery detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/webhooks/deliveries/del_xyz`, + targetPath: `${envPath}/webhooks`, + matchPrefix: true, + }) + ).toBe(true); + + // Prompts detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/prompts/my-prompt`, + targetPath: `${envPath}/prompts`, + matchPrefix: true, + }) + ).toBe(true); + + // Models detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/models/gpt-4o`, + targetPath: `${envPath}/models`, + matchPrefix: true, + }) + ).toBe(true); + + // Models compare + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/models/compare`, + targetPath: `${envPath}/models`, + matchPrefix: true, + }) + ).toBe(true); + + // Alerts detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/alerts/alert_123`, + targetPath: `${envPath}/alerts`, + matchPrefix: true, + }) + ).toBe(true); + + // Alerts new + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/alerts/new`, + targetPath: `${envPath}/alerts`, + matchPrefix: true, + }) + ).toBe(true); + + // Errors detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/errors/err_fingerprint_abc`, + targetPath: `${envPath}/errors`, + matchPrefix: true, + }) + ).toBe(true); + + // Deployments detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/deployments/dep_456`, + targetPath: `${envPath}/deployments`, + matchPrefix: true, + }) + ).toBe(true); + + // Environment variables new + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/environment-variables/new`, + targetPath: `${envPath}/environment-variables`, + matchPrefix: true, + }) + ).toBe(true); + + // Waitpoint tokens detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/waitpoints/tokens/wpt_789`, + targetPath: `${envPath}/waitpoints/tokens`, + matchPrefix: true, + }) + ).toBe(true); + + // Batches detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/batches/batch_123`, + targetPath: `${envPath}/batches`, + matchPrefix: true, + }) + ).toBe(true); + + // Bulk actions detail + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/bulk-actions/action_999`, + targetPath: `${envPath}/bulk-actions`, + matchPrefix: true, + }) + ).toBe(true); + }); + }); + + describe("3. Tasks detail pages", () => { + const tasksItemConfig = { + targetPath: envPath, + nestedPaths: [`${envPath}/tasks`, `${envPath}/agents`], + yieldActiveToFavorite: true, + }; + + it("highlights Tasks on standard task detail page", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/standard/process-order`, + ...tasksItemConfig, + }) + ).toBe(true); + }); + + it("highlights Tasks on scheduled task detail page", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/scheduled/nightly-cleanup`, + ...tasksItemConfig, + }) + ).toBe(true); + }); + + it("highlights Tasks on tasks dashboard and stream pages", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/dashboard`, + ...tasksItemConfig, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/stream`, + ...tasksItemConfig, + }) + ).toBe(true); + }); + + it("highlights Tasks on agent task detail page", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/agents/support-bot`, + ...tasksItemConfig, + }) + ).toBe(true); + }); + + it("does NOT highlight Tasks on other environment sections", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs`, + ...tasksItemConfig, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/sessions`, + ...tasksItemConfig, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/alerts`, + ...tasksItemConfig, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/deployments`, + ...tasksItemConfig, + }) + ).toBe(false); + }); + + it("supports relative nestedPaths for Tasks", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/standard/my-task`, + targetPath: envPath, + nestedPaths: ["/tasks", "/agents"], + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/agents/agent-task`, + targetPath: envPath, + nestedPaths: ["/tasks", "/agents"], + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs`, + targetPath: envPath, + nestedPaths: ["/tasks", "/agents"], + }) + ).toBe(false); + }); + }); + + describe("4. Runs detail pages", () => { + const runsItemConfig = { + targetPath: `${envPath}/runs`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + it("highlights Runs on individual run detail page", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_cm123456789`, + ...runsItemConfig, + }) + ).toBe(true); + }); + + it("highlights Runs on nested run streaming and span subpages", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_cm123456789/stream`, + ...runsItemConfig, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_cm123456789/spans/span_001`, + ...runsItemConfig, + }) + ).toBe(true); + }); + + it("does NOT highlight Runs on unrelated sibling sections", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/sessions`, + ...runsItemConfig, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/standard/test`, + ...runsItemConfig, + }) + ).toBe(false); + }); + }); + + describe("5. Similar but unrelated route prefixes (Path Boundary Respect)", () => { + it("/concurrency does not activate on /concurrency-limits", () => { + const concurrencyItem = { + targetPath: `${envPath}/concurrency`, + matchPrefix: true, + }; + + const concurrencyLimitsItem = { + targetPath: `${envPath}/concurrency-limits`, + matchPrefix: true, + }; + + // On /concurrency-limits: + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/concurrency-limits`, + ...concurrencyItem, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/concurrency-limits`, + ...concurrencyLimitsItem, + }) + ).toBe(true); + + // On /concurrency/queue_alpha: + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/concurrency/queue_alpha`, + ...concurrencyItem, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/concurrency/queue_alpha`, + ...concurrencyLimitsItem, + }) + ).toBe(false); + }); + + it("/runs does not activate on /runs-archived", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs-archived`, + targetPath: `${envPath}/runs`, + matchPrefix: true, + }) + ).toBe(false); + }); + + it("/billing does not activate on /billing-limits", () => { + expect( + isSideMenuItemActive({ + currentPath: `${orgPath}/settings/billing-limits`, + targetPath: `${orgPath}/settings/billing`, + matchPrefix: true, + }) + ).toBe(false); + }); + }); + + describe("6. Dashboard-specific routes", () => { + const dashboardsLandingItem = { + targetPath: `${envPath}/dashboards`, + matchPrefix: false, // Dashboards landing page only + yieldActiveToFavorite: true, + }; + + const runMetricsItem = { + targetPath: `${envPath}/dashboards/overview`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + const aiMetricsItem = { + targetPath: `${envPath}/dashboards/llm`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + const customDashboardItem = { + targetPath: `${envPath}/dashboards/custom/db_sales`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + it("highlights parent Dashboards item only on the landing page", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards`, + ...dashboardsLandingItem, + }) + ).toBe(true); + + // On landing page, specific dashboards are not active + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards`, + ...runMetricsItem, + }) + ).toBe(false); + }); + + it("retains active state on specific dashboard items without activating the landing parent", () => { + // Visiting Run metrics: + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/overview`, + ...runMetricsItem, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/overview`, + ...dashboardsLandingItem, + }) + ).toBe(false); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/overview`, + ...aiMetricsItem, + }) + ).toBe(false); + + // Visiting AI metrics: + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/llm`, + ...aiMetricsItem, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/llm`, + ...dashboardsLandingItem, + }) + ).toBe(false); + + // Visiting Custom dashboard: + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/custom/db_sales`, + ...customDashboardItem, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/custom/db_sales`, + ...dashboardsLandingItem, + }) + ).toBe(false); + }); + + it("allows custom dashboard item to retain active state on nested widgets/subroutes", () => { + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/custom/db_sales/widgets/w_revenue`, + ...customDashboardItem, + }) + ).toBe(true); + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/dashboards/custom/db_sales/widgets/w_revenue`, + ...dashboardsLandingItem, + }) + ).toBe(false); + }); + }); + + describe("7. Favourite navigation behaviour", () => { + it("yields active state to active favorite when yieldActiveToFavorite is true", () => { + const runsItem = { + targetPath: `${envPath}/runs`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + // Current view matches an active favorite + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_123`, + ...runsItem, + activeFavoriteId: "fav_run_123", + }) + ).toBe(false); + + // The favorite item itself uses isActiveOverride: true + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_123`, + targetPath: `${envPath}/runs/run_123?fav=fav_run_123`, + isActiveOverride: true, + }) + ).toBe(true); + }); + + it("keeps regular highlight when no favorite owns the view", () => { + const runsItem = { + targetPath: `${envPath}/runs`, + matchPrefix: true, + yieldActiveToFavorite: true, + }; + + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/runs/run_123`, + ...runsItem, + activeFavoriteId: undefined, + }) + ).toBe(true); + }); + + it("yields active state on Tasks detail pages when a favorite is active", () => { + const tasksItem = { + targetPath: envPath, + nestedPaths: [`${envPath}/tasks`, `${envPath}/agents`], + yieldActiveToFavorite: true, + }; + + // Active favorite on task detail page + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/standard/my-task`, + ...tasksItem, + activeFavoriteId: "fav_task_my_task", + }) + ).toBe(false); + + // Normal navigation without favorite marker + expect( + isSideMenuItemActive({ + currentPath: `${envPath}/tasks/standard/my-task`, + ...tasksItem, + activeFavoriteId: undefined, + }) + ).toBe(true); + }); + + it("does not yield when yieldActiveToFavorite is false", () => { + expect( + isSideMenuItemActive({ + currentPath: "/account", + targetPath: "/account", + yieldActiveToFavorite: false, + activeFavoriteId: "fav_something", + }) + ).toBe(true); + }); + }); +}); +