From 0d899b30763dfdd79a9dc1107c02e8d2c838386e Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Wed, 15 Jul 2026 18:16:32 +0200 Subject: [PATCH 01/12] chore(release): Bump versions to v5.35.0 (#12004) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- .env | 2 +- api/pyproject.toml | 2 +- api/src/backend/api/specs/v1.yaml | 2 +- api/uv.lock | 2 +- docs/getting-started/installation/prowler-app.mdx | 4 ++-- prowler/config/config.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index e4a634cf71..8294fac91c 100644 --- a/.env +++ b/.env @@ -158,7 +158,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.34.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/api/pyproject.toml b/api/pyproject.toml index ddb9170cde..8061632cb8 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.35.0" +version = "1.36.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 369855cb89..de2cbfce2f 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.35.0 + version: 1.36.0 description: |- Prowler API specification. diff --git a/api/uv.lock b/api/uv.lock index 04a6ce76ef..e579bb7b92 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.35.0" +version = "1.36.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 20a45a405a..0c2d032315 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.33.0" -PROWLER_API_VERSION="5.33.0" +PROWLER_UI_VERSION="5.34.0" +PROWLER_API_VERSION="5.34.0" ``` diff --git a/prowler/config/config.py b/prowler/config/config.py index 5bec954def..c76f79910a 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.34.0" +prowler_version = "5.35.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/pyproject.toml b/pyproject.toml index 2bd08999b6..2edbcc428b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.34.0" +version = "5.35.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/uv.lock b/uv.lock index 0c73b97181..a0136100e0 100644 --- a/uv.lock +++ b/uv.lock @@ -3553,7 +3553,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.34.0" +version = "5.35.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, From 0dc424031b67adbbf3f3eb19cebf980a8ad6eb41 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Thu, 16 Jul 2026 10:54:23 +0200 Subject: [PATCH 02/12] fix(api): apply long task limits to Attack Paths scans (#12009) --- api/changelog.d/attack-paths-scan-time-limit.fixed.md | 1 + api/src/backend/config/celery.py | 1 + api/src/backend/tasks/tests/test_tasks.py | 1 + 3 files changed, 3 insertions(+) create mode 100644 api/changelog.d/attack-paths-scan-time-limit.fixed.md diff --git a/api/changelog.d/attack-paths-scan-time-limit.fixed.md b/api/changelog.d/attack-paths-scan-time-limit.fixed.md new file mode 100644 index 0000000000..3726b52668 --- /dev/null +++ b/api/changelog.d/attack-paths-scan-time-limit.fixed.md @@ -0,0 +1 @@ +`attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index 1a35a1a753..9ac4f1e4ef 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -74,6 +74,7 @@ celery_app.conf.task_annotations = { for name in ( "scan-perform", "scan-perform-scheduled", + "attack-paths-scan-perform", "provider-deletion", "tenant-deletion", ) diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 6622ab10a5..476444cb00 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -3304,6 +3304,7 @@ class TestTaskTimeLimits: for name in ( "scan-perform", "scan-perform-scheduled", + "attack-paths-scan-perform", "provider-deletion", "tenant-deletion", ): From 1ee71c1f20882dd4707e9e5a1cede47be56fcd68 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:40:08 +0200 Subject: [PATCH 03/12] feat(ui): redesign app sidebar navigation (#11994) --- ui/app/(prowler)/lighthouse/page.tsx | 8 +- ui/app/(prowler)/page.tsx | 8 +- ui/changelog.d/sidebar-redesign.changed.md | 1 + .../app-sidebar/app-sidebar-content.test.tsx | 126 +++++++ .../app-sidebar/app-sidebar-content.tsx | 68 ++++ .../app-sidebar-mode-store.test.ts | 73 ++++ .../app-sidebar/app-sidebar-mode-store.ts | 54 +++ .../app-sidebar-mode-sync.test.tsx | 20 ++ .../app-sidebar/app-sidebar-mode-sync.tsx | 20 ++ .../app-sidebar/app-sidebar-mode-toggle.tsx | 115 ++++++ .../layout/app-sidebar/app-sidebar.tsx | 10 + ui/components/layout/app-sidebar/index.ts | 5 + .../layout/app-sidebar/launch-scan-action.tsx | 57 +++ .../app-sidebar/mobile-app-sidebar.test.tsx | 62 ++++ .../layout/app-sidebar/mobile-app-sidebar.tsx | 63 ++++ .../app-sidebar/navigation-config.test.ts | 335 ++++++++++++++++++ .../layout/app-sidebar/navigation-config.ts | 325 +++++++++++++++++ .../layout/app-sidebar/sidebar-footer.tsx | 63 ++++ .../app-sidebar/sidebar-navigation.test.tsx | 196 ++++++++++ .../layout/app-sidebar/sidebar-navigation.tsx | 288 +++++++++++++++ ui/components/layout/app-sidebar/types.ts | 71 ++++ ui/components/layout/index.ts | 2 +- .../layout/main-layout/main-layout.test.tsx | 17 +- .../layout/main-layout/main-layout.tsx | 46 +-- .../layout/nav-bar/navbar-client.test.tsx | 12 +- .../layout/nav-bar/navbar-client.tsx | 10 +- .../layout/sidebar/collapsible-menu.tsx | 103 ------ ui/components/layout/sidebar/index.ts | 7 - ui/components/layout/sidebar/menu-item.tsx | 82 ----- ui/components/layout/sidebar/menu.test.tsx | 302 ---------------- ui/components/layout/sidebar/menu.tsx | 271 -------------- .../layout/sidebar/sheet-menu.test.tsx | 35 -- ui/components/layout/sidebar/sheet-menu.tsx | 63 ---- .../layout/sidebar/sidebar-toggle.tsx | 38 -- ui/components/layout/sidebar/sidebar.tsx | 59 --- .../layout/sidebar/submenu-item.test.tsx | 57 --- ui/components/layout/sidebar/submenu-item.tsx | 155 -------- ui/components/shadcn/button/button.tsx | 6 - ui/components/shadcn/index.ts | 1 + .../shadcn/navigation-button/index.ts | 1 + .../navigation-button.test.tsx | 31 ++ .../navigation-button/navigation-button.tsx | 115 ++++++ ui/components/shadcn/sheet/sheet.test.tsx | 31 ++ ui/components/shadcn/sheet/sheet.tsx | 150 ++++---- .../sidebar/navigation-mode-sync.test.tsx | 24 -- .../sidebar/navigation-mode-sync.tsx | 20 -- .../sidebar/navigation-mode-toggle.tsx | 139 -------- ui/components/ui/index.ts | 1 - ui/components/ui/sidebar/index.ts | 3 - ui/hooks/index.ts | 1 - ui/hooks/use-sidebar.ts | 54 --- ui/lib/index.ts | 1 - ui/lib/menu-list.test.ts | 210 ----------- ui/lib/menu-list.ts | 294 --------------- ui/package.json | 6 +- ui/playwright.config.ts | 10 + ui/styles/globals.css | 62 ++++ ui/tests/navigation/navigation-page.ts | 62 ++++ ui/tests/navigation/navigation.md | 36 ++ ui/tests/navigation/navigation.spec.ts | 22 ++ ui/types/components.ts | 62 ---- 61 files changed, 2441 insertions(+), 2128 deletions(-) create mode 100644 ui/changelog.d/sidebar-redesign.changed.md create mode 100644 ui/components/layout/app-sidebar/app-sidebar-content.test.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-content.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-store.ts create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar.tsx create mode 100644 ui/components/layout/app-sidebar/index.ts create mode 100644 ui/components/layout/app-sidebar/launch-scan-action.tsx create mode 100644 ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx create mode 100644 ui/components/layout/app-sidebar/mobile-app-sidebar.tsx create mode 100644 ui/components/layout/app-sidebar/navigation-config.test.ts create mode 100644 ui/components/layout/app-sidebar/navigation-config.ts create mode 100644 ui/components/layout/app-sidebar/sidebar-footer.tsx create mode 100644 ui/components/layout/app-sidebar/sidebar-navigation.test.tsx create mode 100644 ui/components/layout/app-sidebar/sidebar-navigation.tsx create mode 100644 ui/components/layout/app-sidebar/types.ts delete mode 100644 ui/components/layout/sidebar/collapsible-menu.tsx delete mode 100644 ui/components/layout/sidebar/index.ts delete mode 100644 ui/components/layout/sidebar/menu-item.tsx delete mode 100644 ui/components/layout/sidebar/menu.test.tsx delete mode 100644 ui/components/layout/sidebar/menu.tsx delete mode 100644 ui/components/layout/sidebar/sheet-menu.test.tsx delete mode 100644 ui/components/layout/sidebar/sheet-menu.tsx delete mode 100644 ui/components/layout/sidebar/sidebar-toggle.tsx delete mode 100644 ui/components/layout/sidebar/sidebar.tsx delete mode 100644 ui/components/layout/sidebar/submenu-item.test.tsx delete mode 100644 ui/components/layout/sidebar/submenu-item.tsx create mode 100644 ui/components/shadcn/navigation-button/index.ts create mode 100644 ui/components/shadcn/navigation-button/navigation-button.test.tsx create mode 100644 ui/components/shadcn/navigation-button/navigation-button.tsx create mode 100644 ui/components/shadcn/sheet/sheet.test.tsx delete mode 100644 ui/components/sidebar/navigation-mode-sync.test.tsx delete mode 100644 ui/components/sidebar/navigation-mode-sync.tsx delete mode 100644 ui/components/sidebar/navigation-mode-toggle.tsx delete mode 100644 ui/components/ui/sidebar/index.ts delete mode 100644 ui/hooks/use-sidebar.ts delete mode 100644 ui/lib/menu-list.test.ts delete mode 100644 ui/lib/menu-list.ts create mode 100644 ui/tests/navigation/navigation-page.ts create mode 100644 ui/tests/navigation/navigation.md create mode 100644 ui/tests/navigation/navigation.spec.ts diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index cd3ad44ccf..625aaf795a 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -13,10 +13,12 @@ import { import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat"; import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { + APP_SIDEBAR_MODE, + AppSidebarModeSync, +} from "@/components/layout/app-sidebar"; import { Chat } from "@/components/lighthouse-v1"; import { ContentLayout } from "@/components/shadcn/content-layout"; -import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync"; -import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import { isCloud } from "@/lib/shared/env"; @@ -78,7 +80,7 @@ export default async function AIChatbot({ return ( }> - + {/* [contain:layout] traps streamdown's fixed fullscreen overlay inside the chat area so it never covers the sidebar or navbar. */}
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 5111baf656..ff70583e19 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers"; import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; +import { + APP_SIDEBAR_MODE, + AppSidebarModeSync, +} from "@/components/layout/app-sidebar"; import { ContentLayout } from "@/components/shadcn/content-layout"; -import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync"; -import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar"; import { isCloud } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; @@ -55,7 +57,7 @@ export default async function Home({ return ( - +
diff --git a/ui/changelog.d/sidebar-redesign.changed.md b/ui/changelog.d/sidebar-redesign.changed.md new file mode 100644 index 0000000000..9d45d8ffdf --- /dev/null +++ b/ui/changelog.d/sidebar-redesign.changed.md @@ -0,0 +1 @@ +Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx new file mode 100644 index 0000000000..54dedad07c --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { AppSidebarContent } from "./app-sidebar-content"; +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +const { + openCloudUpgradeMock, + openLaunchScanModalMock, + pathnameValue, + pushMock, +} = vi.hoisted(() => ({ + openCloudUpgradeMock: vi.fn(), + openLaunchScanModalMock: vi.fn(), + pathnameValue: { current: "/findings" }, + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameValue.current, + useRouter: () => ({ push: pushMock }), +})); + +vi.mock("@/hooks", () => ({ + useAuth: () => ({ permissions: {} }), +})); + +vi.mock("@/hooks/use-runtime-config", () => ({ + useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }), +})); + +vi.mock("@/store", () => ({ + useScansStore: ( + selector: (state: { + openLaunchScanModal: typeof openLaunchScanModalMock; + }) => unknown, + ) => selector({ openLaunchScanModal: openLaunchScanModalMock }), + useCloudUpgradeStore: ( + selector: (state: { + openCloudUpgrade: typeof openCloudUpgradeMock; + }) => unknown, + ) => selector({ openCloudUpgrade: openCloudUpgradeMock }), +})); + +vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({ + LighthouseV2SidebarChat: () =>
, +})); + +describe("AppSidebarContent", () => { + beforeEach(() => { + pathnameValue.current = "/findings"; + pushMock.mockClear(); + openCloudUpgradeMock.mockClear(); + openLaunchScanModalMock.mockClear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0"); + const user = userEvent.setup(); + + // When + render(); + + // Then + const homeLink = screen.getByRole("link", { name: "Prowler home" }); + expect(homeLink).toBeVisible(); + expect(screen.getByRole("link", { name: "Launch Scan" })).toHaveAttribute( + "href", + "/scans?launchScan=true", + ); + expect(screen.getByText("5.8.0")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + expect(openCloudUpgradeMock).toHaveBeenCalledWith( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + undefined, + ); + expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0); + }); + + it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + + // When + render(); + + // Then + expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Service status" }), + ).toHaveAttribute("href", "https://status.prowler.com"); + expect( + screen.queryByText("All systems operational"), + ).not.toBeInTheDocument(); + }); + + it("opens the current scan modal instead of navigating from the scans route", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + pathnameValue.current = "/scans"; + const user = userEvent.setup(); + + // When + render(); + await user.click(screen.getByRole("button", { name: "Launch Scan" })); + + // Then + expect(openLaunchScanModalMock).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("link", { name: "Launch Scan" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx new file mode 100644 index 0000000000..c32a7d5048 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation"; +import { ProwlerBrand } from "@/components/icons"; +import { useAuth } from "@/hooks"; +import { useRuntimeConfig } from "@/hooks/use-runtime-config"; +import { isCloud } from "@/lib/shared/env"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle"; +import { LaunchScanAction } from "./launch-scan-action"; +import { getNavigationConfig } from "./navigation-config"; +import { SidebarFooter } from "./sidebar-footer"; +import { SidebarNavigation } from "./sidebar-navigation"; +import { APP_SIDEBAR_MODE, type AppSidebarSelectionHandler } from "./types"; + +interface AppSidebarContentProps { + onSelect?: AppSidebarSelectionHandler; +} + +export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { + const pathname = usePathname(); + const { permissions } = useAuth(); + const { apiDocsUrl } = useRuntimeConfig(); + const mode = useAppSidebarMode((state) => state.mode); + const isCloudEnvironment = isCloud(); + const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions }); + const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; + + return ( +
+
+ + + +
+ +
+ + +
+ +
+ {showChat ? ( + + ) : ( + + )} +
+ + +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts new file mode 100644 index 0000000000..ba85902736 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + migrateAppSidebarState, + useAppSidebarMode, +} from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("app sidebar mode store", () => { + beforeEach(() => { + localStorage.clear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("keeps the persisted chat mode while discarding legacy sidebar state", () => { + // Given + const legacyState = { + isOpen: false, + isHover: true, + navigationMode: APP_SIDEBAR_MODE.CHAT, + settings: { disabled: false, isHoverOpen: false }, + }; + + // When + const migrated = migrateAppSidebarState(legacyState); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.CHAT }); + expect(migrated).not.toHaveProperty("isOpen"); + expect(migrated).not.toHaveProperty("settings"); + }); + + it("falls back to browse for an invalid persisted mode", () => { + // Given / When + const migrated = migrateAppSidebarState({ navigationMode: "invalid" }); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("rehydrates the legacy payload under the existing sidebar key", async () => { + // Given + localStorage.setItem( + "sidebar", + JSON.stringify({ + state: { + navigationMode: APP_SIDEBAR_MODE.CHAT, + isOpen: false, + isHover: true, + settings: { disabled: true }, + }, + version: 0, + }), + ); + + // When + await useAppSidebarMode.persist.rehydrate(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + expect(useAppSidebarMode.getState()).not.toEqual( + expect.objectContaining({ isOpen: expect.anything() }), + ); + }); + + it("updates the current navigation mode", () => { + // Given / When + useAppSidebarMode.getState().setMode(APP_SIDEBAR_MODE.CHAT); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts new file mode 100644 index 0000000000..fc076c0296 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts @@ -0,0 +1,54 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types"; + +interface PersistedAppSidebarState { + mode: AppSidebarMode; +} + +interface AppSidebarModeStore extends PersistedAppSidebarState { + setMode: (mode: AppSidebarMode) => void; +} + +function isAppSidebarMode(value: unknown): value is AppSidebarMode { + return Object.values(APP_SIDEBAR_MODE).some((mode) => mode === value); +} + +export function migrateAppSidebarState( + persistedState: unknown, +): PersistedAppSidebarState { + if (typeof persistedState !== "object" || persistedState === null) { + return { mode: APP_SIDEBAR_MODE.BROWSE }; + } + + if ("mode" in persistedState && isAppSidebarMode(persistedState.mode)) { + return { mode: persistedState.mode }; + } + + if ( + "navigationMode" in persistedState && + isAppSidebarMode(persistedState.navigationMode) + ) { + return { mode: persistedState.navigationMode }; + } + + return { mode: APP_SIDEBAR_MODE.BROWSE }; +} + +export const useAppSidebarMode = create()( + persist( + (set) => ({ + mode: APP_SIDEBAR_MODE.BROWSE, + setMode: (mode) => set({ mode }), + }), + { + name: "sidebar", + storage: createJSONStorage(() => localStorage), + merge: (persistedState, currentState) => ({ + ...currentState, + ...migrateAppSidebarState(persistedState), + }), + }, + ), +); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx new file mode 100644 index 0000000000..59caaae738 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("AppSidebarModeSync", () => { + beforeEach(() => { + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + }); + + it("restores the requested sidebar mode when a route mounts", () => { + // Given / When + render(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx new file mode 100644 index 0000000000..c97d84a14d --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useMountEffect } from "@/hooks/use-mount-effect"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import type { AppSidebarMode } from "./types"; + +interface AppSidebarModeSyncProps { + mode: AppSidebarMode; +} + +export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) { + const setMode = useAppSidebarMode((state) => state.setMode); + + useMountEffect(() => { + setMode(mode); + }); + + return null; +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx new file mode 100644 index 0000000000..0631d09dbf --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Home } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { NavigationButton } from "@/components/shadcn/navigation-button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { + APP_SIDEBAR_MODE, + type AppSidebarMode, + type AppSidebarSelectionHandler, +} from "./types"; + +interface AppSidebarModeToggleProps { + chatEnabled: boolean; + onSelect?: AppSidebarSelectionHandler; +} + +const MODES = [ + { + value: APP_SIDEBAR_MODE.BROWSE, + label: "Home", + icon: Home, + }, + { + value: APP_SIDEBAR_MODE.CHAT, + label: "Chat", + icon: LighthouseIcon, + }, +] as const; + +export function AppSidebarModeToggle({ + chatEnabled, + onSelect, +}: AppSidebarModeToggleProps) { + const router = useRouter(); + const mode = useAppSidebarMode((state) => state.mode); + const setMode = useAppSidebarMode((state) => state.setMode); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + const selectMode = (nextMode: AppSidebarMode) => { + const isChatUpsell = nextMode === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + + if (isChatUpsell) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } + + setMode(nextMode); + onSelect?.(); + + if (nextMode === APP_SIDEBAR_MODE.CHAT) { + router.push("/lighthouse"); + } + }; + + return ( +
+ {MODES.map((item) => { + const Icon = item.icon; + const isActive = item.value === mode; + const isCloudUpsell = + item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + const button = ( + selectMode(item.value)} + > + + ); + + if (!isCloudUpsell) return button; + + return ( + + {button} + + Available in Prowler Cloud + + + ); + })} +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar.tsx b/ui/components/layout/app-sidebar/app-sidebar.tsx new file mode 100644 index 0000000000..6864cd64a9 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar.tsx @@ -0,0 +1,10 @@ +import { AppSidebarContent } from "./app-sidebar-content"; + +export function AppSidebar() { + return ( + + ); +} diff --git a/ui/components/layout/app-sidebar/index.ts b/ui/components/layout/app-sidebar/index.ts new file mode 100644 index 0000000000..5ee953a19d --- /dev/null +++ b/ui/components/layout/app-sidebar/index.ts @@ -0,0 +1,5 @@ +export { AppSidebar } from "./app-sidebar"; +export { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +export { MobileAppSidebar } from "./mobile-app-sidebar"; +export type { AppSidebarMode } from "./types"; +export { APP_SIDEBAR_MODE } from "./types"; diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx new file mode 100644 index 0000000000..1da429811f --- /dev/null +++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ScanLine } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation"; +import { useScansStore } from "@/store"; + +import type { AppSidebarSelectionHandler } from "./types"; + +interface LaunchScanActionProps { + onSelect?: AppSidebarSelectionHandler; +} + +function LaunchScanContent() { + return ( + <> +
); diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index 75887ee9b9..30facaf97f 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -9,6 +9,11 @@ import { SeverityBadge, StatusFindingBadge, } from "@/components/shadcn/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { getRegionFlag } from "@/lib/region-flags"; import { getOptionalText } from "@/lib/utils"; import { FindingProps, ProviderType } from "@/types"; @@ -67,11 +72,17 @@ function FindingTitleCell({ finding={finding} defaultOpen={defaultOpen} trigger={ -
-

+ // Single line always: ellipsis beyond the max, full title in the tooltip. + + +

+ {finding.attributes.check_metadata.checktitle} +

+ + {finding.attributes.check_metadata.checktitle} -

-
+ + } /> ); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index af7e38acc3..1d2716985f 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -74,6 +74,7 @@ import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-pan import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; +import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; import { getRegionFlag } from "@/lib/region-flags"; import { getRecommendationLinkLabel } from "@/lib/vulnerability-references"; import type { ComplianceOverviewData } from "@/types/compliance"; @@ -471,6 +472,16 @@ export function ResourceDetailDrawerContent({ const overviewStatusExtended = currentResource?.statusExtended || f?.statusExtended; const showOverviewStatusExtended = Boolean(overviewStatusExtended); + const findingAnalysisPrompt = buildFindingAnalysisPrompt({ + findingId: currentResource?.findingId ?? f?.id, + providerUid, + resourceUid, + checkId: currentResource?.checkId ?? checkMeta.checkId, + severity: findingSeverity, + status: findingStatus, + detail: overviewStatusExtended, + risk: f?.risk || checkMeta.risk, + }); const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => { await updateFindingTriage(input); @@ -712,7 +723,10 @@ export function ResourceDetailDrawerContent({ <>
{/* Resource info grid — 4 data columns */} -
+
{/* Row 1: Provider, Resource, Service, Region */}
- - - Resource Finding Details - - View finding details for the selected resource - - - - - Close - - {open && ( - - )} - - + + + ); } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx index 938b4bc35a..163a2dbd54 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx @@ -8,7 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; export function ResourceDetailSkeleton() { return (
-
+
{/* Row 1: Provider, Resource, Service, Region */}
diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx index a4053e9f55..0a2edb0526 100644 --- a/ui/components/icons/Icons.tsx +++ b/ui/components/icons/Icons.tsx @@ -1129,7 +1129,13 @@ export const LighthouseIcon: React.FC = ({ height, ...props }) => { - const gradientId = (id: string) => (animatedAura ? `${id}_animated` : id); + // Gradient defs are referenced by id, and this icon renders many times per + // page. Duplicate ids resolve against the FIRST instance in the document — + // if that one sits in a display:none subtree (e.g. the desktop sidebar on + // mobile), every other copy loses its fill and turns invisible. useId keeps + // each instance self-contained; strip its delimiters for url(#...) safety. + const uid = React.useId().replace(/[«»:]/g, ""); + const gradientId = (id: string) => `${id}_${uid}`; return ( { + it("keeps gradient ids unique across instances", () => { + // Given: the icon rendered several times on one page (sidebar, navbar, + // overview banner) — with duplicate ids, browsers resolve url(#...) + // against the first instance, which may sit in a display:none subtree + // (the desktop sidebar on mobile) and leave the others unpainted. + const { container } = render( + <> + + + + , + ); + + // Then: every gradient id is unique document-wide + const ids = Array.from( + container.querySelectorAll("linearGradient, radialGradient"), + ).map((gradient) => gradient.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("paints every path from its own instance's defs", () => { + // Given + const { container } = render(); + const svg = container.querySelector("svg") as SVGElement; + const localIds = new Set( + Array.from(svg.querySelectorAll("linearGradient, radialGradient")).map( + (gradient) => gradient.id, + ), + ); + + // Then: every fill/stroke reference resolves inside this same svg + const references = Array.from(svg.querySelectorAll("path")) + .flatMap((path) => [ + path.getAttribute("fill"), + path.getAttribute("stroke"), + ]) + .filter((paint): paint is string => paint?.startsWith("url(#") ?? false); + expect(references.length).toBeGreaterThan(0); + for (const reference of references) { + const id = reference.slice("url(#".length, -1); + expect(localIds.has(id)).toBe(true); + } + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx index 59caaae738..7eb39455b4 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx @@ -1,6 +1,8 @@ import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; +import { useSidePanelStore } from "@/store/side-panel"; + import { useAppSidebarMode } from "./app-sidebar-mode-store"; import { AppSidebarModeSync } from "./app-sidebar-mode-sync"; import { APP_SIDEBAR_MODE } from "./types"; @@ -8,6 +10,7 @@ import { APP_SIDEBAR_MODE } from "./types"; describe("AppSidebarModeSync", () => { beforeEach(() => { useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + useSidePanelStore.setState({ isOpen: false }); }); it("restores the requested sidebar mode when a route mounts", () => { @@ -17,4 +20,26 @@ describe("AppSidebarModeSync", () => { // Then expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE); }); + + it("keeps the side panel open by default", () => { + // Given + useSidePanelStore.setState({ isOpen: true }); + + // When + render(); + + // Then + expect(useSidePanelStore.getState().isOpen).toBe(true); + }); + + it("closes the side panel when the full-page chat mounts", () => { + // Given + useSidePanelStore.setState({ isOpen: true }); + + // When + render(); + + // Then + expect(useSidePanelStore.getState().isOpen).toBe(false); + }); }); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx index c97d84a14d..b4d7887dfb 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx @@ -1,19 +1,29 @@ "use client"; import { useMountEffect } from "@/hooks/use-mount-effect"; +import { useSidePanelStore } from "@/store/side-panel"; import { useAppSidebarMode } from "./app-sidebar-mode-store"; import type { AppSidebarMode } from "./types"; interface AppSidebarModeSyncProps { mode: AppSidebarMode; + // The full-page chat dismisses the side panel: the chat lives in one place + // or the other, never both. + closeSidePanel?: boolean; } -export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) { +export function AppSidebarModeSync({ + mode, + closeSidePanel = false, +}: AppSidebarModeSyncProps) { const setMode = useAppSidebarMode((state) => state.setMode); useMountEffect(() => { setMode(mode); + if (closeSidePanel) { + useSidePanelStore.getState().closePanel(); + } }); return null; diff --git a/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx b/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx index 75aae87f5a..343a6aef72 100644 --- a/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx +++ b/ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx @@ -45,6 +45,17 @@ describe("MobileAppSidebar", () => { expect(openButton).toHaveFocus(); }); + it("hides the trigger based on the viewport, not the narrowed content", () => { + // Given / When + render(); + + // Then: lg: is a container query inside
; the side panel squeezing + // the page must not surface the mobile menu on desktop. + expect(screen.getByRole("button", { name: "Open menu" })).toHaveClass( + "min-[64rem]:hidden", + ); + }); + it("closes after selecting an item from the shared sidebar content", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx b/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx index abb6fbbfe7..0a3f9afdeb 100644 --- a/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx +++ b/ui/components/layout/app-sidebar/mobile-app-sidebar.tsx @@ -35,7 +35,9 @@ export function MobileAppSidebar() { variant="bare" size="icon-sm" aria-label="Open menu" - className={cn("lg:hidden", open && "invisible")} + // min-[64rem] (not lg:): inside
, lg is a container query and + // the side panel squeezing the page would surface the mobile menu. + className={cn("min-[64rem]:hidden", open && "invisible")} > diff --git a/ui/components/layout/main-layout/main-layout.test.tsx b/ui/components/layout/main-layout/main-layout.test.tsx index 9fc9ae816e..26ebcc55e6 100644 --- a/ui/components/layout/main-layout/main-layout.test.tsx +++ b/ui/components/layout/main-layout/main-layout.test.tsx @@ -24,4 +24,35 @@ describe("MainLayout", () => { expect(screen.getByText("Page content")).toBeVisible(); expect(screen.getByRole("main")).toBeVisible(); }); + + it("keeps the desktop sidebar offset based on the viewport", () => { + // Given / When + render( + +
Page content
+ , + ); + + // Then: a right panel may narrow main, but the desktop sidebar stays open. + // Margin reaches the sidebar edge; the 16px gutter is padding so the + // navbar's bled border-b paints instead of being clipped by the scroller. + expect(screen.getByText("Page content").parentElement).toHaveClass( + "min-[64rem]:ml-[264px]", + "pl-4", + ); + }); + + it("marks main as the responsive container for pushed page content", () => { + // Given / When + render( + +
Page content
+
, + ); + + // Then + expect(screen.getByText("Page content").parentElement).toHaveAttribute( + "data-responsive-container", + ); + }); }); diff --git a/ui/components/layout/main-layout/main-layout.tsx b/ui/components/layout/main-layout/main-layout.tsx index 5f12e22aba..09141c8daa 100644 --- a/ui/components/layout/main-layout/main-layout.tsx +++ b/ui/components/layout/main-layout/main-layout.tsx @@ -1,14 +1,60 @@ +"use client"; + +import { usePathname } from "next/navigation"; import { type ReactNode, Suspense } from "react"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useStore } from "@/hooks/use-store"; +import { isLighthouseChatRoute } from "@/lib/lighthouse-routes"; +import { + clampSidePanelWidth, + SIDE_PANEL_PUSH_MEDIA_QUERY, +} from "@/lib/ui-layout"; +import { cn } from "@/lib/utils"; +import { useSidePanelStore } from "@/store/side-panel"; export default function MainLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + // Push (not overlay): the open side panel shrinks the page by exactly its + // (user-resizable) width so everything stays reachable. Below `sm` the + // panel overlays full-width instead, where pushing would leave no page. The + // full-page chat route has no panel at all. + const sidePanelOpen = useStore(useSidePanelStore, (x) => x.isOpen); + const sidePanelWidth = useStore(useSidePanelStore, (x) => x.width); + const sidePanelResizing = useStore(useSidePanelStore, (x) => x.isResizing); + const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY); + // Re-clamp at consumption: a persisted width from a larger monitor + // rehydrates raw and would otherwise collapse
on this viewport. + const pushWidth = + sidePanelOpen && + sidePanelWidth !== undefined && + isPushViewport && + !isLighthouseChatRoute(pathname) + ? clampSidePanelWidth(sidePanelWidth) + : undefined; return (
-
+
is the reference for the app's (container-query) + // breakpoints, so pushing it with the side panel re-evaluates them. + // min-[64rem] (not lg:) keeps the sidebar margin on viewport terms. + // The 16px gutter is padding (not margin): main scrolls, and a scroll + // container clips at its padding box — the navbar's border-b bleeds + // across this gutter to meet the sidebar's border-r. + data-responsive-container + className={cn( + "no-scrollbar @container relative z-10 mb-auto h-full flex-1 flex-col overflow-y-auto pl-4 min-[64rem]:ml-[264px]", + // Margin animates on open/close, but tracks the pointer 1:1 during + // a drag resize. + !sidePanelResizing && + "transition-[margin-left,margin-right] duration-300 ease-in-out", + )} + style={{ marginRight: pushWidth }} + > {children}
diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx index 60147a24f7..c93ed3f6e5 100644 --- a/ui/components/layout/nav-bar/navbar-client.test.tsx +++ b/ui/components/layout/nav-bar/navbar-client.test.tsx @@ -6,8 +6,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getFlowById } from "@/lib/onboarding"; import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter"; import { usePageReadyStore } from "@/store/page-ready"; +import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; -import { NavbarClient } from "./navbar-client"; +import { FeedsLoadingFallback, NavbarClient } from "./navbar-client"; const navigationMocks = vi.hoisted(() => ({ pathname: "/findings", @@ -69,6 +70,10 @@ describe("NavbarClient", () => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); // Default: the current route's content has loaded, so the icon is enabled. usePageReadyStore.setState({ readyPath: "/findings" }); + useSidePanelStore.setState({ + isOpen: false, + selectedTab: SIDE_PANEL_TAB.AI_CHAT, + }); }); it("renders an accessible contextual onboarding button in the breadcrumb", async () => { @@ -231,6 +236,61 @@ describe("NavbarClient", () => { ).not.toBeInTheDocument(); }); + it("shows the Lighthouse AI side-panel trigger in cloud", () => { + // Given / When + render(); + + // Then + expect(screen.getByTestId("side-panel-ai-trigger")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Ask Lighthouse AI" }), + ).toBeInTheDocument(); + }); + + it("hides the Lighthouse AI trigger while the AI chat panel is already open", () => { + // Given + useSidePanelStore.setState({ + isOpen: true, + selectedTab: SIDE_PANEL_TAB.AI_CHAT, + }); + + // When + render(); + + // Then + expect( + screen.queryByTestId("side-panel-ai-trigger"), + ).not.toBeInTheDocument(); + }); + + it("keeps the Lighthouse AI trigger while the panel shows a detail tab", () => { + // Given: the panel is open but on the context (detail) tab, so the trigger + // is still the way to switch to the AI chat. + useSidePanelStore.setState({ + isOpen: true, + selectedTab: SIDE_PANEL_TAB.CONTEXT, + }); + + // When + render(); + + // Then + expect(screen.getByTestId("side-panel-ai-trigger")).toBeInTheDocument(); + }); + + it("hides the Lighthouse AI side-panel trigger in self-hosted (OSS) deployments", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + // When + render(); + + // Then + expect( + screen.queryByTestId("side-panel-ai-trigger"), + ).not.toBeInTheDocument(); + }); + it("does not render a contextual onboarding button for unknown flows", () => { // Given / When render( @@ -245,4 +305,29 @@ describe("NavbarClient", () => { screen.queryByRole("button", { name: /start product tour/i }), ).not.toBeInTheDocument(); }); + + it("draws a bottom separator that reaches the sidebar's edge", () => { + // Given / When + render(); + + // Then: same token as the sidebar's border-r, bled 16px left to meet it + const header = screen.getByRole("banner"); + expect(header).toHaveClass( + "border-b", + "border-border-neutral-secondary", + "-ml-4", + "pl-4", + ); + expect(header).not.toHaveClass("w-full"); + }); + + it("keeps the feeds fallback on the shared ghost icon treatment", () => { + // Given / When + render(); + + // Then: same 32px square as the rest of the navbar action cluster + const button = screen.getByRole("button", { name: "Loading updates" }); + expect(button).toHaveClass("size-8"); + expect(button).not.toHaveClass("rounded-full"); + }); }); diff --git a/ui/components/layout/nav-bar/navbar-client.tsx b/ui/components/layout/nav-bar/navbar-client.tsx index 5ee6a6a0b6..088e31ac33 100644 --- a/ui/components/layout/nav-bar/navbar-client.tsx +++ b/ui/components/layout/nav-bar/navbar-client.tsx @@ -12,6 +12,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn"; +import { SidePanelTrigger } from "@/components/side-panel"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { getFlowById } from "@/lib/onboarding"; import { isCloud } from "@/lib/shared/env"; @@ -79,7 +80,10 @@ export function NavbarClient({ }; return ( -
+ // -ml-4/pl-4: bleed the bar across
's 16px left gutter so its + // border-b meets the sidebar's border-r. The gutter is main's padding — + // main scrolls and would clip anything bled past its padding box. +
@@ -121,6 +125,7 @@ export function NavbarClient({
+ {feedsSlot} @@ -133,11 +138,12 @@ export function NavbarClient({ export function FeedsLoadingFallback() { return ( ); } diff --git a/ui/components/resources/resource-details-sheet.tsx b/ui/components/resources/resource-details-sheet.tsx index 1ae3ec0e74..c0603d9124 100644 --- a/ui/components/resources/resource-details-sheet.tsx +++ b/ui/components/resources/resource-details-sheet.tsx @@ -1,15 +1,6 @@ "use client"; -import { X } from "lucide-react"; - -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from "@/components/shadcn"; +import { DetailSidePanel } from "@/components/side-panel/detail-side-panel"; import { ResourceProps } from "@/types"; import { ResourceDetailContent } from "./table/resource-detail-content"; @@ -26,20 +17,13 @@ export const ResourceDetailsSheet = ({ onOpenChange, }: ResourceDetailsSheetProps) => { return ( - - - - Resource Details - View the resource details - - - - Close - - {open && ( - - )} - - + + + ); }; diff --git a/ui/components/shadcn/combobox/combobox.test.tsx b/ui/components/shadcn/combobox/combobox.test.tsx new file mode 100644 index 0000000000..797b770c19 --- /dev/null +++ b/ui/components/shadcn/combobox/combobox.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Combobox } from "./combobox"; + +describe("Combobox", () => { + it("uses the compact trigger size through its semantic API", () => { + // Given / When + render( + , + ); + + // Then + expect(screen.getByRole("combobox", { name: "Model" })).toHaveClass("h-8"); + }); +}); diff --git a/ui/components/shadcn/combobox/combobox.tsx b/ui/components/shadcn/combobox/combobox.tsx index d8f832babc..4420a469e6 100644 --- a/ui/components/shadcn/combobox/combobox.tsx +++ b/ui/components/shadcn/combobox/combobox.tsx @@ -28,9 +28,14 @@ const comboboxTriggerVariants = cva("", { ghost: "border-none bg-transparent shadow-none hover:bg-accent hover:text-text-neutral-primary", }, + size: { + default: "", + sm: "", + }, }, defaultVariants: { variant: "default", + size: "default", }, }); @@ -89,6 +94,7 @@ export function Combobox({ triggerClassName, contentClassName, variant = "default", + size = "default", disabled = false, showSelectedFirst = true, loading = false, @@ -113,12 +119,13 @@ export function Combobox({ + + + , + ); +} + +describe("DiscoveryCallout", () => { + it("renders the title and description while open", () => { + // Given / When + renderCallout(true, vi.fn()); + + // Then + expect(screen.getByTestId("callout")).toBeInTheDocument(); + expect(screen.getByText("Meet the feature")).toBeInTheDocument(); + expect(screen.getByText("It lives right here.")).toBeInTheDocument(); + }); + + it("renders nothing while closed", () => { + // Given / When + renderCallout(false, vi.fn()); + + // Then + expect(screen.queryByTestId("callout")).not.toBeInTheDocument(); + }); + + it("dismisses through the action button", () => { + // Given + const onDismiss = vi.fn(); + renderCallout(true, onDismiss); + + // When + fireEvent.click(screen.getByRole("button", { name: "Got it" })); + + // Then + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("keeps focus free when it opens", () => { + // Given / When: the callout opens on its own (not user-invoked) + renderCallout(true, vi.fn()); + + // Then: focus stays wherever the user had it + expect(screen.getByTestId("callout")).not.toContainElement( + document.activeElement as HTMLElement, + ); + }); +}); diff --git a/ui/components/shadcn/discovery-callout/discovery-callout.tsx b/ui/components/shadcn/discovery-callout/discovery-callout.tsx new file mode 100644 index 0000000000..f2c2aad8b4 --- /dev/null +++ b/ui/components/shadcn/discovery-callout/discovery-callout.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { ComponentProps, ReactNode } from "react"; + +import { Button } from "@/components/shadcn/button/button"; +import { + Popover, + PopoverAnchor, + PopoverClose, + PopoverContent, +} from "@/components/shadcn/popover"; + +interface DiscoveryCalloutProps { + // Controlled: the caller decides when the hint shows and persists "seen". + open: boolean; + // Fired on every dismissal path: the action button, outside click, Escape. + onDismiss: () => void; + children: ReactNode; +} + +// One-time feature-discovery callout anchored to the control it introduces. +// Compound usage: wrap the anchor element in DiscoveryCalloutAnchor and +// render one DiscoveryCalloutContent beside it, all inside DiscoveryCallout. +export function DiscoveryCallout({ + open, + onDismiss, + children, +}: DiscoveryCalloutProps) { + return ( + { + if (!nextOpen) onDismiss(); + }} + > + {children} + + ); +} + +export const DiscoveryCalloutAnchor = PopoverAnchor; + +interface DiscoveryCalloutContentProps { + title: string; + description: ReactNode; + dismissLabel?: string; + side?: ComponentProps["side"]; + align?: ComponentProps["align"]; + "data-testid"?: string; +} + +export function DiscoveryCalloutContent({ + title, + description, + dismissLabel = "Got it", + side = "bottom", + align = "end", + "data-testid": testId, +}: DiscoveryCalloutContentProps) { + return ( + event.preventDefault()} + data-testid={testId} + > +
+

{title}

+

{description}

+
+ + + +
+
+
+ ); +} diff --git a/ui/components/shadcn/drawer.tsx b/ui/components/shadcn/drawer.tsx deleted file mode 100644 index a688fb0f88..0000000000 --- a/ui/components/shadcn/drawer.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import type { ComponentProps } from "react"; -import { Drawer as DrawerPrimitive } from "vaul"; - -import { cn } from "@/lib/utils"; - -function Drawer({ ...props }: ComponentProps) { - return ; -} - -function DrawerTrigger({ - ...props -}: ComponentProps) { - return ; -} - -function DrawerPortal({ - ...props -}: ComponentProps) { - return ; -} - -function DrawerClose({ - ...props -}: ComponentProps) { - return ; -} - -function DrawerOverlay({ - className, - ...props -}: ComponentProps) { - return ( - - ); -} - -function DrawerContent({ - className, - children, - ...props -}: ComponentProps) { - return ( - - - -
- {children} - - - ); -} - -function DrawerHeader({ className, ...props }: ComponentProps<"div">) { - return ( -
- ); -} - -function DrawerFooter({ className, ...props }: ComponentProps<"div">) { - return ( -
- ); -} - -function DrawerTitle({ - className, - ...props -}: ComponentProps) { - return ( - - ); -} - -function DrawerDescription({ - className, - ...props -}: ComponentProps) { - return ( - - ); -} - -export { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerOverlay, - DrawerPortal, - DrawerTitle, - DrawerTrigger, -}; diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index a36273fe55..6b720d6f79 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -12,7 +12,6 @@ export * from "./card/resource-stats-card/resource-stats-card-header"; export * from "./checkbox/checkbox"; export * from "./combobox"; export * from "./download-icon-button"; -export * from "./drawer"; export * from "./dropdown/dropdown"; export * from "./feedback-banner"; export * from "./field/field"; diff --git a/ui/components/shadcn/popover.tsx b/ui/components/shadcn/popover.tsx index e64bae46ad..c974718f39 100644 --- a/ui/components/shadcn/popover.tsx +++ b/ui/components/shadcn/popover.tsx @@ -32,7 +32,9 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + // House floating surface (same as tooltip); `bg-popover` was a stock + // shadcn token that does not exist in this design system. + "border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border p-4 shadow-lg outline-hidden", className, )} {...props} @@ -47,4 +49,10 @@ function PopoverAnchor({ return ; } -export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }; +function PopoverClose({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }; diff --git a/ui/components/shadcn/side-panel/side-panel.tsx b/ui/components/shadcn/side-panel/side-panel.tsx new file mode 100644 index 0000000000..db4755592c --- /dev/null +++ b/ui/components/shadcn/side-panel/side-panel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + type ComponentProps, + type KeyboardEvent, + type PointerEvent, +} from "react"; + +import { cn } from "@/lib/utils"; + +// Base right-hand side panel primitive: a fixed shell that slides in from the +// right without a backdrop (non-modal). Presentation only — open state, tabs +// and content wiring live in the caller (see components/side-panel/). + +interface SidePanelProps extends ComponentProps<"aside"> { + open: boolean; +} + +export function SidePanel({ + open, + className, + children, + ...props +}: SidePanelProps) { + return ( + + ); +} + +export function SidePanelHeader({ + className, + ...props +}: ComponentProps<"div">) { + return ( +
+ ); +} + +export function SidePanelBody({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +// Width added/removed per arrow-key press when resizing via keyboard. +const KEYBOARD_RESIZE_STEP = 24; + +interface SidePanelResizeHandleProps { + // Receives the pointer's clientX on every drag move; the caller derives the + // new width from it (for a right-anchored panel: viewport width - clientX) + // and clamps it. + onResize: (clientX: number) => void; + onResizeStart?: () => void; + onResizeEnd?: () => void; + // Current width and bounds for the ARIA window-splitter contract and to + // derive keyboard steps. + value: number; + min: number; + max: number; +} + +export function SidePanelResizeHandle({ + onResize, + onResizeStart, + onResizeEnd, + value, + min, + max, +}: SidePanelResizeHandleProps) { + const handlePointerDown = (event: PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + onResizeStart?.(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + onResize(event.clientX); + }; + + // Idempotent: also reached via onLostPointerCapture (capture already gone), + // e.g. when Escape closes the panel mid-drag and pointerup never arrives. + const endResize = (event: PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + onResizeEnd?.(); + }; + + // WAI-ARIA window splitter: arrows move the handle as a drag would, so on + // this right-docked panel ArrowLeft widens and ArrowRight narrows. + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const step = + event.key === "ArrowLeft" ? KEYBOARD_RESIZE_STEP : -KEYBOARD_RESIZE_STEP; + // Same contract as a drag: report the clientX the handle would land on. + onResize(window.innerWidth - (value + step)); + }; + + return ( +
+ ); +} diff --git a/ui/components/shadcn/table/data-table.test.tsx b/ui/components/shadcn/table/data-table.test.tsx index b63a5a9bfb..3332e4aa91 100644 --- a/ui/components/shadcn/table/data-table.test.tsx +++ b/ui/components/shadcn/table/data-table.test.tsx @@ -89,7 +89,7 @@ describe("DataTable", () => { ); }); - it("should stack the right content below search on narrow screens", () => { + it("should stack the right content below search when the container is narrow", () => { // Given render( { const toolbar = screen.getByTestId("data-table-toolbar"); const rightContent = screen.getByTestId("data-table-toolbar-right"); - // Then + // Then: md is a container query app-wide, so this also reacts when the + // side panel shrinks the page; the right group stays on one row. expect(toolbar).toHaveClass("flex-col"); expect(toolbar).toHaveClass("md:flex-row"); expect(rightContent).toHaveClass("w-full"); expect(rightContent).toHaveClass("md:w-auto"); + expect(rightContent).toHaveClass("flex-wrap", "items-center"); }); }); diff --git a/ui/components/shadcn/table/data-table.tsx b/ui/components/shadcn/table/data-table.tsx index b0dd231ab7..26bc6bd2aa 100644 --- a/ui/components/shadcn/table/data-table.tsx +++ b/ui/components/shadcn/table/data-table.tsx @@ -285,7 +285,7 @@ export function DataTable({
{toolbarRightContent} {metadata && ( diff --git a/ui/components/shadcn/tooltip.test.tsx b/ui/components/shadcn/tooltip.test.tsx new file mode 100644 index 0000000000..c543a41362 --- /dev/null +++ b/ui/components/shadcn/tooltip.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; + +describe("TooltipContent", () => { + it("uses a constrained content width through its semantic API", async () => { + // Given / When + render( + + Finding + Long finding description + , + ); + + // Then + const content = ( + await screen.findAllByText("Long finding description") + ).find((element) => element.dataset.slot === "tooltip-content"); + expect(content).toHaveClass("max-w-96"); + }); +}); diff --git a/ui/components/shadcn/tooltip.tsx b/ui/components/shadcn/tooltip.tsx index c024b79c88..698880391c 100644 --- a/ui/components/shadcn/tooltip.tsx +++ b/ui/components/shadcn/tooltip.tsx @@ -1,13 +1,30 @@ "use client"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { ComponentProps } from "react"; import { cn } from "@/lib/utils"; +const tooltipContentVariants = cva( + "border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-2 py-1.5 text-left text-xs break-all whitespace-normal shadow-lg", + { + variants: { + maxWidth: { + default: "max-w-[min(700px,calc(100vw-2rem))]", + md: "max-w-96", + }, + }, + defaultVariants: { + maxWidth: "default", + }, + }, +); + function TooltipProvider({ delayDuration = 0, ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( ) { +function Tooltip({ ...props }: ComponentProps) { return ( @@ -29,25 +44,27 @@ function Tooltip({ function TooltipTrigger({ ...props -}: React.ComponentProps) { +}: ComponentProps) { return ; } +interface TooltipContentProps + extends ComponentProps, + VariantProps {} + function TooltipContent({ className, + maxWidth, sideOffset = 4, children, ...props -}: React.ComponentProps) { +}: TooltipContentProps) { return ( {children} diff --git a/ui/components/side-panel/detail-side-panel.test.tsx b/ui/components/side-panel/detail-side-panel.test.tsx new file mode 100644 index 0000000000..482e5bb738 --- /dev/null +++ b/ui/components/side-panel/detail-side-panel.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; + +import { DetailSidePanel } from "./detail-side-panel"; +import { GlobalSidePanel } from "./global-side-panel"; + +const { isCloudMock } = vi.hoisted(() => ({ isCloudMock: vi.fn(() => true) })); + +vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock })); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/findings", +})); + +vi.mock( + "@/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat", + () => ({ + LighthousePanelChat: () => ( +
chat content
+ ), + }), +); + +// Mimics a table host: local open state, detail content as children. +function Host({ initialOpen = true }: { initialOpen?: boolean }) { + const [open, setOpen] = useState(initialOpen); + return ( + <> + + + +
detail body
+
+ {String(open)} + + ); +} + +// Mimics two findings-table rows, each mounting its own DetailSidePanel with +// per-row open state — the real layout on the findings page. +function DualHost() { + const [openA, setOpenA] = useState(false); + const [openB, setOpenB] = useState(false); + return ( + <> + + + + +
A body
+
+ +
B body
+
+ {String(openA)} + {String(openB)} + + ); +} + +describe("DetailSidePanel", () => { + beforeEach(() => { + isCloudMock.mockReturnValue(true); + localStorage.clear(); + useSidePanelStore.setState({ + isOpen: false, + selectedTab: SIDE_PANEL_TAB.AI_CHAT, + hasBeenOpened: false, + contextTab: null, + contextOwnerToken: 0, + contextOutlet: null, + }); + }); + + it("portals the detail content into the global panel when open", async () => { + // Given / When + render(); + + // Then: the content renders inside the panel's context outlet + const detail = await screen.findByTestId("detail-content"); + expect( + screen.getByTestId("side-panel-context-outlet").contains(detail), + ).toBe(true); + expect(screen.getByRole("tab", { name: "Details" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(useSidePanelStore.getState().isOpen).toBe(true); + }); + + it("clears the host selection when the panel is dismissed", async () => { + // Given + const user = userEvent.setup(); + render(); + await screen.findByTestId("detail-content"); + + // When + await user.click(screen.getByRole("button", { name: "Close side panel" })); + + // Then: the host's open state flips, the content unmounts, the tab is gone + expect(screen.getByTestId("host-open-state")).toHaveTextContent("false"); + expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument(); + expect(useSidePanelStore.getState().contextTab).toBeNull(); + }); + + it("keeps the detail mounted while chatting on the AI tab", async () => { + // Given + const user = userEvent.setup(); + render(); + await screen.findByTestId("detail-content"); + + // When + await user.click(screen.getByRole("tab", { name: "Lighthouse AI" })); + + // Then: chat is up, detail DOM survives hidden (carousel/scroll intact) + expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument(); + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(screen.getByTestId("detail-content")).not.toBeVisible(); + }); + + it("hands the panel to the newest detail view and closes the previous one", async () => { + // Given: finding A's detail view owns the panel + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Open A" })); + await screen.findByTestId("detail-a"); + + // When: the user opens finding B while A is still mounted + await user.click(screen.getByRole("button", { name: "Open B" })); + + // Then: only B portals into the outlet; A closed itself + const detailB = await screen.findByTestId("detail-b"); + expect( + screen.getByTestId("side-panel-context-outlet").contains(detailB), + ).toBe(true); + expect(screen.queryByTestId("detail-a")).not.toBeInTheDocument(); + expect(screen.getByTestId("open-a")).toHaveTextContent("false"); + expect(screen.getByTestId("open-b")).toHaveTextContent("true"); + + // When: the panel is dismissed + await user.click(screen.getByRole("button", { name: "Close side panel" })); + + // Then: B (the current owner) is the one that clears its selection + expect(screen.getByTestId("open-b")).toHaveTextContent("false"); + expect(screen.queryByTestId("detail-b")).not.toBeInTheDocument(); + expect(useSidePanelStore.getState().contextTab).toBeNull(); + }); + + it("registers nothing while closed and registers on open", async () => { + // Given + const user = userEvent.setup(); + render(); + expect(useSidePanelStore.getState().contextTab).toBeNull(); + + // When + await user.click(screen.getByRole("button", { name: "Open detail" })); + + // Then + expect(await screen.findByTestId("detail-content")).toBeInTheDocument(); + expect(useSidePanelStore.getState().contextTab?.label).toBe("Details"); + }); +}); diff --git a/ui/components/side-panel/detail-side-panel.tsx b/ui/components/side-panel/detail-side-panel.tsx new file mode 100644 index 0000000000..c7d30aaa4c --- /dev/null +++ b/ui/components/side-panel/detail-side-panel.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { type ReactNode, useState } from "react"; +import { createPortal } from "react-dom"; + +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { useSidePanelStore } from "@/store/side-panel"; + +interface DetailSidePanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + // Screen-reader heading; the visible tab label is always "Details". + title: string; + description?: string; + children: ReactNode; +} + +// Hosts a detail view (finding/resource) inside the global side panel as its +// "Details" context tab. The owning table keeps ALL detail state (selection, +// carousel, fetch caches) — only the rendered output moves into the panel via +// a portal, so the AI tab and the detail view share one panel, PostHog-style. +// Drop-in replacement for the old vaul detail drawers: same open/onOpenChange +// contract, no modal overlay. +export function DetailSidePanel({ + open, + ...activeProps +}: DetailSidePanelProps) { + // Mount/unmount IS the registration lifecycle: no dependency effects needed. + if (!open) return null; + return ; +} + +function DetailSidePanelActive({ + onOpenChange, + title, + description, + children, +}: Omit) { + // Owner token from registration: several detail views can be mounted at + // once (one per table row); only the current owner may portal or unregister. + const [token, setToken] = useState(null); + + useMountEffect(() => { + const registered = useSidePanelStore.getState().registerContextTab({ + label: "Details", + // Mount-scoped capture is safe: the component remounts per open cycle + // and every consumer's close path ends in stable setters. + onRequestClose: () => onOpenChange(false), + }); + setToken(registered); + return () => useSidePanelStore.getState().unregisterContextTab(registered); + }); + + const ownerToken = useSidePanelStore((state) => state.contextOwnerToken); + const outlet = useSidePanelStore((state) => state.contextOutlet); + if (!outlet || token === null || token !== ownerToken) return null; + + return createPortal( +
+

{title}

+ {description ?

{description}

: null} + {children} +
, + outlet, + ); +} diff --git a/ui/components/side-panel/global-side-panel.test.tsx b/ui/components/side-panel/global-side-panel.test.tsx new file mode 100644 index 0000000000..0626997e0a --- /dev/null +++ b/ui/components/side-panel/global-side-panel.test.tsx @@ -0,0 +1,427 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SIDE_PANEL_DEFAULT_WIDTH } from "@/lib/ui-layout"; +import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; + +import { GlobalSidePanel } from "./global-side-panel"; + +const { isCloudMock } = vi.hoisted(() => ({ isCloudMock: vi.fn(() => true) })); + +const navigationMocks = vi.hoisted(() => ({ pathname: "/findings" })); + +// jsdom has no matchMedia: emulate the push (>= sm) viewport per test. +const mediaMocks = vi.hoisted(() => ({ isPushViewport: false })); + +// Toggles a render failure to exercise the panel's local error boundary. +const chatMocks = vi.hoisted(() => ({ shouldThrow: false })); + +// Adds a second registry tab on demand: the real registry only holds one +// today, but tab-switch isolation must already hold for when cloud adds more. +const registryMocks = vi.hoisted(() => ({ extraTab: false })); + +vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock })); + +vi.mock("./side-panel-tabs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getVisibleSidePanelTabs: () => { + const tabs = original.getVisibleSidePanelTabs(); + if (!registryMocks.extraTab) return tabs; + return [ + ...tabs, + { + id: "second-tab", + label: "Second tab", + Icon: () => null, + loadContent: () => + Promise.resolve({ + default: () => ( +
second content
+ ), + }), + Fallback: () => null, + isAvailable: () => true, + }, + ]; + }, + }; +}); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigationMocks.pathname, +})); + +vi.mock("@/hooks/use-media-query", () => ({ + useMediaQuery: () => mediaMocks.isPushViewport, +})); + +// The AI tab's real content pulls in the whole chat (server actions, +// streamdown); the shell test only cares that the registry's content mounts. +vi.mock( + "@/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat", + () => ({ + LighthousePanelChat: () => { + if (chatMocks.shouldThrow) throw new Error("chunk load failed"); + return
chat content
; + }, + }), +); + +describe("GlobalSidePanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + isCloudMock.mockReturnValue(true); + navigationMocks.pathname = "/findings"; + mediaMocks.isPushViewport = false; + chatMocks.shouldThrow = false; + registryMocks.extraTab = false; + localStorage.clear(); + useSidePanelStore.setState({ + isOpen: false, + selectedTab: SIDE_PANEL_TAB.AI_CHAT, + hasBeenOpened: false, + width: SIDE_PANEL_DEFAULT_WIDTH, + isResizing: false, + contextTab: null, + contextOwnerToken: 0, + contextOutlet: null, + }); + }); + + it("renders closed and empty by default (lazy-mount)", () => { + // Given / When + render(); + + // Then: panel exists but is off-screen, inert, and mounts no content + const panel = screen.getByTestId("global-side-panel"); + expect(panel).toHaveClass("translate-x-full"); + expect(screen.queryByTestId("panel-chat-content")).not.toBeInTheDocument(); + }); + + it("slides in and mounts the AI tab content when opened via the store", async () => { + // Given + render(); + + // When + act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT)); + + // Then + expect(screen.getByTestId("global-side-panel")).toHaveClass( + "translate-x-0", + ); + expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument(); + }); + + it("keeps the content mounted (hidden) after closing", async () => { + // Given + render(); + act(() => useSidePanelStore.getState().openPanel()); + await screen.findByTestId("panel-chat-content"); + + // When + act(() => useSidePanelStore.getState().closePanel()); + + // Then: off-screen but the chat DOM survives (scroll/draft preservation) + expect(screen.getByTestId("global-side-panel")).toHaveClass( + "translate-x-full", + ); + expect(screen.getByTestId("panel-chat-content")).toBeInTheDocument(); + }); + + it("closes on Escape", async () => { + // Given + const user = userEvent.setup(); + render(); + act(() => useSidePanelStore.getState().openPanel()); + + // When + await user.keyboard("{Escape}"); + + // Then + await waitFor(() => + expect(useSidePanelStore.getState().isOpen).toBe(false), + ); + }); + + it("toggles with Cmd/Ctrl+.", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When / Then + await user.keyboard("{Meta>}.{/Meta}"); + expect(useSidePanelStore.getState().isOpen).toBe(true); + + await user.keyboard("{Meta>}.{/Meta}"); + expect(useSidePanelStore.getState().isOpen).toBe(false); + }); + + it("closes via the close button", async () => { + // Given + const user = userEvent.setup(); + render(); + act(() => useSidePanelStore.getState().openPanel()); + + // When + await user.click(screen.getByRole("button", { name: "Close side panel" })); + + // Then + expect(useSidePanelStore.getState().isOpen).toBe(false); + }); + + it("groups the chat actions beside close in the existing header", async () => { + // Given + render(); + + // When + act(() => useSidePanelStore.getState().openPanel()); + + // Then: no extra toolbar; both actions are adjacent in the panel header + const newChat = await screen.findByRole("button", { name: "New chat" }); + const fullPage = screen.getByRole("link", { + name: "Open Lighthouse AI full page", + }); + const close = screen.getByRole("button", { name: "Close side panel" }); + const actionGroup = close.parentElement; + expect(actionGroup).toContainElement(newChat); + expect(actionGroup).toContainElement(fullPage); + expect(actionGroup).toHaveClass("ml-auto"); + expect(newChat).not.toHaveClass("ml-auto"); + expect(close).not.toHaveClass("ml-auto"); + }); + + it("renders nothing in OSS while no detail view is registered", () => { + // Given + isCloudMock.mockReturnValue(false); + + // When + const { container } = render(); + + // Then + expect(container).toBeEmptyDOMElement(); + }); + + it("hosts a registered detail view in OSS, without the AI tab", () => { + // Given + isCloudMock.mockReturnValue(false); + render(); + + // When: a detail view registers its context tab + act(() => + useSidePanelStore.getState().registerContextTab({ + label: "Details", + onRequestClose: vi.fn(), + }), + ); + + // Then: the panel appears with only the Details surface (no tab strip) + expect(screen.getByTestId("global-side-panel")).toHaveClass( + "translate-x-0", + ); + expect(screen.getByText("Details")).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.getByTestId("side-panel-context-outlet")).toBeInTheDocument(); + }); + + it("shows Details and Lighthouse AI as switchable tabs in cloud", async () => { + // Given + const user = userEvent.setup(); + render(); + act(() => + useSidePanelStore.getState().registerContextTab({ + label: "Details", + onRequestClose: vi.fn(), + }), + ); + + // Then: the context tab opens selected, beside the AI tab + const detailsTab = screen.getByRole("tab", { name: "Details" }); + expect(detailsTab).toHaveAttribute("aria-selected", "true"); + expect(detailsTab).toHaveClass("aria-selected:after:scale-x-100"); + expect(detailsTab).not.toHaveClass("rounded-[8px]"); + expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible(); + + const lighthouseTab = screen.getByRole("tab", { name: "Lighthouse AI" }); + expect(lighthouseTab.querySelector("svg")?.parentElement).toHaveClass( + "flex", + ); + + // When: switching to the AI tab + await user.click(lighthouseTab); + + // Then: the chat mounts and the detail outlet stays mounted, hidden + expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument(); + expect(screen.getByTestId("side-panel-context-outlet")).not.toBeVisible(); + + // When: switching back + await user.click(screen.getByRole("tab", { name: "Details" })); + + // Then + expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible(); + }); + + it("contains a lazy tab failure inside the panel and recovers via Retry", async () => { + // Given: the AI tab's content throws on render (e.g. chunk-load failure) + const user = userEvent.setup(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + chatMocks.shouldThrow = true; + render(); + + // When + act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT)); + + // Then: the failure stays inside the panel body; the shell survives + expect( + await screen.findByText("This panel failed to load."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Close side panel" }), + ).toBeInTheDocument(); + + // When: the failure clears and the user retries + chatMocks.shouldThrow = false; + await user.click(screen.getByRole("button", { name: "Retry" })); + + // Then: the content mounts normally + expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("renders the next tab fresh after another tab failed", async () => { + // Given: two registry tabs, with the first one failing to render + registryMocks.extraTab = true; + chatMocks.shouldThrow = true; + const user = userEvent.setup(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + render(); + act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT)); + await screen.findByText("This panel failed to load."); + + // When: the user switches to the healthy second tab + await user.click(screen.getByRole("tab", { name: "Second tab" })); + + // Then: its content mounts instead of the previous tab's error state + expect(await screen.findByTestId("second-tab-content")).toBeInTheDocument(); + expect( + screen.queryByText("This panel failed to load."), + ).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("keeps the context outlet attached across store-driven re-renders", () => { + // Given: a registered detail view with its portal outlet attached + render(); + act(() => + useSidePanelStore.getState().registerContextTab({ + label: "Details", + onRequestClose: vi.fn(), + }), + ); + const outlet = useSidePanelStore.getState().contextOutlet; + expect(outlet).not.toBeNull(); + + // When: an unrelated update re-renders the panel (a resize tick) + const seen: (HTMLElement | null)[] = []; + const unsubscribe = useSidePanelStore.subscribe((state) => + seen.push(state.contextOutlet), + ); + act(() => useSidePanelStore.getState().setWidth(500)); + unsubscribe(); + + // Then: the outlet never detached (a null flicker remounts the portal) + expect(seen).not.toContain(null); + expect(useSidePanelStore.getState().contextOutlet).toBe(outlet); + }); + + it("re-clamps a persisted oversized width to the current viewport", () => { + // Given: a huge width rehydrated raw from a larger monitor + mediaMocks.isPushViewport = true; + useSidePanelStore.setState({ width: 5000 }); + render(); + + // When + act(() => useSidePanelStore.getState().openPanel()); + + // Then: applied width is capped at 85% of this viewport + expect(screen.getByTestId("global-side-panel")).toHaveStyle({ + width: `${Math.floor(window.innerWidth * 0.85)}px`, + }); + }); + + it("resizes with arrow keys on the handle (left widens, right narrows)", async () => { + // Given + mediaMocks.isPushViewport = true; + const user = userEvent.setup(); + render(); + act(() => useSidePanelStore.getState().openPanel()); + const handle = screen.getByRole("separator", { name: "Resize panel" }); + expect(handle).toHaveAttribute( + "aria-valuenow", + String(SIDE_PANEL_DEFAULT_WIDTH), + ); + + // When / Then + act(() => handle.focus()); + await user.keyboard("{ArrowLeft}"); + expect(useSidePanelStore.getState().width).toBe( + SIDE_PANEL_DEFAULT_WIDTH + 24, + ); + + await user.keyboard("{ArrowRight}"); + expect(useSidePanelStore.getState().width).toBe(SIDE_PANEL_DEFAULT_WIDTH); + }); + + it("exposes half the viewport as the resize maximum on ultra-wide screens", () => { + // Given + vi.stubGlobal("innerWidth", 2560); + mediaMocks.isPushViewport = true; + useSidePanelStore.setState({ width: 5000 }); + + // When + render(); + + // Then + expect(screen.getByTestId("global-side-panel")).toHaveStyle({ + width: "1280px", + }); + expect( + screen.getByRole("separator", { name: "Resize panel" }), + ).toHaveAttribute("aria-valuemax", "1280"); + }); + + it("does not exist on the full-page chat route (one place or the other)", () => { + // Given: the user is on the agentic chat page with the panel open + navigationMocks.pathname = "/lighthouse"; + useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true }); + + // When + const { container } = render(); + + // Then: no panel DOM at all — the chat lives in the page there + expect(container).toBeEmptyDOMElement(); + }); + + it("remains available on Lighthouse settings", () => { + // Given: settings must coexist with the panel, unlike the chat page + navigationMocks.pathname = "/lighthouse/settings"; + useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true }); + + // When + render(); + + // Then + expect(screen.getByTestId("global-side-panel")).toHaveClass( + "translate-x-0", + ); + }); +}); diff --git a/ui/components/side-panel/global-side-panel.tsx b/ui/components/side-panel/global-side-panel.tsx new file mode 100644 index 0000000000..539cca7880 --- /dev/null +++ b/ui/components/side-panel/global-side-panel.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { X } from "lucide-react"; +import { usePathname } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { + SidePanel, + SidePanelBody, + SidePanelHeader, + SidePanelResizeHandle, +} from "@/components/shadcn/side-panel/side-panel"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn/tabs/tabs"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { isLighthouseChatRoute } from "@/lib/lighthouse-routes"; +import { + clampSidePanelWidth, + getSidePanelMaxWidth, + SIDE_PANEL_MIN_WIDTH, + SIDE_PANEL_PUSH_MEDIA_QUERY, +} from "@/lib/ui-layout"; +import { cn } from "@/lib/utils"; +import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; + +import { RetryableLazyContent } from "./retryable-lazy-content"; +import { getVisibleSidePanelTabs } from "./side-panel-tabs"; + +// Non-modal push panel (PostHog-style): no backdrop, MainLayout shifts the +// page by the panel width so everything stays reachable. It hosts the fixed +// registry tabs (Lighthouse AI, cloud-only) plus one dynamic "context" tab +// that detail views (finding/resource) register and portal their content into +// — one single panel for every right-hand surface. On the full-page chat +// route the panel does not exist at all: the chat lives in one place or the +// other, never both. +export function GlobalSidePanel() { + const pathname = usePathname(); + const isOpen = useSidePanelStore((state) => state.isOpen); + const selectedTab = useSidePanelStore((state) => state.selectedTab); + const hasBeenOpened = useSidePanelStore((state) => state.hasBeenOpened); + const contextTab = useSidePanelStore((state) => state.contextTab); + const width = useSidePanelStore((state) => state.width); + const isResizing = useSidePanelStore((state) => state.isResizing); + const openPanel = useSidePanelStore((state) => state.openPanel); + const closePanel = useSidePanelStore((state) => state.closePanel); + // Stable action for the outlet ref: an inline closure would detach/reattach + // (null → element) on every render, e.g. on each resize tick. + const setContextOutlet = useSidePanelStore((state) => state.setContextOutlet); + // Below `sm` the panel overlays full-width; the resizable px width and the + // push margin only make sense from `sm` up. + const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY); + + useMountEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Radix overlays (modals, popovers) preventDefault their own Escape. + if (event.defaultPrevented) return; + // The full-page chat owns its route: no panel there (read the live URL, + // the mount closure would keep a stale pathname). + if (isLighthouseChatRoute(window.location.pathname)) return; + const store = useSidePanelStore.getState(); + if ((event.metaKey || event.ctrlKey) && event.key === ".") { + // Nothing to show in OSS without a detail view registered. + if (getVisibleSidePanelTabs().length === 0 && !store.contextTab) { + return; + } + event.preventDefault(); + store.togglePanel(); + return; + } + if (event.key === "Escape" && store.isOpen) { + store.closePanel(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }); + + const registryTabs = getVisibleSidePanelTabs(); + if (isLighthouseChatRoute(pathname)) return null; + if (registryTabs.length === 0 && !contextTab) return null; + + // The persisted tab can point at a tab that is not available here; fall + // back to the context tab first (it was registered on purpose), then to the + // first registry tab. + const activeRegistryTab = + registryTabs.find((tab) => tab.id === selectedTab) ?? registryTabs[0]; + const isContextSelected = + Boolean(contextTab) && + (selectedTab === SIDE_PANEL_TAB.CONTEXT || !activeRegistryTab); + const tabCount = registryTabs.length + (contextTab ? 1 : 0); + const activeTabId = isContextSelected + ? SIDE_PANEL_TAB.CONTEXT + : activeRegistryTab?.id; + + const handleTabChange = (tabId: string) => { + if (tabId === SIDE_PANEL_TAB.CONTEXT) { + openPanel(SIDE_PANEL_TAB.CONTEXT); + return; + } + const registryTab = registryTabs.find((tab) => tab.id === tabId); + if (registryTab) openPanel(registryTab.id); + }; + + const handleResize = (clientX: number) => { + // Right-anchored panel: dragging the left edge sets width to the distance + // between the pointer and the right viewport edge. + useSidePanelStore.getState().setWidth(window.innerWidth - clientX); + }; + + return ( + on this viewport. + style={{ width: isPushViewport ? clampSidePanelWidth(width) : undefined }} + > + {isPushViewport ? ( + useSidePanelStore.getState().setIsResizing(true)} + onResizeEnd={() => useSidePanelStore.getState().setIsResizing(false)} + /> + ) : null} + + + {tabCount > 1 ? ( + + {contextTab ? ( + + {contextTab.label} + + ) : null} + {registryTabs.map((tab) => { + const TabIcon = tab.Icon; + return ( + + + + {tab.label} + + + ); + })} + + ) : ( + + )} +
+ {!isContextSelected && activeRegistryTab?.HeaderActions ? ( + + ) : null} + +
+
+ {/* Portal target for the registered detail view. Always rendered while + the panel exists so the owner can portal in as soon as it registers; + Radix keeps the inactive panel mounted but hidden. */} + +