mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): add Lighthouse AI Skills on findings (#12355)
This commit is contained in:
@@ -7,9 +7,12 @@ import type {
|
||||
} from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { isGroupedJiraDispatchEnabledMock } = vi.hoisted(() => ({
|
||||
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
|
||||
}));
|
||||
const { isCloudMock, isGroupedJiraDispatchEnabledMock, launchSkillMock } =
|
||||
vi.hoisted(() => ({
|
||||
isCloudMock: vi.fn(() => false),
|
||||
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
|
||||
launchSkillMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
|
||||
vi.mock("@/components/shadcn/custom/custom-link", () => ({
|
||||
@@ -67,6 +70,19 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/info-field/info-field", () => ({
|
||||
@@ -169,6 +185,19 @@ vi.mock("@/lib/deployment", () => ({
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
vi.mock("./lighthouse-skills-launch", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("./lighthouse-skills-launch")>();
|
||||
return {
|
||||
...actual,
|
||||
useLighthouseSkillLaunch: () => launchSkillMock,
|
||||
};
|
||||
});
|
||||
|
||||
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
@@ -247,10 +276,14 @@ function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
|
||||
|
||||
function renderResourceActionsCell({
|
||||
resource = makeResource(),
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
resource?: FindingResourceRow;
|
||||
onSkillLaunchOpenDrawer?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onSkillLaunchOpenDrawer"];
|
||||
onTriageUpdateAction?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onTriageUpdateAction"];
|
||||
@@ -261,6 +294,7 @@ function renderResourceActionsCell({
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
});
|
||||
@@ -272,19 +306,48 @@ function renderResourceActionsCell({
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
const CellComponent = actionsColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
row: { original: FindingResourceRow; index: number };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: resource } })}</div>);
|
||||
render(<div>{CellComponent({ row: { original: resource, index: 0 } })}</div>);
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isCloudMock.mockReturnValue(false);
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("opens the finding drawer and launches a row skill with full context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSkillLaunchOpenDrawer = vi.fn();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
renderResourceActionsCell({ onSkillLaunchOpenDrawer });
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Triage Decision" }));
|
||||
|
||||
// Then
|
||||
expect(onSkillLaunchOpenDrawer).toHaveBeenCalledWith(0);
|
||||
expect(launchSkillMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
providerUid: "123456789",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
region: "us-east-1",
|
||||
}),
|
||||
);
|
||||
expect(onSkillLaunchOpenDrawer.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
launchSkillMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("should render actions as the last visible column after Triage without Notes", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
|
||||
@@ -20,6 +20,8 @@ import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-colu
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
@@ -34,19 +36,40 @@ import {
|
||||
} from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
LighthouseSkillsRowButton,
|
||||
LighthouseSkillsSubmenu,
|
||||
useLighthousePromptLaunch,
|
||||
useLighthouseSkillLaunch,
|
||||
} from "./lighthouse-skills-launch";
|
||||
import {
|
||||
type DeltaType,
|
||||
NotificationIndicator,
|
||||
} from "./notification-indicator";
|
||||
|
||||
// One finding-context item per resource row, shared by the leading Skills
|
||||
// pill and the ⋮ submenu so both launch with identical context.
|
||||
const buildResourceFindingItem = (resource: FindingResourceRow) =>
|
||||
buildFindingResourceContext({
|
||||
findingId: resource.findingId,
|
||||
checkId: resource.checkId,
|
||||
severity: resource.severity,
|
||||
status: resource.status,
|
||||
providerUid: resource.providerUid,
|
||||
resourceUid: resource.resourceUid,
|
||||
region: resource.region,
|
||||
});
|
||||
|
||||
const ResourceRowActions = ({
|
||||
row,
|
||||
findingTitle,
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
row: Row<FindingResourceRow>;
|
||||
findingTitle?: string;
|
||||
onSkillLaunchOpenDrawer?: (rowIndex: number) => void;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
@@ -54,6 +77,8 @@ const ResourceRowActions = ({
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
@@ -164,6 +189,18 @@ const ResourceRowActions = ({
|
||||
})}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
{isCloud() && (
|
||||
<LighthouseSkillsSubmenu
|
||||
onLaunch={(skill) => {
|
||||
onSkillLaunchOpenDrawer?.(row.index);
|
||||
launchSkill(skill, buildResourceFindingItem(resource));
|
||||
}}
|
||||
onSubmitPrompt={(text) => {
|
||||
onSkillLaunchOpenDrawer?.(row.index);
|
||||
launchPrompt(text, buildResourceFindingItem(resource));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
@@ -174,6 +211,9 @@ interface GetColumnFindingResourcesOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
selectableRowCount: number;
|
||||
findingTitle?: string;
|
||||
// Skill launch (pill or ⋮ submenu) opens this row's finding drawer behind
|
||||
// the chat tab, so the run and the finding share the side panel.
|
||||
onSkillLaunchOpenDrawer?: (rowIndex: number) => void;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
@@ -184,6 +224,7 @@ export function getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle,
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
|
||||
@@ -206,6 +247,7 @@ export function getColumnFindingResources({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mirrors the row's indicator + arrow so checkboxes stay aligned */}
|
||||
<div className="w-2" />
|
||||
<div className="w-4" />
|
||||
<Checkbox
|
||||
@@ -222,14 +264,23 @@ export function getColumnFindingResources({
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
// relative: paints above the cell's hover-extension pseudo-element,
|
||||
// which would otherwise cover the in-flow checkbox and indicator.
|
||||
<div className="relative flex items-center gap-2">
|
||||
<NotificationIndicator
|
||||
delta={row.original.delta as DeltaType | undefined}
|
||||
isMuted={row.original.isMuted}
|
||||
mutedReason={row.original.mutedReason}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
||||
{isCloud() ? (
|
||||
<LighthouseSkillsRowButton
|
||||
findingItem={buildResourceFindingItem(row.original)}
|
||||
onSkillLaunch={() => onSkillLaunchOpenDrawer?.(row.index)}
|
||||
/>
|
||||
) : (
|
||||
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={!!rowSelection[row.id]}
|
||||
@@ -370,6 +421,7 @@ export function getColumnFindingResources({
|
||||
<ResourceRowActions
|
||||
row={row}
|
||||
findingTitle={findingTitle}
|
||||
onSkillLaunchOpenDrawer={onSkillLaunchOpenDrawer}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
|
||||
@@ -15,9 +15,13 @@ import {
|
||||
} from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
const { MuteFindingsModalMock } = vi.hoisted(() => ({
|
||||
MuteFindingsModalMock: vi.fn((_props: unknown) => null),
|
||||
}));
|
||||
const { isCloudMock, launchSkillMock, MuteFindingsModalMock } = vi.hoisted(
|
||||
() => ({
|
||||
isCloudMock: vi.fn(() => false),
|
||||
launchSkillMock: vi.fn(),
|
||||
MuteFindingsModalMock: vi.fn((_props: unknown) => null),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
@@ -36,6 +40,19 @@ vi.mock("@/lib/deployment", () => ({
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
vi.mock("./lighthouse-skills-launch", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("./lighthouse-skills-launch")>();
|
||||
return {
|
||||
...actual,
|
||||
useLighthouseSkillLaunch: () => launchSkillMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
@@ -53,6 +70,19 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
@@ -136,9 +166,57 @@ function makeFindingRow(overrides?: Partial<FindingRowData>) {
|
||||
describe("DataTableRowActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isCloudMock.mockReturnValue(false);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("launches a Lighthouse skill from the row submenu with finding context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
render(<DataTableRowActions row={makeFindingRow()} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Triage Decision" }));
|
||||
|
||||
// Then
|
||||
expect(launchSkillMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
findingId: "finding-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the Lighthouse skills submenu on finding group rows", () => {
|
||||
// Group rows carry check ids, not finding UUIDs, so the finding-level
|
||||
// skills (and their Jira/mute follow-up actions) must not launch there.
|
||||
isCloudMock.mockReturnValue(true);
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "ecs_task_definitions_no_environment_secrets",
|
||||
checkTitle: "ECS task definitions no environment secrets",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 475,
|
||||
resourcesTotal: 475,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Lighthouse Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Triage Decision" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the mute modal immediately in preparing state for finding groups", async () => {
|
||||
// Given
|
||||
const deferred = deferredPromise<string[]>();
|
||||
|
||||
@@ -15,12 +15,15 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
@@ -28,6 +31,11 @@ import type { FindingTriageContext } from "./finding-note-modal";
|
||||
import { FindingNoteActionItem } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
LighthouseSkillsSubmenu,
|
||||
useLighthousePromptLaunch,
|
||||
useLighthouseSkillLaunch,
|
||||
} from "./lighthouse-skills-launch";
|
||||
|
||||
export interface FindingRowData {
|
||||
id: string;
|
||||
@@ -37,6 +45,8 @@ export interface FindingRowData {
|
||||
checktitle?: string;
|
||||
};
|
||||
};
|
||||
severity?: string;
|
||||
status?: string;
|
||||
triage?: FindingTriageSummary;
|
||||
relationships?: {
|
||||
resource?: {
|
||||
@@ -235,6 +245,19 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
// Skills are finding-level only: group rows carry check ids, not finding
|
||||
// UUIDs, so their menu never offers the Lighthouse entries (see below).
|
||||
const buildSkillFindingItem = () =>
|
||||
buildFindingResourceContext({ findingId: finding.id });
|
||||
const handleLaunchSkill = (skill: LighthouseSkillDefinition) => {
|
||||
launchSkill(skill, buildSkillFindingItem());
|
||||
};
|
||||
const handleSubmitPrompt = (text: string) => {
|
||||
launchPrompt(text, buildSkillFindingItem());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
@@ -275,6 +298,12 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
|
||||
{isCloud() && !isGroup && (
|
||||
<LighthouseSkillsSubmenu
|
||||
onLaunch={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
@@ -18,9 +18,11 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { TableCell, TableRow } from "@/components/shadcn/table";
|
||||
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
@@ -71,9 +73,18 @@ const COMPACT_LABELED_COLUMN_IDS = new Set([
|
||||
const STICKY_RESOURCE_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary";
|
||||
|
||||
// The hover Skills pill overhangs the first cell into the scrollport's pl-6
|
||||
// indent, which the row background never paints (and the row's rounded-l-full
|
||||
// cap starts at the cell edge). This pseudo-element repaints the highlight
|
||||
// from 24px left of the cell, so the hovered/selected row visually contains
|
||||
// the pill instead of leaving a dark notch around it.
|
||||
const SELECT_CELL_HOVER_EXTENSION_CLASS =
|
||||
"relative before:pointer-events-none before:absolute before:inset-y-0 before:-left-6 before:right-0 before:rounded-l-full before:content-[''] before:bg-transparent before:transition-colors group-hover:before:bg-bg-neutral-tertiary group-data-[state=selected]:before:bg-bg-neutral-tertiary";
|
||||
|
||||
const getResourceCellClassName = (columnId: string) =>
|
||||
cn(
|
||||
COMPACT_LABELED_COLUMN_IDS.has(columnId) && "align-top",
|
||||
columnId === "select" && SELECT_CELL_HOVER_EXTENSION_CLASS,
|
||||
columnId === ACTIONS_COLUMN_ID && STICKY_RESOURCE_ACTION_CELL_CLASS,
|
||||
);
|
||||
|
||||
@@ -207,6 +218,37 @@ export function InlineResourceContainer({
|
||||
showScrollHint,
|
||||
} = useScrollHint({ refreshToken: resources.length });
|
||||
|
||||
// Pin geometry for the expanded panel (PostHog-style): sized to the outer
|
||||
// card's scrollport and stuck to its left edge, so horizontal scrolling
|
||||
// moves the group columns while this block stays in place — which also
|
||||
// lets the sub-table's own sticky actions column anchor to a scrollport
|
||||
// that is actually visible from the start.
|
||||
const [scrollportPin, setScrollportPin] = useState<{
|
||||
width: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
useMountEffect(() => {
|
||||
const scrollParent = scrollContainerRef.current?.closest(
|
||||
"[data-table-scroll-container]",
|
||||
);
|
||||
if (!(scrollParent instanceof HTMLElement)) return;
|
||||
const measure = () => {
|
||||
const styles = getComputedStyle(scrollParent);
|
||||
const paddingLeft = parseFloat(styles.paddingLeft);
|
||||
setScrollportPin({
|
||||
width:
|
||||
scrollParent.clientWidth -
|
||||
paddingLeft -
|
||||
parseFloat(styles.paddingRight),
|
||||
left: paddingLeft,
|
||||
});
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(scrollParent);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
|
||||
const combinedScrollRef = (node: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = node;
|
||||
@@ -215,10 +257,18 @@ export function InlineResourceContainer({
|
||||
|
||||
useImperativeHandle(ref, () => ({ refresh, clearSelection }));
|
||||
|
||||
// A skill launch opens the drawer behind the chat: the Details tab must
|
||||
// register without stealing the AI tab the launch just selected.
|
||||
const [isSkillLaunchDrawer, setIsSkillLaunchDrawer] = useState(false);
|
||||
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle: group.checkTitle,
|
||||
onSkillLaunchOpenDrawer: (rowIndex) => {
|
||||
setIsSkillLaunchDrawer(true);
|
||||
drawer.openDrawer(rowIndex);
|
||||
},
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
@@ -267,15 +317,28 @@ export function InlineResourceContainer({
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
className="sticky overflow-hidden"
|
||||
// Without a measured scrollport the insets stay auto, which
|
||||
// makes the sticky inert and falls back to spanning the row.
|
||||
style={
|
||||
scrollportPin
|
||||
? { width: scrollportPin.width, left: scrollportPin.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
// pl (not ml): padding sits inside the overflow clip region,
|
||||
// giving the hover Skills pill room to extend left over the
|
||||
// row indent — a margin would clip it at the content edge.
|
||||
className="minimal-scrollbar max-h-[440px] overflow-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-max min-w-full border-separate border-spacing-y-4">
|
||||
{/* Resource rows or skeleton placeholder. No w-max: auto
|
||||
table layout compresses truncatable cells to fit, so
|
||||
horizontal scroll (and its extra trackpad gestures) only
|
||||
appears when columns genuinely can't fit. */}
|
||||
<table className="-mt-2.5 min-w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
@@ -300,7 +363,14 @@ export function InlineResourceContainer({
|
||||
)
|
||||
)
|
||||
return;
|
||||
setIsSkillLaunchDrawer(false);
|
||||
drawer.openDrawer(row.index);
|
||||
// The drawer may already be mounted (e.g. after
|
||||
// a skill launch left the AI tab in front):
|
||||
// a row click always fronts the Details tab.
|
||||
useSidePanelStore
|
||||
.getState()
|
||||
.openPanel(SIDE_PANEL_TAB.CONTEXT);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
@@ -371,8 +441,12 @@ export function InlineResourceContainer({
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) drawer.closeDrawer();
|
||||
if (!open) {
|
||||
drawer.closeDrawer();
|
||||
setIsSkillLaunchDrawer(false);
|
||||
}
|
||||
}}
|
||||
selectTabOnOpen={!isSkillLaunchDrawer}
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
import {
|
||||
LighthouseSkillsRowButton,
|
||||
LighthouseSkillsSubmenu,
|
||||
} from "./lighthouse-skills-launch";
|
||||
|
||||
const { requestPanelSkillLaunchMock, requestPanelChatMessageMock } = vi.hoisted(
|
||||
() => ({
|
||||
requestPanelSkillLaunchMock: vi.fn(),
|
||||
requestPanelChatMessageMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_lib/panel-chat-store", () => ({
|
||||
requestPanelSkillLaunch: requestPanelSkillLaunchMock,
|
||||
requestPanelChatMessage: requestPanelChatMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({
|
||||
children,
|
||||
trigger,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
trigger?: ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{trigger}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}) => <button onClick={onSelect}>{label}</button>,
|
||||
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Lighthouse skills launch controls", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSidePanelStore.setState({
|
||||
isOpen: false,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the shared submenu and launch the selected skill", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunch = vi.fn();
|
||||
render(<LighthouseSkillsSubmenu onLaunch={onLaunch} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Triage Decision" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Lighthouse Skills")).toBeInTheDocument();
|
||||
expect(onLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should open chat and launch a row skill with its finding context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSkillLaunch = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRowButton
|
||||
findingItem={{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Finding finding-1",
|
||||
findingId: "finding-1",
|
||||
}}
|
||||
onSkillLaunch={onSkillLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Triage Decision" }));
|
||||
|
||||
// Then
|
||||
expect(onSkillLaunch).toHaveBeenCalledOnce();
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(requestPanelSkillLaunchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
findingId: "finding-1",
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should start a fresh conversation from the row prompt with its finding context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSkillLaunch = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRowButton
|
||||
findingItem={{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Finding finding-1",
|
||||
findingId: "finding-1",
|
||||
}}
|
||||
onSkillLaunch={onSkillLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Ask Lighthouse anything" }),
|
||||
"Is this exposed?{Enter}",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onSkillLaunch).toHaveBeenCalledOnce();
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(requestPanelChatMessageMock).toHaveBeenCalledWith(
|
||||
"Is this exposed?",
|
||||
{
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
findingId: "finding-1",
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the recommended lead and the prompt row in the submenu", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseSkillsSubmenu onLaunch={vi.fn()} onSubmitPrompt={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Then — same shared menu body everywhere: Recommended group + footer.
|
||||
expect(screen.getByText("Recommended")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Ask Lighthouse anything" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { CornerDownRight, PencilLine } from "lucide-react";
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { getAllSkills } from "@/lib/lighthouse/skills/registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import {
|
||||
LIGHTHOUSE_CONTEXT_TRANSPORT,
|
||||
type LighthouseContextEnvelope,
|
||||
type LighthouseFindingContextItem,
|
||||
} from "@/types/lighthouse-context";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
|
||||
// Launching from a table row bypasses the context-contribution store: the row
|
||||
// already knows its finding, so the launch carries a minimal one-item envelope.
|
||||
function buildRowEnvelope(
|
||||
findingItem: LighthouseFindingContextItem,
|
||||
): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: LIGHTHOUSE_CONTEXT_TRANSPORT.INLINE,
|
||||
items: [findingItem],
|
||||
};
|
||||
}
|
||||
|
||||
export function useLighthouseSkillLaunch() {
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
|
||||
return (
|
||||
skill: LighthouseSkillDefinition,
|
||||
findingItem: LighthouseFindingContextItem,
|
||||
) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
// Lazy import: the panel chat store pulls in the whole chat/server-action
|
||||
// graph, which table columns must not load just to render a menu. Ordering
|
||||
// is safe — the store queues launches until the panel exists.
|
||||
void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then(
|
||||
({ requestPanelSkillLaunch }) =>
|
||||
requestPanelSkillLaunch(skill, buildRowEnvelope(findingItem)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Free-text sibling of useLighthouseSkillLaunch: starts a fresh Lighthouse
|
||||
// conversation about the row's finding with whatever the user typed.
|
||||
export function useLighthousePromptLaunch() {
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
|
||||
return (text: string, findingItem: LighthouseFindingContextItem) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then(
|
||||
({ requestPanelChatMessage }) =>
|
||||
requestPanelChatMessage(text, buildRowEnvelope(findingItem)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// The one skills-menu catalog split, shared by every surface: the first
|
||||
// enabled skill leads as RECOMMENDED, the rest follow in catalog order.
|
||||
const MENU_SKILLS = getAllSkills();
|
||||
const RECOMMENDED_SKILL = MENU_SKILLS.find((skill) => skill.enabled);
|
||||
const REST_SKILLS = MENU_SKILLS.filter((skill) => skill !== RECOMMENDED_SKILL);
|
||||
|
||||
// THE Lighthouse skills menu. Every surface that opens a skills dropdown
|
||||
// (row ⋮ submenu, hover pill, finding-detail rail) must render this body so
|
||||
// the menu stays identical app-wide. `onSubmitPrompt` adds the free-text
|
||||
// "Ask Lighthouse anything..." footer.
|
||||
export function LighthouseSkillsMenuItems({
|
||||
onLaunch,
|
||||
onSubmitPrompt,
|
||||
}: {
|
||||
onLaunch: (skill: LighthouseSkillDefinition) => void;
|
||||
onSubmitPrompt?: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{RECOMMENDED_SKILL && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-text-neutral-tertiary text-[10px] font-semibold tracking-wider uppercase">
|
||||
Recommended
|
||||
</DropdownMenuLabel>
|
||||
<ActionDropdownItem
|
||||
icon={<RECOMMENDED_SKILL.icon className="text-text-lighthouse" />}
|
||||
label={RECOMMENDED_SKILL.name}
|
||||
description={RECOMMENDED_SKILL.description}
|
||||
className="bg-bg-neutral-tertiary"
|
||||
onSelect={() => onLaunch(RECOMMENDED_SKILL)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{REST_SKILLS.map((skill) => (
|
||||
<ActionDropdownItem
|
||||
key={skill.id}
|
||||
icon={<skill.icon className="text-text-lighthouse" />}
|
||||
label={skill.name}
|
||||
description={skill.description}
|
||||
disabled={!skill.enabled}
|
||||
disabledTooltip="Coming soon"
|
||||
onSelect={() => onLaunch(skill)}
|
||||
/>
|
||||
))}
|
||||
{onSubmitPrompt && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AskLighthouseAnythingRow onSubmit={onSubmitPrompt} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain div on purpose: a DropdownMenuItem would hand the row to Radix roving
|
||||
// focus and close the menu on select while the user is still typing.
|
||||
function AskLighthouseAnythingRow({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
}) {
|
||||
// Local state needed: the prompt is buffered until the user submits.
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// Let Escape bubble so Radix closes the menu as usual.
|
||||
if (event.key === "Escape") return;
|
||||
// Keep every other key away from the menu: Radix typeahead would steal
|
||||
// printable characters to focus items while the user is typing.
|
||||
event.stopPropagation();
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
const text = prompt.trim();
|
||||
if (!text) return;
|
||||
onSubmit(text);
|
||||
setPrompt("");
|
||||
// Close the whole menu tree (works from submenus too) by replaying the
|
||||
// native dismissal path instead of threading open-state through props.
|
||||
event.currentTarget.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<PencilLine
|
||||
className="text-text-neutral-tertiary size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Ask Lighthouse anything"
|
||||
placeholder="Ask Lighthouse anything..."
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="text-text-neutral-primary placeholder:text-text-neutral-tertiary min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared ⋮-menu wrapper used by both finding-group and resource rows.
|
||||
export function LighthouseSkillsSubmenu({
|
||||
onLaunch,
|
||||
onSubmitPrompt,
|
||||
}: {
|
||||
onLaunch: (skill: LighthouseSkillDefinition) => void;
|
||||
onSubmitPrompt?: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="hover:bg-border-neutral-secondary flex cursor-pointer items-center gap-2 rounded-lg">
|
||||
<LighthouseIcon size={16} aria-hidden />
|
||||
Lighthouse Skills
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
variant="lighthouse"
|
||||
className="bg-bg-neutral-secondary w-72 rounded-xl"
|
||||
>
|
||||
<LighthouseSkillsMenuItems
|
||||
onLaunch={onLaunch}
|
||||
onSubmitPrompt={onSubmitPrompt}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Hover swap over the child-row corner arrow (design 1a revisited): at rest
|
||||
// the ↳ arrow occupies its normal 16px slot, so the row reserves no pill
|
||||
// width; on row hover, keyboard focus, or while the menu is open, the Skills
|
||||
// pill overlays the arrow, extending right over the row content. Relies on
|
||||
// the DataTable row's `group` class.
|
||||
export function LighthouseSkillsRowButton({
|
||||
findingItem,
|
||||
onSkillLaunch,
|
||||
}: {
|
||||
findingItem: LighthouseFindingContextItem;
|
||||
// Fired on launch so the owning table can open this row's finding detail
|
||||
// drawer behind the chat tab.
|
||||
onSkillLaunch?: () => void;
|
||||
}) {
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
|
||||
return (
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center">
|
||||
<ActionDropdown
|
||||
ariaLabel="Lighthouse skills for this finding"
|
||||
className="w-72"
|
||||
align="start"
|
||||
menuVariant="lighthouse"
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
// Overlay: right edge pinned to the arrow slot, extending left
|
||||
// over the dot and the row indent — nothing interactive lives
|
||||
// there, so the checkbox next to the arrow stays clickable. The
|
||||
// scroll container's pl-6 (padding, inside the clip region)
|
||||
// provides the room; a margin there would clip the pill.
|
||||
"absolute top-1/2 right-0 z-10 w-16 -translate-y-1/2",
|
||||
// Opaque base under the translucent gradient: the pill covers
|
||||
// row content, which must not show through. bg-bg-neutral-
|
||||
// tertiary matches the hovered row background.
|
||||
"border-border-lighthouse bg-bg-neutral-tertiary bg-lighthouse-soft text-text-lighthouse inline-flex items-center justify-center gap-1 rounded-full border py-1 text-xs font-medium",
|
||||
"peer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100",
|
||||
)}
|
||||
>
|
||||
<LighthouseIcon size={14} aria-hidden />
|
||||
Skills
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<LighthouseSkillsMenuItems
|
||||
onLaunch={(skill) => {
|
||||
onSkillLaunch?.();
|
||||
launchSkill(skill, findingItem);
|
||||
}}
|
||||
onSubmitPrompt={(text) => {
|
||||
onSkillLaunch?.();
|
||||
launchPrompt(text, findingItem);
|
||||
}}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
{/* peer-*: the arrow hides exactly while the pill shows, including when
|
||||
the open menu keeps the pill visible without hover. */}
|
||||
<CornerDownRight
|
||||
className="text-text-neutral-tertiary h-4 w-4 shrink-0 transition-opacity group-hover:opacity-0 peer-focus-visible:opacity-0 peer-data-[state=open]:opacity-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
|
||||
|
||||
describe("LighthouseSkillsBlock", () => {
|
||||
it("should render every skill with its description", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseSkillsBlock onLaunchSkill={vi.fn()} onAskAnything={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contextual Fix")).toBeInTheDocument();
|
||||
expect(screen.getByText("Triage Decision")).toBeInTheDocument();
|
||||
expect(screen.getByText("Systemic Scope")).toBeInTheDocument();
|
||||
expect(screen.getByText("Compliance Impact")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Is this real, and if not, close it out"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should launch the clicked skill", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onAskAnything={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).toHaveBeenCalledOnce();
|
||||
expect(onLaunchSkill.mock.calls[0][0]).toMatchObject({
|
||||
id: "triage-decision",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render disabled skills as coming soon and not launch them", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onAskAnything={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const card = screen.getByRole("button", { name: /Compliance Impact/ });
|
||||
expect(card).toBeDisabled();
|
||||
expect(screen.getByText("Coming soon")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(card);
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should keep the free-form fallback available", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onAskAnything = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={vi.fn()}
|
||||
onAskAnything={onAskAnything}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onAskAnything).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { getAllSkills } from "@/lib/lighthouse/skills/registry";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
|
||||
interface LighthouseSkillsBlockProps {
|
||||
onLaunchSkill: (skill: LighthouseSkillDefinition) => void;
|
||||
onAskAnything: () => void;
|
||||
}
|
||||
|
||||
// Finding-detail CTA: a grid of launchable agentic workflows plus
|
||||
// the free-form "ask anything" fallback that the old gradient banner offered.
|
||||
export function LighthouseSkillsBlock({
|
||||
onLaunchSkill,
|
||||
onAskAnything,
|
||||
}: LighthouseSkillsBlockProps) {
|
||||
return (
|
||||
<Card variant="lighthouse" className="gap-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LighthouseIcon size={16} />
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Lighthouse AI Skills
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary ml-auto text-xs">
|
||||
Run in chat
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @md:grid-cols-2">
|
||||
{getAllSkills().map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onLaunch={() => onLaunchSkill(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAskAnything}
|
||||
className="text-text-lighthouse self-end text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
Or ask Lighthouse anything about this finding →
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
onLaunch,
|
||||
}: {
|
||||
skill: LighthouseSkillDefinition;
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
const Icon = skill.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLaunch}
|
||||
disabled={!skill.enabled}
|
||||
className="group border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary disabled:hover:bg-bg-neutral-secondary flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Icon
|
||||
className="text-text-lighthouse mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-text-neutral-primary flex items-center gap-1.5 text-sm font-medium">
|
||||
{skill.name}
|
||||
{skill.enabled ? (
|
||||
<ArrowRight
|
||||
className="text-text-lighthouse size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="text-text-neutral-tertiary text-xs font-normal">
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs leading-snug">
|
||||
{skill.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LighthouseSkillsRail } from "./lighthouse-skills-rail";
|
||||
|
||||
describe("LighthouseSkillsRail", () => {
|
||||
it("should render a launch chip per enabled skill and none for disabled ones", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<LighthouseSkillsRail onLaunchSkill={vi.fn()} onSubmitPrompt={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Contextual Fix" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage Decision" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Systemic Scope" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Compliance Impact/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should launch a skill directly from its chip", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onSubmitPrompt={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Triage Decision" }));
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).toHaveBeenCalledOnce();
|
||||
expect(onLaunchSkill.mock.calls[0][0]).toMatchObject({
|
||||
id: "triage-decision",
|
||||
});
|
||||
});
|
||||
|
||||
it("should list the full catalog in the menu with a recommended lead", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onSubmitPrompt={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "More Lighthouse skills" }),
|
||||
);
|
||||
|
||||
// Then — every skill shows, the first enabled one leads as Recommended
|
||||
// (description included), and disabled ones stay dimmed as coming soon.
|
||||
expect(screen.getByText("Recommended")).toBeInTheDocument();
|
||||
const recommended = screen.getByRole("menuitem", {
|
||||
name: /Contextual Fix/,
|
||||
});
|
||||
expect(recommended).toHaveTextContent("Give me the fix for this finding");
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /Triage Decision/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /Systemic Scope/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /Compliance Impact/ }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// When — launching from a menu item works like the chips.
|
||||
await user.click(screen.getByRole("menuitem", { name: /Systemic Scope/ }));
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ id: "systemic-scope" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should submit the typed prompt, clear it and close the menu", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmitPrompt = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={vi.fn()}
|
||||
onSubmitPrompt={onSubmitPrompt}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "More Lighthouse skills" }),
|
||||
);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Ask Lighthouse anything",
|
||||
});
|
||||
await user.type(input, "Is this reachable from the internet?{Enter}");
|
||||
|
||||
// Then
|
||||
expect(onSubmitPrompt).toHaveBeenCalledExactlyOnceWith(
|
||||
"Is this reachable from the internet?",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Compliance Impact/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should ignore whitespace-only prompts and keep the menu open", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSubmitPrompt = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={vi.fn()}
|
||||
onSubmitPrompt={onSubmitPrompt}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "More Lighthouse skills" }),
|
||||
);
|
||||
|
||||
// When
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Ask Lighthouse anything",
|
||||
});
|
||||
await user.type(input, " {Enter}");
|
||||
|
||||
// Then
|
||||
expect(onSubmitPrompt).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /Compliance Impact/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep typed letters in the prompt instead of feeding menu typeahead", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onLaunchSkill = vi.fn();
|
||||
render(
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={onLaunchSkill}
|
||||
onSubmitPrompt={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "More Lighthouse skills" }),
|
||||
);
|
||||
|
||||
// When — "C" is Compliance Impact's typeahead prefix.
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Ask Lighthouse anything",
|
||||
});
|
||||
await user.type(input, "Compliance{Enter}");
|
||||
|
||||
// Then
|
||||
expect(onLaunchSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { LighthouseSkillsMenuItems } from "@/components/findings/table/lighthouse-skills-launch";
|
||||
import { LighthouseIcon } from "@/components/icons";
|
||||
import { ActionDropdown } from "@/components/shadcn/dropdown";
|
||||
import {
|
||||
getAllSkills,
|
||||
getLaunchableSkills,
|
||||
} from "@/lib/lighthouse/skills/registry";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
|
||||
// Catalog is static, so the chip split is module-level: launchable skills
|
||||
// become direct-launch chips; +N counts what the rail hides (disabled skills
|
||||
// today). The menu itself is the app-wide shared skills menu.
|
||||
const CHIP_SKILLS = getLaunchableSkills();
|
||||
const OVERFLOW_COUNT = getAllSkills().length - CHIP_SKILLS.length;
|
||||
|
||||
interface LighthouseSkillsRailProps {
|
||||
onLaunchSkill: (skill: LighthouseSkillDefinition) => void;
|
||||
// Free-text prompt submitted from the menu footer; starts a fresh
|
||||
// Lighthouse panel conversation with the typed text.
|
||||
onSubmitPrompt: (text: string) => void;
|
||||
}
|
||||
|
||||
// Finding-detail skill launcher, "dropdown" experiment variant (chip rail):
|
||||
// one compact row under the finding title — direct-launch chips plus a +N
|
||||
// trigger opening the same skills menu used by the table row surfaces.
|
||||
export function LighthouseSkillsRail({
|
||||
onLaunchSkill,
|
||||
onSubmitPrompt,
|
||||
}: LighthouseSkillsRailProps) {
|
||||
return (
|
||||
<div className="border-border-lighthouse bg-lighthouse-soft flex h-10 shrink-0 items-center gap-2 rounded-xl border px-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<LighthouseIcon size={16} aria-hidden />
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Skills
|
||||
</span>
|
||||
</div>
|
||||
<span aria-hidden className="bg-border-lighthouse h-5 w-px shrink-0" />
|
||||
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||||
{CHIP_SKILLS.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onLaunchSkill(skill)}
|
||||
className="border-border-lighthouse text-text-neutral-primary hover:bg-bg-neutral-tertiary inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<skill.icon className="text-text-lighthouse size-3.5" aria-hidden />
|
||||
{skill.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="bg-border-lighthouse h-5 w-px shrink-0" />
|
||||
{/* Pinned outside the scroll strip so the trigger never scrolls away. */}
|
||||
<ActionDropdown
|
||||
align="end"
|
||||
menuVariant="lighthouse"
|
||||
className="bg-bg-neutral-secondary w-80 rounded-xl"
|
||||
ariaLabel="More Lighthouse skills"
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More Lighthouse skills"
|
||||
className="text-text-lighthouse inline-flex shrink-0 cursor-pointer items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
{OVERFLOW_COUNT > 0 && `+${OVERFLOW_COUNT}`}
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<LighthouseSkillsMenuItems
|
||||
onLaunch={onLaunchSkill}
|
||||
onSubmitPrompt={onSubmitPrompt}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+154
-16
@@ -25,7 +25,9 @@ const {
|
||||
mockUpdateFindingTriage,
|
||||
mockLoadLatestFindingTriageNote,
|
||||
mockRequestPanelChatMessage,
|
||||
mockRequestPanelSkillLaunch,
|
||||
mockIsCloud,
|
||||
mockUseSkillLauncherVariant,
|
||||
mockCurrentLighthouseContext,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
@@ -37,7 +39,9 @@ const {
|
||||
mockUpdateFindingTriage: vi.fn(),
|
||||
mockLoadLatestFindingTriageNote: vi.fn(),
|
||||
mockRequestPanelChatMessage: vi.fn(),
|
||||
mockRequestPanelSkillLaunch: vi.fn(),
|
||||
mockIsCloud: vi.fn(() => true),
|
||||
mockUseSkillLauncherVariant: vi.fn(() => "card"),
|
||||
mockCurrentLighthouseContext: {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
@@ -160,17 +164,29 @@ vi.mock("@/components/shadcn/card/card", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
// Always-open stand-in: renders the trigger (for presence assertions) and
|
||||
// the menu children inline. Real menu behavior is covered by the rail's and
|
||||
// the primitive's own tests.
|
||||
ActionDropdown: ({
|
||||
children,
|
||||
trigger,
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
trigger?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
}) => (
|
||||
<div role="menu" aria-label={ariaLabel}>
|
||||
{children}
|
||||
<div>
|
||||
{trigger}
|
||||
<div role="menu" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => null,
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
disabled,
|
||||
@@ -288,6 +304,11 @@ vi.mock("@/actions/findings", () => ({
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
getComplianceIcon: mockGetComplianceIcon,
|
||||
LighthouseIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./use-skill-launcher-variant", () => ({
|
||||
useSkillLauncherVariant: mockUseSkillLauncherVariant,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
@@ -384,6 +405,7 @@ vi.mock("@/lib/shared/env", () => ({
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_lib/panel-chat-store", () => ({
|
||||
requestPanelChatMessage: mockRequestPanelChatMessage,
|
||||
requestPanelSkillLaunch: mockRequestPanelSkillLaunch,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-lighthouse-context", () => ({
|
||||
@@ -537,6 +559,7 @@ afterEach(() => {
|
||||
(_: string) => null as string | null,
|
||||
);
|
||||
mockIsCloud.mockReturnValue(true);
|
||||
mockUseSkillLauncherVariant.mockReturnValue("card");
|
||||
useSidePanelStore.setState({
|
||||
isOpen: false,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
@@ -805,7 +828,7 @@ const mockResourceRow: FindingResourceRow = {
|
||||
};
|
||||
|
||||
describe("ResourceDetailDrawerContent — Lighthouse AI", () => {
|
||||
it("should open the Lighthouse tab and submit a contextual analysis", async () => {
|
||||
it("should open the Lighthouse tab without starting a conversation", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
useSidePanelStore.setState({
|
||||
@@ -827,22 +850,26 @@ describe("ResourceDetailDrawerContent — Lighthouse AI", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
// When: the free-form fallback only navigates to the chat tab
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Analyze This Finding With Lighthouse AI",
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockRequestPanelChatMessage).toHaveBeenCalledWith(
|
||||
"Analyze this finding",
|
||||
mockCurrentLighthouseContext,
|
||||
);
|
||||
// Then — no conversation is started on the user's behalf
|
||||
expect(mockRequestPanelChatMessage).not.toHaveBeenCalled();
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
|
||||
// And launching a skill goes through the skill launcher with the context
|
||||
await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
|
||||
expect(mockRequestPanelSkillLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
mockCurrentLighthouseContext,
|
||||
);
|
||||
});
|
||||
|
||||
it("should hide the action when the Lighthouse panel tab is unavailable", () => {
|
||||
@@ -866,14 +893,129 @@ describe("ResourceDetailDrawerContent — Lighthouse AI", () => {
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Analyze This Finding With Lighthouse AI",
|
||||
name: /ask Lighthouse anything about this finding/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
|
||||
const renderDrawer = (overrides: { isNavigating?: boolean } = {}) =>
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={overrides.isNavigating ?? false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("should swap the footer card for the header chip rail on the dropdown variant", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
renderDrawer();
|
||||
|
||||
// Then — control card gone, rail chips present. The always-open dropdown
|
||||
// mock repeats every skill as a menu button, hence getAllByRole.
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Contextual Fix" }).length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// When — first match is the rail chip (rendered before the menu).
|
||||
await user.click(
|
||||
screen.getAllByRole("button", { name: "Triage Decision" })[0],
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockRequestPanelSkillLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "triage-decision" }),
|
||||
mockCurrentLighthouseContext,
|
||||
);
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("should start a fresh conversation from the rail prompt", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
renderDrawer();
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "More Lighthouse skills" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Ask Lighthouse anything" }),
|
||||
"Is this exposed?{Enter}",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockRequestPanelChatMessage).toHaveBeenCalledWith(
|
||||
"Is this exposed?",
|
||||
mockCurrentLighthouseContext,
|
||||
);
|
||||
expect(useSidePanelStore.getState()).toMatchObject({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide the rail outside cloud and while navigating", () => {
|
||||
// Given
|
||||
mockUseSkillLauncherVariant.mockReturnValue("dropdown");
|
||||
mockIsCloud.mockReturnValue(false);
|
||||
|
||||
// When
|
||||
const { unmount } = renderDrawer();
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Contextual Fix" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Given
|
||||
unmount();
|
||||
mockIsCloud.mockReturnValue(true);
|
||||
|
||||
// When
|
||||
renderDrawer({ isNavigating: true });
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Contextual Fix" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the card control for unresolved or unknown variants", () => {
|
||||
// Given — the hook already collapses those to "card"; the drawer treats
|
||||
// anything that is not exactly "dropdown" as control.
|
||||
mockUseSkillLauncherVariant.mockReturnValue("card");
|
||||
|
||||
// When
|
||||
renderDrawer();
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "More Lighthouse skills" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — remediation code editors", () => {
|
||||
const checkMetaWithCommands: CheckMeta = {
|
||||
...mockCheckMeta,
|
||||
@@ -1759,11 +1901,7 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
|
||||
expect(screen.getByText("security")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Analyze This Finding With Lighthouse AI",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => {
|
||||
|
||||
+127
-36
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
Box,
|
||||
CircleArrowRight,
|
||||
CircleChevronLeft,
|
||||
CircleChevronRight,
|
||||
Container,
|
||||
@@ -20,7 +19,10 @@ import {
|
||||
type ResourceDrawerFinding,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { requestPanelChatMessage } from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import {
|
||||
requestPanelChatMessage,
|
||||
requestPanelSkillLaunch,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
@@ -83,6 +85,10 @@ import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
type LighthouseSkillDefinition,
|
||||
} from "@/types/lighthouse-skills";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
@@ -93,8 +99,11 @@ import {
|
||||
} from "../finding-triage-cells";
|
||||
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
||||
|
||||
import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
|
||||
import { LighthouseSkillsRail } from "./lighthouse-skills-rail";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
|
||||
|
||||
const OTHER_FINDINGS_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
|
||||
@@ -307,34 +316,50 @@ export function ResourceDetailDrawerContent({
|
||||
const searchParams = useSearchParams();
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
const lighthouseContext = useLighthouseCurrentContext();
|
||||
// A/B experiment: PostHog decides between the footer card (control) and
|
||||
// the header chip rail. Falls back to the card until the flag resolves.
|
||||
const isDropdownLauncher =
|
||||
useSkillLauncherVariant() === SKILL_LAUNCHER_VARIANT.DROPDOWN;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
// Initial load — no check metadata yet
|
||||
// Initial load — no check metadata yet. Mirrors the loaded layout 1:1:
|
||||
// header (badges, title, compliance chips), navigation row, and the
|
||||
// resource card with metadata grid, tabs bar and overview blocks.
|
||||
if (!checkMeta && isLoading) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Header skeleton — status/severity badges, title, compliance chips */}
|
||||
<div className="flex flex-col gap-2" aria-hidden="true">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-14 rounded-md" />
|
||||
<Skeleton className="h-6 w-16 rounded-md" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-3/4 rounded" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-7 w-48 rounded" />
|
||||
{/* Navigation skeleton — "Resource X of N" tag + carousel chevrons */}
|
||||
<div className="flex items-center justify-between" aria-hidden="true">
|
||||
<Skeleton className="h-7 w-32 rounded" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource card skeleton */}
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
|
||||
<ResourceDetailSkeleton />
|
||||
<TabsBarSkeleton />
|
||||
<OverviewNavigationSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -431,9 +456,22 @@ export function ResourceDetailDrawerContent({
|
||||
onTriageUpdate?.(input);
|
||||
};
|
||||
|
||||
const handleAnalyzeFinding = () => {
|
||||
// Navigation only: the panel picks up the focused finding as context on its
|
||||
// own, so no conversation is started on the user's behalf.
|
||||
const handleOpenLighthouseChat = () => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
requestPanelChatMessage("Analyze this finding", lighthouseContext.context);
|
||||
};
|
||||
|
||||
const handleLaunchSkill = (skill: LighthouseSkillDefinition) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
requestPanelSkillLaunch(skill, lighthouseContext.context);
|
||||
};
|
||||
|
||||
// TODO(experiment): capture launch origin + skill id via cloud's
|
||||
// trackEvent/ANALYTICS_EVENTS once the flag readout event lands.
|
||||
const handleSubmitPrompt = (text: string) => {
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
requestPanelChatMessage(text, lighthouseContext.context);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -535,12 +573,22 @@ export function ResourceDetailDrawerContent({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-7 w-16 rounded-md" />
|
||||
<Skeleton className="h-7 w-20 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
<Skeleton className="size-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill launcher experiment, "dropdown" variant: chip rail under the
|
||||
title instead of the footer card. */}
|
||||
{isCloud() && !isNavigating && isDropdownLauncher && (
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation: "Resource (X of N)" */}
|
||||
@@ -1042,7 +1090,7 @@ export function ResourceDetailDrawerContent({
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<OverviewNavigationSkeleton testId="remediation-navigation-skeleton" />
|
||||
<RemediationNavigationSkeleton />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -1245,45 +1293,88 @@ export function ResourceDetailDrawerContent({
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Lighthouse AI button */}
|
||||
{isCloud() && !isNavigating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAnalyzeFinding}
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--gradient-lighthouse)",
|
||||
}}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
Analyze This Finding With Lighthouse AI
|
||||
</button>
|
||||
{/* Lighthouse AI Skills (design 1d) — the experiment's card control */}
|
||||
{isCloud() && !isNavigating && !isDropdownLauncher && (
|
||||
<LighthouseSkillsBlock
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onAskAnything={handleOpenLighthouseChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewNavigationSkeleton({ testId }: { testId?: string } = {}) {
|
||||
// Mirrors the loaded Overview tab: risk callout, description and the IDs card.
|
||||
function OverviewNavigationSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid={testId ?? "overview-navigation-skeleton"}
|
||||
data-testid="overview-navigation-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Risk — left-bordered callout */}
|
||||
<div className="border-border-neutral-primary flex flex-col gap-2 border-l-4 pl-3">
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-5/6 rounded" />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-2 px-1">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
{/* Check ID / Finding ID / Finding UID card */}
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-24", "w-full", "w-5/6"]} />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-x-6">
|
||||
{["w-16", "w-20", "w-20"].map((labelWidth, index) => (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
<Skeleton className={`h-3.5 ${labelWidth} rounded`} />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the loaded Remediation tab: heading row with link, text, code card.
|
||||
function RemediationNavigationSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid="remediation-navigation-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
</div>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton
|
||||
lineWidths={["w-28", "w-3/4", "w-full", "w-2/3"]}
|
||||
/>
|
||||
</Card>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-20", "w-40", "w-24"]} />
|
||||
<Skeleton className="h-24 w-full rounded" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the tabs bar: six text triggers with their separators' spacing.
|
||||
function TabsBarSkeleton() {
|
||||
return (
|
||||
<div className="mt-2 mb-4 flex items-center gap-8" aria-hidden="true">
|
||||
{["w-16", "w-24", "w-16", "w-24", "w-12", "w-12"].map(
|
||||
(tabWidth, index) => (
|
||||
<Skeleton key={index} className={`h-5 ${tabWidth} rounded`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewCardSkeleton({ lineWidths }: { lineWidths: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-hidden="true">
|
||||
|
||||
@@ -23,6 +23,9 @@ interface ResourceDetailDrawerProps {
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
showSyntheticResourceHint?: boolean;
|
||||
// Forwarded to DetailSidePanel: false opens the Details tab without
|
||||
// selecting it (skill launches keep the AI chat tab in front).
|
||||
selectTabOnOpen?: boolean;
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
@@ -41,6 +44,7 @@ export function ResourceDetailDrawer({
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
showSyntheticResourceHint = false,
|
||||
selectTabOnOpen,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
@@ -68,6 +72,7 @@ export function ResourceDetailDrawer({
|
||||
title="Resource Finding Details"
|
||||
description="View finding details for the selected resource"
|
||||
context={context}
|
||||
selectTabOnOpen={selectTabOnOpen}
|
||||
>
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -7,34 +7,45 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
*/
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
data-responsive-container
|
||||
className="@container flex min-w-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
{/* Row 1: Provider, Resource, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8">
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
|
||||
<>
|
||||
<div className="flex items-start gap-4" aria-hidden="true">
|
||||
<div
|
||||
data-responsive-container
|
||||
className="@container flex min-w-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
{/* Row 1: Provider, Resource, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8">
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
|
||||
</div>
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton labelWidth="w-14" />
|
||||
</div>
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
</div>
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton labelWidth="w-14" />
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8">
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
</div>
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8">
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
</div>
|
||||
{/* Actions ⋮ — same footprint as the bordered ActionDropdown trigger */}
|
||||
<Skeleton className="size-8 shrink-0 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Actions button */}
|
||||
<Skeleton className="size-11 shrink-0 rounded-full" />
|
||||
</div>
|
||||
{/* Status line card (status_extended) below the resource info */}
|
||||
<div
|
||||
className="border-border-neutral-secondary flex flex-col gap-2 rounded-lg border p-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +66,11 @@ function EntityInfoSkeleton({
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
{/* "UID:" label + code-snippet pill */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-7 rounded" />
|
||||
<Skeleton className="h-6 w-28 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useFeatureFlagVariantKeyMock } = vi.hoisted(() => ({
|
||||
useFeatureFlagVariantKeyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagVariantKey: useFeatureFlagVariantKeyMock,
|
||||
}));
|
||||
|
||||
import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
|
||||
|
||||
describe("useSkillLauncherVariant", () => {
|
||||
beforeEach(() => {
|
||||
useFeatureFlagVariantKeyMock.mockReset();
|
||||
});
|
||||
|
||||
it("should ask PostHog for the experiment flag", () => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue(undefined);
|
||||
|
||||
renderHook(() => useSkillLauncherVariant());
|
||||
|
||||
expect(useFeatureFlagVariantKeyMock).toHaveBeenCalledWith(
|
||||
"finding-detail-skill-launcher",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the dropdown variant when the flag resolves to it", () => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue("dropdown");
|
||||
|
||||
const { result } = renderHook(() => useSkillLauncherVariant());
|
||||
|
||||
expect(result.current).toBe("dropdown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["card", "card"],
|
||||
["unresolved flag", undefined],
|
||||
["boolean flag", false],
|
||||
["unknown variant", "weird"],
|
||||
])("should fall back to card for %s", (_label, flagValue) => {
|
||||
useFeatureFlagVariantKeyMock.mockReturnValue(flagValue);
|
||||
|
||||
const { result } = renderHook(() => useSkillLauncherVariant());
|
||||
|
||||
expect(result.current).toBe("card");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useFeatureFlagVariantKey } from "posthog-js/react";
|
||||
|
||||
import {
|
||||
SKILL_LAUNCHER_FLAG,
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
type SkillLauncherVariant,
|
||||
} from "@/types/lighthouse-skills";
|
||||
|
||||
// The hook reads the global posthog singleton (posthog-js/react's default
|
||||
// context), so it needs no provider or init here: the cloud fork initializes
|
||||
// and identifies the client; OSS builds never resolve the flag and fall back
|
||||
// to the card control, as do unresolved or unknown variants.
|
||||
export function useSkillLauncherVariant(): SkillLauncherVariant {
|
||||
const value = useFeatureFlagVariantKey(SKILL_LAUNCHER_FLAG);
|
||||
return value === SKILL_LAUNCHER_VARIANT.DROPDOWN
|
||||
? SKILL_LAUNCHER_VARIANT.DROPDOWN
|
||||
: SKILL_LAUNCHER_VARIANT.CARD;
|
||||
}
|
||||
@@ -88,8 +88,9 @@ export function NavbarClient({
|
||||
// -ml-4/pl-4: bleed the bar across <main>'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.
|
||||
<header className="border-border-neutral-secondary sticky top-0 z-10 -ml-4 border-b pt-4 pl-4 backdrop-blur-sm">
|
||||
<div className="flex h-14 items-center pr-6">
|
||||
<header className="border-border-neutral-secondary sticky top-0 z-10 -ml-4 border-b pl-4 backdrop-blur-sm">
|
||||
{/* h-15 (60px) matches the side panel's tab header so both border-b lines align. */}
|
||||
<div className="flex h-15 items-center pr-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileAppSidebar />
|
||||
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
|
||||
|
||||
@@ -28,6 +28,8 @@ const badgeVariants = cva(
|
||||
cloud:
|
||||
"bg-feature-cloud h-6 rounded-lg border-0 px-2 py-0 text-xs leading-5 font-bold text-black",
|
||||
new: "bg-bg-feature-new text-text-feature-new border-0 font-bold",
|
||||
lighthouse:
|
||||
"border-border-lighthouse bg-lighthouse-soft text-text-lighthouse",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
|
||||
@@ -21,6 +21,18 @@ describe("Button", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("supports extra-small text buttons", () => {
|
||||
render(
|
||||
<Button variant="outline" size="xs">
|
||||
Create Jira ticket
|
||||
</Button>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Create Jira ticket" });
|
||||
expect(button).toHaveClass("h-7");
|
||||
expect(button).toHaveClass("text-xs");
|
||||
});
|
||||
|
||||
it("supports extra-small link buttons", () => {
|
||||
render(
|
||||
<Button variant="link" size="link-xs">
|
||||
|
||||
@@ -28,6 +28,9 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
// Compact inline actions (e.g. under a chat answer): icon drops to
|
||||
// 3.5 to stay proportional with the h-7 box and text-xs label.
|
||||
xs: "h-7 gap-1 px-2.5 text-xs has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
xl: "h-12 px-8 text-base has-[>svg]:px-6",
|
||||
|
||||
@@ -22,6 +22,9 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
danger: "border-border-error bg-bg-fail-secondary gap-1 rounded-[12px]",
|
||||
success: "border-bg-pass bg-bg-pass-secondary gap-1 rounded-[12px]",
|
||||
warning: "border-bg-warning bg-bg-warning-secondary gap-1 rounded-[12px]",
|
||||
// Blue-green animated gradient ring (Lighthouse accent); border-0 —
|
||||
// the ring pseudo draws the edge in place of the real border.
|
||||
lighthouse: "gradient-border-lighthouse bg-bg-neutral-primary border-0",
|
||||
},
|
||||
padding: {
|
||||
default: "",
|
||||
|
||||
@@ -26,7 +26,7 @@ const comboboxTriggerVariants = cva("", {
|
||||
default:
|
||||
"w-full justify-between rounded-lg border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary",
|
||||
ghost:
|
||||
"border-none bg-transparent shadow-none hover:bg-accent hover:text-text-neutral-primary",
|
||||
"border-none bg-transparent shadow-none hover:bg-border-neutral-secondary hover:text-text-neutral-primary",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
|
||||
@@ -153,8 +153,8 @@ function CommandItem({
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors select-none",
|
||||
"hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary",
|
||||
"data-[selected=true]:bg-bg-neutral-tertiary data-[selected=true]:text-text-neutral-primary",
|
||||
"hover:bg-border-neutral-secondary hover:text-text-neutral-primary",
|
||||
"data-[selected=true]:bg-border-neutral-secondary data-[selected=true]:text-text-neutral-primary",
|
||||
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
|
||||
@@ -31,7 +31,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hover:text-accent-foreground focus:bg-accent data-[state=open]:bg-accent text-text-neutral-secondary hover:bg-bg-neutral-tertiary flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:font-bold",
|
||||
"hover:text-text-neutral-primary focus:bg-border-neutral-secondary data-[state=open]:bg-border-neutral-secondary text-text-neutral-secondary hover:bg-border-neutral-secondary flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:font-bold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
@@ -88,7 +88,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground text-text-neutral-secondary hover:bg-bg-neutral-tertiary relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm subpixel-antialiased transition-colors outline-none select-none hover:[font-variation-settings:'wght'_600] data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary text-text-neutral-secondary hover:bg-border-neutral-secondary relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm subpixel-antialiased transition-colors outline-none select-none hover:[font-variation-settings:'wght'_600] data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
@@ -104,7 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground text-text-neutral-secondary hover:bg-bg-neutral-tertiary relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none hover:font-bold data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary text-text-neutral-secondary hover:bg-border-neutral-secondary relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none hover:font-bold data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ActionDropdown, ActionDropdownItem } from "./action-dropdown";
|
||||
|
||||
describe("ActionDropdownItem", () => {
|
||||
it("should keep a disabled item with tooltip dimmed, inert and hoverable", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<ActionDropdown trigger={<button type="button">Actions</button>}>
|
||||
<ActionDropdownItem
|
||||
label="Compliance Impact"
|
||||
disabled
|
||||
disabledTooltip="Coming soon"
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</ActionDropdown>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
const item = screen.getByRole("menuitem", { name: /Compliance Impact/ });
|
||||
|
||||
// Then — stays interactive for the tooltip but reads and looks disabled.
|
||||
expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
expect(item).toHaveClass("opacity-50");
|
||||
|
||||
// When
|
||||
await user.hover(item);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("Coming soon");
|
||||
|
||||
// When
|
||||
await user.click(item);
|
||||
|
||||
// Then
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should support controlled open state", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ActionDropdown
|
||||
open={false}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={<button type="button">Actions</button>}
|
||||
>
|
||||
<ActionDropdownItem label="Item" />
|
||||
</ActionDropdown>,
|
||||
);
|
||||
|
||||
// Then — closed until the controller says otherwise.
|
||||
expect(screen.queryByRole("menuitem")).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
// Then — the component only notifies; the owner flips the prop.
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
expect(screen.queryByRole("menuitem")).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<ActionDropdown
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={<button type="button">Actions</button>}
|
||||
>
|
||||
<ActionDropdownItem label="Item" />
|
||||
</ActionDropdown>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("menuitem", { name: "Item" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should stay open when the scroll happens inside the menu content", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ActionDropdown trigger={<button type="button">Actions</button>}>
|
||||
<ActionDropdownItem label="Item" />
|
||||
</ActionDropdown>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
const item = screen.getByRole("menuitem", { name: "Item" });
|
||||
|
||||
// When — a scroll event bubbling from inside the menu's own content.
|
||||
item.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("menuitem", { name: "Item" })).toBeInTheDocument();
|
||||
|
||||
// When — a scroll anywhere else (ancestor/page) still closes it.
|
||||
document.body.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.queryByRole("menuitem")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not dim an enabled item and fire its onSelect", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<ActionDropdown trigger={<button type="button">Actions</button>}>
|
||||
<ActionDropdownItem label="Contextual Fix" onSelect={onSelect} />
|
||||
</ActionDropdown>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
const item = screen.getByRole("menuitem", { name: /Contextual Fix/ });
|
||||
|
||||
// Then
|
||||
expect(item).not.toHaveClass("opacity-50");
|
||||
|
||||
// When
|
||||
await user.click(item);
|
||||
|
||||
// Then
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
type DropdownContentVariant,
|
||||
} from "./dropdown";
|
||||
|
||||
const ACTION_TRIGGER_STYLES = {
|
||||
@@ -31,8 +32,14 @@ interface ActionDropdownProps {
|
||||
align?: "start" | "center" | "end";
|
||||
/** Additional className for the content */
|
||||
className?: string;
|
||||
/** Content style variant, e.g. the Lighthouse gradient border */
|
||||
menuVariant?: DropdownContentVariant;
|
||||
/** Accessible label for the trigger */
|
||||
ariaLabel?: string;
|
||||
/** Controlled open state. Omit for the default uncontrolled behavior. */
|
||||
open?: boolean;
|
||||
/** Open-state change notifications; pairs with `open` for controlled use. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -41,29 +48,42 @@ export function ActionDropdown({
|
||||
variant = "table",
|
||||
align = "end",
|
||||
className,
|
||||
menuVariant,
|
||||
ariaLabel = "Open actions menu",
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: ActionDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||
const open = openProp ?? uncontrolledOpen;
|
||||
|
||||
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events),
|
||||
// but ignore scrolls originating inside a nested dialog (e.g. pasting into a modal
|
||||
// textarea) so they don't unmount a modal rendered within this menu.
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setUncontrolledOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
// Close dropdown when any ancestor scrolls (capture phase catches all scroll
|
||||
// events), but ignore scrolls originating inside a nested dialog (e.g.
|
||||
// pasting into a modal textarea) or inside the menu's own content, so they
|
||||
// don't unmount what the user is interacting with.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('[data-slot="dialog-content"]')
|
||||
target.closest(
|
||||
'[data-slot="dialog-content"], [data-slot="dropdown-menu-content"]',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
if (openProp === undefined) setUncontrolledOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => window.removeEventListener("scroll", handleScroll, true);
|
||||
}, [open]);
|
||||
}, [open, openProp, onOpenChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
@@ -85,6 +105,7 @@ export function ActionDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
variant={menuVariant}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary w-56 rounded-xl",
|
||||
className,
|
||||
@@ -127,9 +148,14 @@ export function ActionDropdownItem({
|
||||
const item = (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
|
||||
"hover:bg-border-neutral-secondary flex cursor-pointer items-start gap-2 rounded-lg transition-colors",
|
||||
destructive &&
|
||||
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
|
||||
// A disabled item with a tooltip stays interactive so hover can fire,
|
||||
// which means Radix never stamps data-disabled — mirror its disabled
|
||||
// styling manually.
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-50 hover:bg-transparent focus:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
aria-disabled={disabled || undefined}
|
||||
|
||||
@@ -31,11 +31,26 @@ function DropdownMenuTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
// Content look shared by DropdownMenuContent and DropdownMenuSubContent.
|
||||
// `lighthouse` draws the blue-green gradient ring used by skill surfaces.
|
||||
const DROPDOWN_CONTENT_VARIANT_STYLES = {
|
||||
default: "",
|
||||
// border-0: the ring pseudo draws the edge; a real border would offset it
|
||||
// and poke scrollable overflow into the content's overflow-y-auto.
|
||||
lighthouse: "gradient-border-lighthouse border-0",
|
||||
} as const;
|
||||
|
||||
export type DropdownContentVariant =
|
||||
keyof typeof DROPDOWN_CONTENT_VARIANT_STYLES;
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Content> & {
|
||||
variant?: DropdownContentVariant;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
@@ -44,6 +59,7 @@ function DropdownMenuContent({
|
||||
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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
DROPDOWN_CONTENT_VARIANT_STYLES[variant],
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -74,7 +90,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +108,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +144,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -170,7 +186,10 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
// bg-border-neutral-secondary, not shadcn's bg-border: this theme never
|
||||
// defines --color-border, so bg-border compiles to nothing and the
|
||||
// separator renders invisible.
|
||||
className={cn("bg-border-neutral-secondary -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -208,7 +227,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary data-[state=open]:bg-border-neutral-secondary data-[state=open]:text-text-neutral-primary [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -221,14 +240,18 @@ function DropdownMenuSubTrigger({
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.SubContent> & {
|
||||
variant?: DropdownContentVariant;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
DROPDOWN_CONTENT_VARIANT_STYLES[variant],
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("MultiSelect", () => {
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:hover:bg-button-tertiary/15",
|
||||
);
|
||||
expect(selectedItem).toHaveClass("hover:bg-slate-200");
|
||||
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
|
||||
expect(selectedItem).toHaveClass("hover:bg-border-neutral-secondary");
|
||||
expect(selectedItem).toHaveClass("hover:bg-border-neutral-secondary");
|
||||
expect(selectedItem.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ export function MultiSelectItem({
|
||||
keywords={keywords}
|
||||
data-slot="multiselect-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 data-[selected=true]:data-[state=checked]:bg-button-tertiary/15 my-1 flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 data-[selected=true]:data-[state=checked]:bg-button-tertiary/15 hover:bg-border-neutral-secondary my-1 flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
@@ -489,7 +489,7 @@ export function MultiSelectSelectAll({
|
||||
type="button"
|
||||
data-slot="multiselect-select-all"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary hover:bg-border-neutral-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
hasSelections && "text-destructive hover:text-destructive",
|
||||
!hasSelections && "cursor-not-allowed opacity-50",
|
||||
"font-semibold",
|
||||
@@ -518,7 +518,7 @@ export function MultiSelectSelectAll({
|
||||
type="button"
|
||||
data-slot="multiselect-select-all"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary hover:bg-border-neutral-secondary flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
allSelected && "cursor-not-allowed opacity-50",
|
||||
"font-semibold",
|
||||
className,
|
||||
|
||||
@@ -74,8 +74,8 @@ describe("Select", () => {
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:hover:bg-button-tertiary/15",
|
||||
);
|
||||
expect(selectedItem).toHaveClass("hover:bg-slate-200");
|
||||
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
|
||||
// Shared highlight: same neutral gray as menu items.
|
||||
expect(selectedItem).toHaveClass("hover:bg-border-neutral-secondary");
|
||||
expect(
|
||||
within(selectedItem).queryByRole("img", { hidden: true }),
|
||||
).toBeNull();
|
||||
|
||||
@@ -178,7 +178,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-4 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-border-neutral-secondary focus:text-text-neutral-primary [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 hover:bg-border-neutral-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-4 pl-4 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -45,7 +45,9 @@ export function SidePanelHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-neutral-secondary flex items-center gap-1 border-b px-3 py-2",
|
||||
// min-h = py-2 + 44px tab row + border-b, so the header keeps the same
|
||||
// height (and stays aligned with the navbar) when a panel has no tabs.
|
||||
"border-border-neutral-secondary flex min-h-[61px] items-center gap-1 border-b px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -259,6 +259,9 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div
|
||||
// Expanded sub-rows pin themselves to this scrollport (sticky left) so
|
||||
// horizontal scrolling moves the group columns, not the expanded panel.
|
||||
data-table-scroll-container=""
|
||||
className={cn(
|
||||
"minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto rounded-[14px] border p-4 shadow-sm transition-opacity duration-200",
|
||||
isPending && "pointer-events-none opacity-60",
|
||||
|
||||
@@ -28,7 +28,13 @@ vi.mock(
|
||||
);
|
||||
|
||||
// Mimics a table host: local open state, detail content as children.
|
||||
function Host({ initialOpen = true }: { initialOpen?: boolean }) {
|
||||
function Host({
|
||||
initialOpen = true,
|
||||
selectTabOnOpen,
|
||||
}: {
|
||||
initialOpen?: boolean;
|
||||
selectTabOnOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
return (
|
||||
<>
|
||||
@@ -39,6 +45,7 @@ function Host({ initialOpen = true }: { initialOpen?: boolean }) {
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
selectTabOnOpen={selectTabOnOpen}
|
||||
title="Resource Details"
|
||||
description="View the resource details"
|
||||
context={{
|
||||
@@ -166,6 +173,26 @@ describe("DetailSidePanel", () => {
|
||||
expect(useSidePanelStore.getState().isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the AI tab in front when opened with selectTabOnOpen: false", async () => {
|
||||
// Given: a skill launch selected the AI tab before the drawer mounted
|
||||
useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
|
||||
// When: the detail registers without stealing the selection
|
||||
render(<Host selectTabOnOpen={false} />);
|
||||
await screen.findByTestId("detail-content");
|
||||
|
||||
// Then: chat stays selected, Details is available in the background
|
||||
expect(screen.getByRole("tab", { name: "Lighthouse AI" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Details" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByTestId("detail-content")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("clears the host selection when the panel is dismissed", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -15,6 +15,10 @@ interface DetailSidePanelProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
context?: LighthouseContextItem;
|
||||
// false registers the Details tab without selecting it, so an opener that
|
||||
// already selected another tab (e.g. a skill launch into the AI chat)
|
||||
// keeps that tab in front.
|
||||
selectTabOnOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -38,6 +42,7 @@ function DetailSidePanelActive({
|
||||
title,
|
||||
description,
|
||||
context,
|
||||
selectTabOnOpen = true,
|
||||
children,
|
||||
}: Omit<DetailSidePanelProps, "open">) {
|
||||
// Owner token from registration: several detail views can be mounted at
|
||||
@@ -45,12 +50,15 @@ function DetailSidePanelActive({
|
||||
const [token, setToken] = useState<number | null>(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),
|
||||
});
|
||||
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),
|
||||
},
|
||||
{ select: selectTabOnOpen },
|
||||
);
|
||||
useLighthouseContextStore
|
||||
.getState()
|
||||
.setFocusedContext(registered, context ?? null);
|
||||
|
||||
Reference in New Issue
Block a user