diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx
index 23bc24b4b9..aeb12998fd 100644
--- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.test.tsx
@@ -105,6 +105,15 @@ describe("MessageBubble", () => {
label: "Findings",
path: "/findings",
},
+ {
+ kind: "finding",
+ id: "finding-1",
+ source: "focused",
+ scope_key: "findings:/findings",
+ label: "Focused finding",
+ finding_id: "finding-1",
+ check_id: "aws_s3_bucket_public_access",
+ },
],
},
},
@@ -119,7 +128,7 @@ describe("MessageBubble", () => {
render();
// Then
- expect(screen.getByText("@ Findings")).toBeInTheDocument();
+ expect(screen.getByText("@ Findings +1")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Remove Findings context/ }),
).not.toBeInTheDocument();
diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
index d75a164061..e46daa6516 100644
--- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx
@@ -11,6 +11,11 @@ import type {
LighthouseV2Session,
LighthouseV2SupportedModel,
} from "@/app/(prowler)/lighthouse/_types";
+import {
+ buildAttackPathContext,
+ buildFocusedFindingContext,
+} from "@/lib/lighthouse/context/contributions";
+import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import {
LighthousePanelChat,
@@ -101,6 +106,7 @@ describe("LighthousePanelChat", () => {
stubEventSource();
resetPanelChatStoreForTests();
resetPanelChatConfigCacheForTests();
+ useLighthouseContextStore.getState().resetContributions();
getConfigurationsMock.mockResolvedValue({ data: configurations });
getSupportedProvidersMock.mockResolvedValue({
@@ -243,6 +249,82 @@ describe("LighthousePanelChat", () => {
);
});
+ it("sends page, focused finding, and parent Attack Path context together", async () => {
+ // Given
+ const user = userEvent.setup();
+ window.history.replaceState(null, "", "/attack-paths?scanId=scan-1");
+ const contextStore = useLighthouseContextStore.getState();
+ contextStore.registerContribution(
+ "attack-path-current",
+ buildAttackPathContext({
+ pathname: "/attack-paths",
+ scanId: "scan-1",
+ queryId: "query-1",
+ queryLabel: "Internet-exposed resources",
+ }),
+ );
+ contextStore.setFocusedContext(
+ 1,
+ buildFocusedFindingContext({
+ pathname: "/attack-paths",
+ findingId: "finding-1",
+ checkId: "aws_s3_bucket_public_access",
+ severity: "critical",
+ status: "FAIL",
+ providerUid: "123456789012",
+ resourceUid: "arn:aws:s3:::example",
+ region: "eu-west-1",
+ }),
+ );
+ createSessionMock.mockResolvedValue({
+ data: session("session-context", "Explain this finding"),
+ });
+ sendMessageMock.mockResolvedValue({
+ data: {
+ task: {
+ id: "task-context",
+ name: "lighthouse-run",
+ state: "executing",
+ },
+ },
+ });
+ render();
+ const input = await screen.findByRole("textbox", { name: "Message" });
+ expect(screen.getByText("@ Attack Paths +1")).toBeInTheDocument();
+
+ // When
+ await user.type(input, "Explain this finding{Enter}");
+
+ // Then
+ await waitFor(() =>
+ expect(sendMessageMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ displayText: "Explain this finding",
+ context: expect.objectContaining({
+ items: [
+ expect.objectContaining({
+ kind: "page",
+ id: "attack-paths",
+ filters: { scanId: ["scan-1"] },
+ }),
+ expect.objectContaining({
+ kind: "finding",
+ id: "finding-1",
+ source: "focused",
+ }),
+ expect.objectContaining({
+ kind: "attack_path",
+ id: "current-query",
+ scanId: "scan-1",
+ queryId: "query-1",
+ }),
+ ],
+ }),
+ }),
+ ),
+ );
+ });
+
it("opens a recent chat in place without navigating", async () => {
// Given
const user = userEvent.setup();
diff --git a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts
index 024e57f584..8b4512ebb4 100644
--- a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts
+++ b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts
@@ -162,9 +162,9 @@ describe("createLighthouseChatStore", () => {
it("retries with the original context snapshot", async () => {
// Given
const store = makeStore();
- const context = findingsContext();
+ const context = focusedFindingsContext();
await store.getState().submitMessage("Prioritize findings", context);
- context.items[0].label = "Mutated after send";
+ context.items[1].label = "Mutated after send";
eventSources[0].fail(2 /* EventSource.CLOSED */);
sendMessageMock.mockResolvedValueOnce({
data: {
@@ -179,7 +179,7 @@ describe("createLighthouseChatStore", () => {
expect(sendMessageMock).toHaveBeenNthCalledWith(2, {
sessionId: "session-1",
displayText: "Prioritize findings",
- context: findingsContext(),
+ context: focusedFindingsContext(),
provider: "openai",
model: "gpt-5.1",
});
@@ -596,6 +596,25 @@ function findingsContext(): LighthouseContextEnvelope {
};
}
+function focusedFindingsContext(): LighthouseContextEnvelope {
+ const context = findingsContext();
+ return {
+ ...context,
+ items: [
+ ...context.items,
+ {
+ kind: "finding",
+ id: "finding-1",
+ source: "focused",
+ scopeKey: "findings:/findings",
+ label: "Focused finding",
+ findingId: "finding-1",
+ checkId: "aws_s3_bucket_public_access",
+ },
+ ],
+ };
+}
+
function oversizedFindingsContext(): LighthouseContextEnvelope {
const context = findingsContext();
return {
diff --git a/ui/changelog.d/lighthouse-contextual-pages.added.md b/ui/changelog.d/lighthouse-contextual-pages.added.md
index 9784ad6d9e..cec94a52c3 100644
--- a/ui/changelog.d/lighthouse-contextual-pages.added.md
+++ b/ui/changelog.d/lighthouse-contextual-pages.added.md
@@ -1 +1 @@
-Lighthouse AI contextual messages with page-aware prompts, removable context chips, selected-resource metadata, and retry-safe historical badges
+Lighthouse AI contextual messages with page-aware prompts, focused side-panel details, selected-resource metadata, and retry-safe historical badges
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx
new file mode 100644
index 0000000000..6e9356e229
--- /dev/null
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx
@@ -0,0 +1,130 @@
+import { render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { FindingResourceRow } from "@/types";
+
+import { ResourceDetailDrawer } from "./resource-detail-drawer";
+
+const { pathnameMock } = vi.hoisted(() => ({
+ pathnameMock: vi.fn(() => "/findings"),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: pathnameMock,
+}));
+
+vi.mock("@/components/side-panel/detail-side-panel", () => ({
+ DetailSidePanel: ({
+ context,
+ children,
+ }: {
+ context?: unknown;
+ children: ReactNode;
+ }) => (
+ <>
+
+ {children}
+ >
+ ),
+}));
+
+vi.mock("./resource-detail-drawer-content", () => ({
+ ResourceDetailDrawerContent: () =>
Finding details
,
+}));
+
+describe("ResourceDetailDrawer", () => {
+ beforeEach(() => {
+ pathnameMock.mockReturnValue("/findings");
+ });
+
+ it("should update focused finding context when drawer navigation changes", () => {
+ // Given
+ const firstFinding = findingResource("finding-1", "bucket-1");
+ const secondFinding = findingResource("finding-2", "bucket-2");
+ const { rerender } = renderDrawer(firstFinding);
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"findingId":"finding-1"',
+ );
+
+ // When
+ rerender(drawer(secondFinding));
+
+ // Then
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"findingId":"finding-2"',
+ );
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"resourceUid":"bucket-2"',
+ );
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"source":"focused"',
+ );
+ });
+
+ it("should scope a finding opened from an Attack Paths node", () => {
+ // Given
+ pathnameMock.mockReturnValue("/attack-paths");
+
+ // When
+ renderDrawer(findingResource("finding-attack-path", "bucket-attack-path"));
+
+ // Then
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"scopeKey":"attack-paths:/attack-paths"',
+ );
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"findingId":"finding-attack-path"',
+ );
+ });
+});
+
+function renderDrawer(currentResource: FindingResourceRow) {
+ return render(drawer(currentResource));
+}
+
+function drawer(currentResource: FindingResourceRow) {
+ return (
+
+ );
+}
+
+function findingResource(
+ findingId: string,
+ resourceUid: string,
+): FindingResourceRow {
+ return {
+ id: findingId,
+ rowType: "resource",
+ findingId,
+ checkId: "aws_s3_bucket_public_access",
+ providerType: "aws",
+ providerAlias: "Production",
+ providerUid: "123456789012",
+ resourceName: resourceUid,
+ resourceType: "AwsS3Bucket",
+ resourceGroup: "storage",
+ resourceUid,
+ service: "s3",
+ region: "eu-west-1",
+ severity: "critical",
+ status: "FAIL",
+ isMuted: false,
+ firstSeenAt: null,
+ lastSeenAt: null,
+ };
+}
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx
index c2c4629d63..adb132bbfe 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx
@@ -1,7 +1,10 @@
"use client";
+import { usePathname } from "next/navigation";
+
import type { ResourceDrawerFinding } from "@/actions/findings";
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
+import { buildFocusedFindingContext } from "@/lib/lighthouse/context/contributions";
import type { FindingResourceRow } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
@@ -43,12 +46,27 @@ export function ResourceDetailDrawer({
onMuteComplete,
onTriageUpdate,
}: ResourceDetailDrawerProps) {
+ const pathname = usePathname();
+ const context = currentResource
+ ? buildFocusedFindingContext({
+ pathname,
+ findingId: currentFinding?.id ?? currentResource.findingId,
+ checkId: currentFinding?.checkId ?? currentResource.checkId,
+ severity: currentFinding?.severity ?? currentResource.severity,
+ status: currentFinding?.status ?? currentResource.status,
+ providerUid: currentFinding?.providerUid ?? currentResource.providerUid,
+ resourceUid: currentFinding?.resourceUid ?? currentResource.resourceUid,
+ region: currentFinding?.resourceRegion ?? currentResource.region,
+ })
+ : undefined;
+
return (
{
await user.hover(contextBadge);
// Then
- expect(contextBadge).toHaveTextContent("@ Findings +1");
expect(
screen.queryByRole("button", { name: /Findings context/ }),
).not.toBeInTheDocument();
- expect(await screen.findByRole("tooltip")).toHaveTextContent(
- "Findings context will be included in your next message.",
+ const tooltip = await screen.findByRole("tooltip");
+ expect(contextBadge).toHaveTextContent("@ Findings +2");
+ expect(tooltip).toHaveTextContent("Filters: severity: critical");
+ expect(tooltip).toHaveTextContent("Finding: finding-focused");
+ expect(tooltip).toHaveTextContent("Finding: finding-1");
+ });
+
+ it.each([
+ ["resource", resourceContext(), "Resource: resource-1 (bucket-1)"],
+ ["scan", scanContext(), "Scan: scan-1"],
+ ["Attack Path", attackPathContext(), "Attack Path: query-1 (scan scan-1)"],
+ ])("should identify included %s context", async (_, context, expected) => {
+ // Given
+ const user = userEvent.setup();
+ render();
+
+ // When
+ await user.hover(
+ screen.getByLabelText(`${context.items[0].label} context`),
);
+
+ // Then
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(expected);
});
});
@@ -36,7 +55,7 @@ describe("LighthouseContextBadge", () => {
render();
// Then
- expect(screen.getByText("@ Findings +1")).toBeInTheDocument();
+ expect(screen.getByText("@ Findings +2")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Findings context/ }),
).not.toBeInTheDocument();
@@ -65,6 +84,94 @@ function findingsContext(): LighthouseContextEnvelope {
label: "Selected finding",
findingId: "finding-1",
},
+ {
+ kind: "finding",
+ id: "finding-focused",
+ source: "focused",
+ scopeKey: "findings:/findings",
+ label: "Focused finding",
+ findingId: "finding-focused",
+ checkId: "aws_s3_bucket_public_access",
+ },
+ ],
+ };
+}
+
+function resourceContext(): LighthouseContextEnvelope {
+ return {
+ schemaVersion: 1,
+ transport: "inline",
+ items: [
+ {
+ kind: "page",
+ id: "resources",
+ source: "automatic",
+ scopeKey: "resources:/resources",
+ label: "Resources",
+ path: "/resources",
+ },
+ {
+ kind: "resource",
+ id: "resource-1",
+ source: "focused",
+ scopeKey: "resources:/resources",
+ label: "Focused resource",
+ resourceId: "resource-1",
+ resourceUid: "bucket-1",
+ },
+ ],
+ };
+}
+
+function scanContext(): LighthouseContextEnvelope {
+ return {
+ schemaVersion: 1,
+ transport: "inline",
+ items: [
+ {
+ kind: "page",
+ id: "scans",
+ source: "automatic",
+ scopeKey: "scans:/scans",
+ label: "Scans",
+ path: "/scans",
+ filters: { scanId: ["scan-1"] },
+ },
+ {
+ kind: "scan",
+ id: "scan-1",
+ source: "selection",
+ scopeKey: "scans:/scans",
+ label: "Selected scan",
+ scanId: "scan-1",
+ },
+ ],
+ };
+}
+
+function attackPathContext(): LighthouseContextEnvelope {
+ return {
+ schemaVersion: 1,
+ transport: "inline",
+ items: [
+ {
+ kind: "page",
+ id: "attack-paths",
+ source: "automatic",
+ scopeKey: "attack-paths:/attack-paths",
+ label: "Attack Paths",
+ path: "/attack-paths",
+ filters: { scanId: ["scan-1"] },
+ },
+ {
+ kind: "attack_path",
+ id: "current-query",
+ source: "automatic",
+ scopeKey: "attack-paths:/attack-paths",
+ label: "Internet-exposed resources",
+ scanId: "scan-1",
+ queryId: "query-1",
+ },
],
};
}
diff --git a/ui/components/lighthouse/context-chip.tsx b/ui/components/lighthouse/context-chip.tsx
index 0909138dc8..f3a9518c04 100644
--- a/ui/components/lighthouse/context-chip.tsx
+++ b/ui/components/lighthouse/context-chip.tsx
@@ -10,6 +10,7 @@ import {
LIGHTHOUSE_CONTEXT_KIND,
LIGHTHOUSE_CONTEXT_SOURCE,
type LighthouseContextEnvelope,
+ type LighthouseContextItem,
} from "@/types/lighthouse-context";
interface LighthouseCurrentContextBadgeProps {
@@ -20,20 +21,18 @@ export function LighthouseCurrentContextBadge({
context,
}: LighthouseCurrentContextBadgeProps) {
if (!context) return null;
- const { pageLabel, selectionCount } = getContextBadgeContent(context);
+ const { pageLabel, additionalCount } = getContextBadgeContent(context);
return (
- {buildContextLabel(pageLabel, selectionCount)}
+ {buildContextLabel(pageLabel, additionalCount)}
-
- {pageLabel} context will be included in your next message.
-
+
);
}
@@ -43,14 +42,14 @@ export function LighthouseContextBadge({
}: {
context: LighthouseContextEnvelope;
}) {
- const { pageLabel, selectionCount } = getContextBadgeContent(context);
+ const { pageLabel, additionalCount } = getContextBadgeContent(context);
return (
- {buildContextLabel(pageLabel, selectionCount)}
+ {buildContextLabel(pageLabel, additionalCount)}
@@ -64,11 +63,13 @@ function getContextBadgeContent(context: LighthouseContextEnvelope) {
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE,
);
const pageLabel = page?.label ?? "Context";
- const selectionCount = context.items.filter(
- (item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
+ const additionalCount = context.items.filter(
+ (item) =>
+ item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED ||
+ item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
).length;
- return { pageLabel, selectionCount };
+ return { pageLabel, additionalCount };
}
function LighthouseContextTooltip({
@@ -85,29 +86,79 @@ function LighthouseContextTooltip({
.map(([key, values]) => `${key}: ${values.join(", ")}`)
.join("; ")
: "";
- const summaries = context.items
- .filter(
- (item) =>
- item.kind !== LIGHTHOUSE_CONTEXT_KIND.PAGE &&
- item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC,
- )
- .map((item) => item.label)
- .join(", ");
- const types = Array.from(
- new Set(context.items.map((item) => item.kind)),
- ).join(", ");
+ const itemDescriptions = context.items
+ .map(getContextItemDescription)
+ .filter((description) => description !== null);
return (
+ {page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE && (
+
Page: {page.path}
+ )}
{filters &&
Filters: {filters}
}
- {summaries &&
Summaries: {summaries}
}
-
Included types: {types}
+ {itemDescriptions.map(({ id, text }) => (
+
{text}
+ ))}
);
}
-function buildContextLabel(pageLabel: string, selectionCount: number): string {
- return `@ ${pageLabel}${selectionCount > 0 ? ` +${selectionCount}` : ""}`;
+interface ContextItemDescription {
+ id: string;
+ text: string;
+}
+
+function getContextItemDescription(
+ item: LighthouseContextItem,
+): ContextItemDescription | null {
+ if (item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE) return null;
+ if (
+ item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC &&
+ item.id === "summary"
+ ) {
+ return { id: `${item.kind}:${item.id}`, text: `Summary: ${item.label}` };
+ }
+
+ switch (item.kind) {
+ case LIGHTHOUSE_CONTEXT_KIND.FINDING:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Finding: ${item.findingId}${item.checkId ? ` (${item.checkId})` : ""}`,
+ };
+ case LIGHTHOUSE_CONTEXT_KIND.RESOURCE:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Resource: ${item.resourceId}${item.resourceUid ? ` (${item.resourceUid})` : ""}`,
+ };
+ case LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Compliance: ${item.framework}${item.scanId ? ` (scan ${item.scanId})` : ""}`,
+ };
+ case LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Attack Path: ${item.queryId ?? item.id}${item.scanId ? ` (scan ${item.scanId})` : ""}`,
+ };
+ case LIGHTHOUSE_CONTEXT_KIND.SCAN:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Scan: ${item.scanId ?? item.id}`,
+ };
+ case LIGHTHOUSE_CONTEXT_KIND.PROVIDER:
+ return {
+ id: `${item.kind}:${item.id}`,
+ text: `Provider: ${item.providerUid ?? item.providerId ?? item.id}`,
+ };
+ default: {
+ const exhaustiveItem: never = item;
+ return exhaustiveItem;
+ }
+ }
+}
+
+function buildContextLabel(pageLabel: string, additionalCount: number): string {
+ return `@ ${pageLabel}${additionalCount > 0 ? ` +${additionalCount}` : ""}`;
}
diff --git a/ui/components/resources/resource-details-sheet.tsx b/ui/components/resources/resource-details-sheet.tsx
index c0603d9124..3fe5006723 100644
--- a/ui/components/resources/resource-details-sheet.tsx
+++ b/ui/components/resources/resource-details-sheet.tsx
@@ -1,7 +1,10 @@
"use client";
+import { usePathname } from "next/navigation";
+
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
-import { ResourceProps } from "@/types";
+import { buildFocusedResourceContext } from "@/lib/lighthouse/context/contributions";
+import type { ResourceProps } from "@/types";
import { ResourceDetailContent } from "./table/resource-detail-content";
@@ -16,12 +19,21 @@ export const ResourceDetailsSheet = ({
open,
onOpenChange,
}: ResourceDetailsSheetProps) => {
+ const pathname = usePathname();
+ const context = buildFocusedResourceContext({
+ pathname,
+ id: resource.id,
+ attributes: resource.attributes,
+ providerUid: resource.relationships.provider.data.attributes.uid,
+ });
+
return (
diff --git a/ui/components/resources/table/resources-table-with-selection.test.tsx b/ui/components/resources/table/resources-table-with-selection.test.tsx
index b4b9d36d39..09d2c63326 100644
--- a/ui/components/resources/table/resources-table-with-selection.test.tsx
+++ b/ui/components/resources/table/resources-table-with-selection.test.tsx
@@ -1,4 +1,6 @@
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ResourceProps } from "@/types";
@@ -19,8 +21,27 @@ vi.mock("@/components/shadcn/table", () => ({
),
}));
-vi.mock("@/components/resources/resource-details-sheet", () => ({
- ResourceDetailsSheet: () => Resource details
,
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/resources",
+}));
+
+vi.mock("@/components/side-panel/detail-side-panel", () => ({
+ DetailSidePanel: ({
+ context,
+ children,
+ }: {
+ context?: unknown;
+ children: ReactNode;
+ }) => (
+ <>
+
+ {children}
+ >
+ ),
+}));
+
+vi.mock("./resource-detail-content", () => ({
+ ResourceDetailContent: () => Resource details
,
}));
vi.mock("@/components/lighthouse/context-contributor", () => ({
@@ -38,21 +59,54 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
}));
const resource = {
+ type: "resources",
id: "resource-1",
attributes: {
+ inserted_at: "2026-07-22T10:00:00Z",
+ updated_at: "2026-07-22T10:00:00Z",
uid: "arn:aws:s3:::example",
+ name: "example",
service: "s3",
region: "eu-west-1",
type: "AwsS3Bucket",
+ groups: ["storage"],
failed_findings_count: 3,
+ details: "full configuration must stay local",
+ partition: "aws",
+ tags: { owner: "security@example.com" },
+ metadata: { secret: "do-not-send" },
},
relationships: {
- provider: { data: { attributes: { uid: "123456789012" } } },
+ provider: {
+ data: {
+ type: "providers",
+ id: "provider-1",
+ attributes: {
+ inserted_at: "2026-07-22T10:00:00Z",
+ updated_at: "2026-07-22T10:00:00Z",
+ provider: "aws",
+ uid: "123456789012",
+ alias: "Production",
+ connection: {
+ connected: true,
+ last_checked_at: "2026-07-22T10:00:00Z",
+ },
+ },
+ relationships: {
+ secret: { data: { type: "provider-secrets", id: "secret-1" } },
+ },
+ links: { self: "/providers/provider-1" },
+ },
+ },
+ findings: { meta: { count: 0 }, data: [] },
},
-} as ResourceProps;
+ links: { self: "/resources/resource-1" },
+} satisfies ResourceProps;
describe("ResourcesTableWithSelection", () => {
- it("publishes the loaded total and selected resource as context", async () => {
+ it("publishes the loaded total and opens the selected resource detail", async () => {
+ // Given
+ const user = userEvent.setup();
render(
{
'"total":17',
);
- screen.getByRole("button", { name: "Open resource" }).click();
+ // When
+ await user.click(screen.getByRole("button", { name: "Open resource" }));
+ // Then
+ expect(screen.getByText("Resource details")).toBeInTheDocument();
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"resourceId":"resource-1"',
+ );
+ expect(screen.getByTestId("focused-context")).toHaveTextContent(
+ '"providerUid":"123456789012"',
+ );
+ expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
+ "full configuration must stay local",
+ );
+ expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
+ "security@example.com",
+ );
+ expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
+ "do-not-send",
+ );
expect(
- await screen.findByTestId("context-resource-resource-1"),
- ).toHaveTextContent('"providerUid":"123456789012"');
+ screen.queryByTestId("context-resource-resource-1"),
+ ).not.toBeInTheDocument();
});
});
diff --git a/ui/components/resources/table/resources-table-with-selection.tsx b/ui/components/resources/table/resources-table-with-selection.tsx
index cff4f72e2e..473ea48442 100644
--- a/ui/components/resources/table/resources-table-with-selection.tsx
+++ b/ui/components/resources/table/resources-table-with-selection.tsx
@@ -5,10 +5,7 @@ import { useState } from "react";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
import { DataTable } from "@/components/shadcn/table";
-import {
- buildResourceContext,
- buildResourceSummaryContext,
-} from "@/lib/lighthouse/context/contributions";
+import { buildResourceSummaryContext } from "@/lib/lighthouse/context/contributions";
import { MetaDataProps, ResourceProps } from "@/types";
import { getColumnResources } from "./column-resources";
@@ -46,18 +43,6 @@ export function ResourcesTableWithSelection({
item={buildResourceSummaryContext(metadata.pagination.count)}
/>
)}
- {selectedResource && (
-
- )}
detail body
@@ -61,10 +70,34 @@ function DualHost() {
Open B
-
+
A body
-
+
B body
@@ -73,6 +106,34 @@ function DualHost() {
);
}
+function NavigatingHost() {
+ const [findingId, setFindingId] = useState("finding-1");
+
+ return (
+ <>
+
+
+
+ {findingId}
+
+ >
+ );
+}
+
describe("DetailSidePanel", () => {
beforeEach(() => {
isCloudMock.mockReturnValue(true);
@@ -85,6 +146,7 @@ describe("DetailSidePanel", () => {
contextOwnerToken: 0,
contextOutlet: null,
});
+ useLighthouseContextStore.getState().resetContributions();
});
it("portals the detail content into the global panel when open", async () => {
@@ -131,6 +193,13 @@ describe("DetailSidePanel", () => {
expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
expect(screen.getByTestId("detail-content")).toBeInTheDocument();
expect(screen.getByTestId("detail-content")).not.toBeVisible();
+ expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1");
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Close side panel" }));
+
+ // Then
+ expect(useLighthouseContextStore.getState().focused).toBeNull();
});
it("hands the panel to the newest detail view and closes the previous one", async () => {
@@ -151,6 +220,7 @@ describe("DetailSidePanel", () => {
expect(screen.queryByTestId("detail-a")).not.toBeInTheDocument();
expect(screen.getByTestId("open-a")).toHaveTextContent("false");
expect(screen.getByTestId("open-b")).toHaveTextContent("true");
+ expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-b");
// When: the panel is dismissed
await user.click(screen.getByRole("button", { name: "Close side panel" }));
@@ -159,6 +229,7 @@ describe("DetailSidePanel", () => {
expect(screen.getByTestId("open-b")).toHaveTextContent("false");
expect(screen.queryByTestId("detail-b")).not.toBeInTheDocument();
expect(useSidePanelStore.getState().contextTab).toBeNull();
+ expect(useLighthouseContextStore.getState().focused).toBeNull();
});
it("registers nothing while closed and registers on open", async () => {
@@ -174,4 +245,21 @@ describe("DetailSidePanel", () => {
expect(await screen.findByTestId("detail-content")).toBeInTheDocument();
expect(useSidePanelStore.getState().contextTab?.label).toBe("Details");
});
+
+ it("updates focused context while navigating inside the current drawer", async () => {
+ // Given
+ const user = userEvent.setup();
+ render();
+ await screen.findByText("finding-1");
+ expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1");
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Next finding" }));
+
+ // Then
+ expect(screen.getByTestId("navigating-detail")).toHaveTextContent(
+ "finding-2",
+ );
+ expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-2");
+ });
});
diff --git a/ui/components/side-panel/detail-side-panel.tsx b/ui/components/side-panel/detail-side-panel.tsx
index c7d30aaa4c..230fe42ea2 100644
--- a/ui/components/side-panel/detail-side-panel.tsx
+++ b/ui/components/side-panel/detail-side-panel.tsx
@@ -4,7 +4,9 @@ import { type ReactNode, useState } from "react";
import { createPortal } from "react-dom";
import { useMountEffect } from "@/hooks/use-mount-effect";
+import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { useSidePanelStore } from "@/store/side-panel";
+import type { LighthouseContextItem } from "@/types/lighthouse-context";
interface DetailSidePanelProps {
open: boolean;
@@ -12,6 +14,7 @@ interface DetailSidePanelProps {
// Screen-reader heading; the visible tab label is always "Details".
title: string;
description?: string;
+ context?: LighthouseContextItem;
children: ReactNode;
}
@@ -34,6 +37,7 @@ function DetailSidePanelActive({
onOpenChange,
title,
description,
+ context,
children,
}: Omit) {
// Owner token from registration: several detail views can be mounted at
@@ -47,20 +51,60 @@ function DetailSidePanelActive({
// and every consumer's close path ends in stable setters.
onRequestClose: () => onOpenChange(false),
});
+ useLighthouseContextStore
+ .getState()
+ .setFocusedContext(registered, context ?? null);
setToken(registered);
- return () => useSidePanelStore.getState().unregisterContextTab(registered);
+ return () => {
+ useLighthouseContextStore.getState().clearFocusedContext(registered);
+ useSidePanelStore.getState().unregisterContextTab(registered);
+ };
});
const ownerToken = useSidePanelStore((state) => state.contextOwnerToken);
const outlet = useSidePanelStore((state) => state.contextOutlet);
- if (!outlet || token === null || token !== ownerToken) return null;
+ const focusedRegistration =
+ context && token !== null ? (
+
+ ) : null;
- return createPortal(
-
-
{title}
- {description ?
{description}
: null}
- {children}
-
,
- outlet,
+ if (!outlet || token === null || token !== ownerToken) {
+ return focusedRegistration;
+ }
+
+ return (
+ <>
+ {focusedRegistration}
+ {createPortal(
+
+
{title}
+ {description ?
{description}
: null}
+ {children}
+
,
+ outlet,
+ )}
+ >
);
}
+
+interface FocusedContextRegistrationProps {
+ ownerToken: number;
+ context: LighthouseContextItem;
+}
+
+function FocusedContextRegistration({
+ ownerToken,
+ context,
+}: FocusedContextRegistrationProps) {
+ useMountEffect(() => {
+ useLighthouseContextStore.getState().setFocusedContext(ownerToken, context);
+ return () =>
+ useLighthouseContextStore.getState().clearFocusedContext(ownerToken);
+ });
+
+ return null;
+}
diff --git a/ui/hooks/use-lighthouse-context.test.ts b/ui/hooks/use-lighthouse-context.test.ts
index f07ffd57a0..a612416d56 100644
--- a/ui/hooks/use-lighthouse-context.test.ts
+++ b/ui/hooks/use-lighthouse-context.test.ts
@@ -40,4 +40,46 @@ describe("buildCurrentLighthouseContext", () => {
expect(current.page.label).toBe("Findings");
expect(current.selectionCount).toBe(0);
});
+
+ it("should combine the page, focused detail, and parent attack path", () => {
+ // Given
+ const parentContext = {
+ kind: "attack_path" as const,
+ id: "current-query",
+ source: "automatic" as const,
+ scopeKey: "attack-paths:/attack-paths",
+ label: "Internet-exposed resources",
+ scanId: "scan-1",
+ queryId: "query-1",
+ };
+ const focusedContext = {
+ kind: "finding" as const,
+ id: "finding-1",
+ source: "focused" as const,
+ scopeKey: "attack-paths:/attack-paths",
+ label: "Focused finding",
+ findingId: "finding-1",
+ checkId: "aws_s3_bucket_public_access",
+ };
+
+ // When
+ const current = buildCurrentLighthouseContext(
+ "/attack-paths",
+ new URLSearchParams("scanId=scan-1"),
+ [parentContext],
+ focusedContext,
+ );
+
+ // Then
+ expect(current.context?.items.map((item) => item.id)).toEqual([
+ "attack-paths",
+ "finding-1",
+ "current-query",
+ ]);
+ expect(current.context?.items[0]).toMatchObject({
+ kind: "page",
+ filters: { scanId: ["scan-1"] },
+ });
+ expect(current.selectionCount).toBe(1);
+ });
});
diff --git a/ui/hooks/use-lighthouse-context.ts b/ui/hooks/use-lighthouse-context.ts
index c4c0dd3042..337126ee45 100644
--- a/ui/hooks/use-lighthouse-context.ts
+++ b/ui/hooks/use-lighthouse-context.ts
@@ -29,11 +29,13 @@ export function useLighthouseCurrentContext(): LighthouseCurrentContext {
const contributions = useLighthouseContextStore(
(state) => state.contributions,
);
+ const focused = useLighthouseContextStore((state) => state.focused);
return buildCurrentLighthouseContext(
pathname,
new URLSearchParams(searchParams.toString()),
Object.values(contributions),
+ focused ?? undefined,
);
}
@@ -41,6 +43,7 @@ export function buildCurrentLighthouseContext(
pathname: string,
searchParams: URLSearchParams,
contributions: LighthouseContextItem[],
+ focused?: LighthouseContextItem,
): LighthouseCurrentContext {
const page = resolveLighthousePage(pathname);
const scopeKey = getLighthouseScopeKey(pathname);
@@ -49,7 +52,7 @@ export function buildCurrentLighthouseContext(
(item) => item.scopeKey === scopeKey,
);
const context = compileLighthouseContext(
- [pageContext, ...scopedContributions],
+ [pageContext, ...(focused ? [focused] : []), ...scopedContributions],
scopeKey,
);
@@ -59,7 +62,9 @@ export function buildCurrentLighthouseContext(
scopeKey,
selectionCount:
context?.items.filter(
- (item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
+ (item) =>
+ item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED ||
+ item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
).length ?? 0,
};
}
diff --git a/ui/lib/lighthouse/context/contributions.test.ts b/ui/lib/lighthouse/context/contributions.test.ts
index 68e9b30d24..0d407bde6f 100644
--- a/ui/lib/lighthouse/context/contributions.test.ts
+++ b/ui/lib/lighthouse/context/contributions.test.ts
@@ -6,6 +6,8 @@ import {
buildFindingGroupContext,
buildFindingResourceContext,
buildFindingSummaryContext,
+ buildFocusedFindingContext,
+ buildFocusedResourceContext,
buildProviderContext,
buildProviderSummaryContext,
buildResourceContext,
@@ -69,6 +71,36 @@ describe("Lighthouse page contributions", () => {
});
});
+ it("builds a focused finding for the owning page scope", () => {
+ // Given / When
+ const context = buildFocusedFindingContext({
+ pathname: "/attack-paths",
+ findingId: "finding-2",
+ checkId: "aws_s3_bucket_public_access",
+ severity: "critical",
+ status: "FAIL",
+ providerUid: "123456789012",
+ resourceUid: "arn:aws:s3:::example",
+ region: "eu-west-1",
+ });
+
+ // Then
+ expect(context).toEqual({
+ kind: "finding",
+ id: "finding-2",
+ source: "focused",
+ scopeKey: "attack-paths:/attack-paths",
+ label: "Focused finding",
+ findingId: "finding-2",
+ checkId: "aws_s3_bucket_public_access",
+ severity: "critical",
+ status: "FAIL",
+ providerUid: "123456789012",
+ resourceUid: "arn:aws:s3:::example",
+ region: "eu-west-1",
+ });
+ });
+
it("builds resource summary and selected resource snapshots", () => {
expect(buildResourceSummaryContext(17)).toMatchObject({
kind: "resource",
@@ -107,6 +139,38 @@ describe("Lighthouse page contributions", () => {
});
});
+ it("builds a focused resource for the owning page scope", () => {
+ // Given / When
+ const context = buildFocusedResourceContext({
+ pathname: "/resources",
+ id: "resource-1",
+ attributes: {
+ uid: "arn:aws:s3:::example",
+ service: "s3",
+ region: "eu-west-1",
+ type: "AwsS3Bucket",
+ failed_findings_count: 3,
+ },
+ providerUid: "123456789012",
+ });
+
+ // Then
+ expect(context).toEqual({
+ kind: "resource",
+ id: "resource-1",
+ source: "focused",
+ scopeKey: "resources:/resources",
+ label: "Focused resource",
+ resourceId: "resource-1",
+ resourceUid: "arn:aws:s3:::example",
+ providerUid: "123456789012",
+ service: "s3",
+ region: "eu-west-1",
+ resourceType: "AwsS3Bucket",
+ failedFindingsCount: 3,
+ });
+ });
+
it("builds compliance framework snapshots with score and totals", () => {
expect(
buildComplianceContext({
diff --git a/ui/lib/lighthouse/context/contributions.ts b/ui/lib/lighthouse/context/contributions.ts
index 064b2761d8..131f79d728 100644
--- a/ui/lib/lighthouse/context/contributions.ts
+++ b/ui/lib/lighthouse/context/contributions.ts
@@ -38,6 +38,10 @@ interface FindingResourceContextInput {
region?: string;
}
+interface FocusedFindingContextInput extends FindingResourceContextInput {
+ pathname: string;
+}
+
interface ResourceContextInput {
id: string;
attributes: {
@@ -50,6 +54,10 @@ interface ResourceContextInput {
providerUid?: string;
}
+interface FocusedResourceContextInput extends ResourceContextInput {
+ pathname: string;
+}
+
interface ComplianceContextInput {
pathname: string;
id: string;
@@ -140,6 +148,26 @@ export function buildFindingResourceContext(
};
}
+export function buildFocusedFindingContext(
+ finding: FocusedFindingContextInput,
+): LighthouseFindingContextItem {
+ const safeFindingId = toBoundedString(finding.findingId);
+ return {
+ kind: LIGHTHOUSE_CONTEXT_KIND.FINDING,
+ id: safeFindingId,
+ source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED,
+ scopeKey: getLighthouseScopeKey(finding.pathname),
+ label: "Focused finding",
+ findingId: safeFindingId,
+ checkId: optionalBoundedString(finding.checkId),
+ severity: optionalBoundedString(finding.severity),
+ status: optionalBoundedString(finding.status),
+ providerUid: optionalBoundedString(finding.providerUid),
+ resourceUid: optionalBoundedString(finding.resourceUid),
+ region: optionalBoundedString(finding.region),
+ };
+}
+
export function buildResourceSummaryContext(
total: number,
): LighthouseResourceContextItem {
@@ -174,6 +202,25 @@ export function buildResourceContext(
};
}
+export function buildFocusedResourceContext(
+ resource: FocusedResourceContextInput,
+): LighthouseResourceContextItem {
+ return {
+ kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE,
+ id: toBoundedString(resource.id),
+ source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED,
+ scopeKey: getLighthouseScopeKey(resource.pathname),
+ label: "Focused resource",
+ resourceId: toBoundedString(resource.id),
+ resourceUid: toBoundedString(resource.attributes.uid),
+ providerUid: optionalBoundedString(resource.providerUid),
+ service: toBoundedString(resource.attributes.service),
+ region: toBoundedString(resource.attributes.region),
+ resourceType: toBoundedString(resource.attributes.type),
+ failedFindingsCount: toSafeCount(resource.attributes.failed_findings_count),
+ };
+}
+
export function buildComplianceContext(
input: ComplianceContextInput,
): LighthouseComplianceContextItem {
diff --git a/ui/store/lighthouse-context/store.test.ts b/ui/store/lighthouse-context/store.test.ts
index 9b5200e06b..5b14598106 100644
--- a/ui/store/lighthouse-context/store.test.ts
+++ b/ui/store/lighthouse-context/store.test.ts
@@ -72,4 +72,38 @@ describe("useLighthouseContextStore", () => {
).map((item) => item.id),
).toEqual(["resource-2"]);
});
+
+ it("should keep focused context owned by the latest detail panel", () => {
+ // Given
+ const { setFocusedContext, clearFocusedContext } =
+ useLighthouseContextStore.getState();
+ setFocusedContext(1, {
+ kind: "finding",
+ id: "finding-1",
+ source: "focused",
+ scopeKey: "findings:/findings",
+ label: "Focused finding",
+ findingId: "finding-1",
+ });
+
+ // When
+ setFocusedContext(2, {
+ kind: "resource",
+ id: "resource-2",
+ source: "focused",
+ scopeKey: "resources:/resources",
+ label: "Focused resource",
+ resourceId: "resource-2",
+ });
+ clearFocusedContext(1);
+
+ // Then
+ expect(useLighthouseContextStore.getState().focused?.id).toBe("resource-2");
+
+ // When
+ clearFocusedContext(2);
+
+ // Then
+ expect(useLighthouseContextStore.getState().focused).toBeNull();
+ });
});
diff --git a/ui/store/lighthouse-context/store.ts b/ui/store/lighthouse-context/store.ts
index c8d4e80dc5..aba88ed7cd 100644
--- a/ui/store/lighthouse-context/store.ts
+++ b/ui/store/lighthouse-context/store.ts
@@ -4,17 +4,26 @@ import type { LighthouseContextItem } from "@/types/lighthouse-context";
export interface LighthouseContextStoreState {
contributions: Record;
+ focused: LighthouseContextItem | null;
+ focusedOwnerToken: number;
registerContribution: (
contributorId: string,
item: LighthouseContextItem,
) => void;
removeContribution: (contributorId: string) => void;
+ setFocusedContext: (
+ ownerToken: number,
+ item: LighthouseContextItem | null,
+ ) => void;
+ clearFocusedContext: (ownerToken: number) => void;
resetContributions: () => void;
}
export const useLighthouseContextStore = create(
(set) => ({
contributions: {},
+ focused: null,
+ focusedOwnerToken: 0,
registerContribution: (contributorId, item) =>
set((state) => ({
contributions: {
@@ -30,7 +39,16 @@ export const useLighthouseContextStore = create(
),
),
})),
- resetContributions: () => set({ contributions: {} }),
+ setFocusedContext: (ownerToken, focused) =>
+ set({ focused, focusedOwnerToken: ownerToken }),
+ clearFocusedContext: (ownerToken) =>
+ set((state) =>
+ state.focusedOwnerToken === ownerToken
+ ? { focused: null, focusedOwnerToken: 0 }
+ : state,
+ ),
+ resetContributions: () =>
+ set({ contributions: {}, focused: null, focusedOwnerToken: 0 }),
}),
);