feat(ui): add Lighthouse AI Skills on findings (#12355)

This commit is contained in:
Alejandro Bailo
2026-08-11 11:05:04 +02:00
committed by GitHub
parent 85c36bb812
commit a8b12813f9
74 changed files with 3983 additions and 235 deletions
@@ -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>
);
}
@@ -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", () => {
@@ -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>
@@ -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
+2
View File
@@ -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">
+3
View File
@@ -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",
+3
View File
@@ -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: "",
+1 -1
View File
@@ -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: "",
+2 -2
View File
@@ -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}
+30 -7
View File
@@ -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();
});
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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();
+14 -6
View File
@@ -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);