mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
fix(ui): make Lighthouse page context automatic
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { LighthouseContextControl } from "@/components/lighthouse/context-chip";
|
||||
import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip";
|
||||
import { Card } from "@/components/shadcn";
|
||||
import {
|
||||
Combobox,
|
||||
@@ -68,14 +68,11 @@ export function LighthouseV2ChatView({
|
||||
lastSubmission,
|
||||
selectedModelSelection,
|
||||
modelPreferenceSaving,
|
||||
isContextEnabled,
|
||||
setInput,
|
||||
dismissFeedback,
|
||||
selectModel,
|
||||
submitMessage,
|
||||
retryLastMessage,
|
||||
disableContext,
|
||||
enableContext,
|
||||
} = state;
|
||||
const { modelsByProvider, supportedProviders } = config;
|
||||
const connectedConfigurations = config.configurations.filter(
|
||||
@@ -146,14 +143,7 @@ export function LighthouseV2ChatView({
|
||||
onRetry: () => void retryLastMessage(),
|
||||
onDismissFeedback: dismissFeedback,
|
||||
contextControl: supportsAutomaticContext ? (
|
||||
<LighthouseContextControl
|
||||
context={currentContext.context}
|
||||
pageLabel={currentContext.page.label}
|
||||
enabled={isContextEnabled}
|
||||
selectionCount={currentContext.selectionCount}
|
||||
onDisable={disableContext}
|
||||
onEnable={enableContext}
|
||||
/>
|
||||
<LighthouseCurrentContextBadge context={currentContext.context} />
|
||||
) : undefined,
|
||||
canSend,
|
||||
input,
|
||||
@@ -224,9 +214,7 @@ export function LighthouseV2ChatView({
|
||||
footer={emptyStateFooter}
|
||||
compact={surface === LIGHTHOUSE_CHAT_SURFACE.PANEL}
|
||||
suggestions={
|
||||
supportsAutomaticContext && isContextEnabled
|
||||
? currentContext.page.suggestions
|
||||
: undefined
|
||||
supportsAutomaticContext ? currentContext.page.suggestions : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -198,10 +198,8 @@ describe("LighthousePanelChat", () => {
|
||||
const modelControl = await screen.findByLabelText(
|
||||
"Current model: OpenAI gpt-5.1",
|
||||
);
|
||||
const contextControl = screen.getByRole("button", {
|
||||
name: "Disable Findings context",
|
||||
});
|
||||
expect(modelControl.nextElementSibling).toBe(contextControl);
|
||||
const contextBadge = screen.getByLabelText("Findings context");
|
||||
expect(modelControl.nextElementSibling).toBe(contextBadge);
|
||||
});
|
||||
|
||||
it("sends the current page context from the side panel", async () => {
|
||||
@@ -245,53 +243,6 @@ describe("LighthousePanelChat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps context disabled and restores global suggestions for the conversation", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: session("session-without-context", "Question"),
|
||||
});
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-without-context",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<LighthousePanelChat />);
|
||||
const input = await screen.findByRole("textbox", { name: "Message" });
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Disable Findings context" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
const disabledContext = screen.getByRole("button", {
|
||||
name: "Enable Findings context",
|
||||
});
|
||||
expect(disabledContext).toHaveAttribute("aria-pressed", "false");
|
||||
expect(disabledContext).toHaveTextContent("@ Findings");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Critical findings" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.type(input, "Question{Enter}");
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-without-context",
|
||||
displayText: "Question",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a recent chat in place without navigating", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -185,51 +185,6 @@ describe("createLighthouseChatStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries with the original snapshot even when current context was disabled", async () => {
|
||||
const store = makeStore();
|
||||
const context = findingsContext();
|
||||
await store.getState().submitMessage("Prioritize findings", context);
|
||||
eventSources[0].fail(2 /* EventSource.CLOSED */);
|
||||
store.getState().disableContext();
|
||||
|
||||
await store.getState().retryLastMessage();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenNthCalledWith(2, {
|
||||
sessionId: "session-1",
|
||||
displayText: "Prioritize findings",
|
||||
context,
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
expect(store.getState().isContextEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps context disabled for the conversation and restores it for a new chat", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
store.getState().disableContext();
|
||||
|
||||
// When
|
||||
await store
|
||||
.getState()
|
||||
.submitMessage("Question without context", findingsContext());
|
||||
|
||||
// Then
|
||||
expect(store.getState().isContextEnabled).toBe(false);
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
displayText: "Question without context",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
|
||||
// When
|
||||
store.getState().resetToNewChat();
|
||||
|
||||
// Then
|
||||
expect(store.getState().isContextEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("degrades oversized context before sending without blocking the message", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface LighthouseChatState {
|
||||
/** @deprecated Use lastSubmission so retries can preserve their context snapshot. */
|
||||
lastSubmittedText: string | null;
|
||||
lastSubmission: LighthouseChatSubmission | null;
|
||||
isContextEnabled: boolean;
|
||||
selectedModelSelection: LighthouseV2ModelSelection | null;
|
||||
modelPreferenceSaving: boolean;
|
||||
setSessionUrlSyncEnabled: (enabled: boolean) => void;
|
||||
@@ -77,8 +76,6 @@ export interface LighthouseChatState {
|
||||
context?: LighthouseContextEnvelope,
|
||||
) => Promise<void>;
|
||||
retryLastMessage: () => Promise<void>;
|
||||
disableContext: () => void;
|
||||
enableContext: () => void;
|
||||
openSession: (sessionId: string) => Promise<void>;
|
||||
resetToNewChat: () => void;
|
||||
handleSessionArchived: (sessionId: string) => void;
|
||||
@@ -90,10 +87,6 @@ export interface LighthouseChatSubmission {
|
||||
context?: LighthouseContextEnvelope;
|
||||
}
|
||||
|
||||
interface LighthouseChatSubmitOptions {
|
||||
bypassContextGate?: boolean;
|
||||
}
|
||||
|
||||
export type LighthouseChatStore = StoreApi<LighthouseChatState>;
|
||||
|
||||
export function selectLighthouseChatCanSend(
|
||||
@@ -270,7 +263,6 @@ export function createLighthouseChatStore(
|
||||
const submitMessageInternal = async (
|
||||
displayText: string,
|
||||
context?: LighthouseContextEnvelope,
|
||||
submitOptions: LighthouseChatSubmitOptions = {},
|
||||
): Promise<void> => {
|
||||
if (!displayText.trim()) return;
|
||||
if (!get().selectedModelSelection) {
|
||||
@@ -279,11 +271,7 @@ export function createLighthouseChatStore(
|
||||
}
|
||||
if (!selectLighthouseChatCanSend(get())) return;
|
||||
|
||||
const shouldUseContext =
|
||||
submitOptions.bypassContextGate === true || get().isContextEnabled;
|
||||
const contextSnapshot = shouldUseContext
|
||||
? prepareLighthouseContext(context)
|
||||
: undefined;
|
||||
const contextSnapshot = prepareLighthouseContext(context);
|
||||
|
||||
set({ isSubmitting: true });
|
||||
try {
|
||||
@@ -366,7 +354,6 @@ export function createLighthouseChatStore(
|
||||
isLoadingSession: false,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
selectedModelSelection: resolveInitialModelSelection(
|
||||
connectedConfigurations,
|
||||
config.modelsByProvider,
|
||||
@@ -381,10 +368,6 @@ export function createLighthouseChatStore(
|
||||
|
||||
dismissFeedback: () => set({ feedback: null }),
|
||||
|
||||
disableContext: () => set({ isContextEnabled: false }),
|
||||
|
||||
enableContext: () => set({ isContextEnabled: true }),
|
||||
|
||||
selectModel: async (selection) => {
|
||||
// The selection drives the model used for the next message, so it stays
|
||||
// applied even if persisting it as the provider's default model fails —
|
||||
@@ -416,13 +399,7 @@ export function createLighthouseChatStore(
|
||||
retryLastMessage: async () => {
|
||||
const submission = get().lastSubmission;
|
||||
if (!submission) return;
|
||||
await submitMessageInternal(
|
||||
submission.displayText,
|
||||
submission.context,
|
||||
{
|
||||
bypassContextGate: true,
|
||||
},
|
||||
);
|
||||
await submitMessageInternal(submission.displayText, submission.context);
|
||||
},
|
||||
|
||||
openSession: async (sessionId) => {
|
||||
@@ -439,7 +416,6 @@ export function createLighthouseChatStore(
|
||||
isLoadingSession: true,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
streamState: createInitialLighthouseV2StreamState(),
|
||||
});
|
||||
syncSessionUrl(sessionId);
|
||||
@@ -467,7 +443,6 @@ export function createLighthouseChatStore(
|
||||
isLoadingSession: false,
|
||||
lastSubmittedText: null,
|
||||
lastSubmission: null,
|
||||
isContextEnabled: true,
|
||||
streamState: createInitialLighthouseV2StreamState(),
|
||||
});
|
||||
syncSessionUrl(null);
|
||||
|
||||
@@ -1,86 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
|
||||
|
||||
import {
|
||||
LighthouseContextBadge,
|
||||
LighthouseContextControl,
|
||||
LighthouseCurrentContextBadge,
|
||||
} from "./context-chip";
|
||||
|
||||
describe("LighthouseContextControl", () => {
|
||||
it("should show enabled context as a pressed badge and explain disabling it", async () => {
|
||||
describe("LighthouseCurrentContextBadge", () => {
|
||||
it("should show current context as read-only and explain automatic inclusion", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onDisable = vi.fn();
|
||||
render(
|
||||
<LighthouseContextControl
|
||||
context={findingsContext()}
|
||||
pageLabel="Findings"
|
||||
enabled
|
||||
selectionCount={1}
|
||||
onDisable={onDisable}
|
||||
onEnable={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const contextControl = screen.getByRole("button", {
|
||||
name: "Disable Findings context",
|
||||
});
|
||||
render(<LighthouseCurrentContextBadge context={findingsContext()} />);
|
||||
const contextBadge = screen.getByLabelText("Findings context");
|
||||
|
||||
// When
|
||||
await user.hover(contextControl);
|
||||
await user.hover(contextBadge);
|
||||
|
||||
// Then
|
||||
expect(contextControl).toHaveAttribute("aria-pressed", "true");
|
||||
expect(contextControl).toHaveTextContent("@ Findings +1");
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"Click to stop including Findings context in new messages.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(contextControl);
|
||||
|
||||
// Then
|
||||
expect(onDisable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should keep the same badge label when disabled and explain enabling it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onEnable = vi.fn();
|
||||
render(
|
||||
<LighthouseContextControl
|
||||
context={findingsContext()}
|
||||
pageLabel="Findings"
|
||||
enabled={false}
|
||||
selectionCount={1}
|
||||
onDisable={vi.fn()}
|
||||
onEnable={onEnable}
|
||||
/>,
|
||||
);
|
||||
const contextControl = screen.getByRole("button", {
|
||||
name: "Enable Findings context",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.hover(contextControl);
|
||||
|
||||
// Then
|
||||
expect(contextControl).toHaveAttribute("aria-pressed", "false");
|
||||
expect(contextControl).toHaveTextContent("@ Findings +1");
|
||||
expect(contextBadge).toHaveTextContent("@ Findings +1");
|
||||
expect(
|
||||
screen.queryByText("+ Add Findings context"),
|
||||
screen.queryByRole("button", { name: /Findings context/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"Click to include Findings context in new messages.",
|
||||
"Findings context will be included in your next message.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(contextControl);
|
||||
|
||||
// Then
|
||||
expect(onEnable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,43 +12,27 @@ import {
|
||||
type LighthouseContextEnvelope,
|
||||
} from "@/types/lighthouse-context";
|
||||
|
||||
interface LighthouseContextControlProps {
|
||||
interface LighthouseCurrentContextBadgeProps {
|
||||
context: LighthouseContextEnvelope | undefined;
|
||||
pageLabel: string;
|
||||
enabled: boolean;
|
||||
selectionCount: number;
|
||||
onDisable: () => void;
|
||||
onEnable: () => void;
|
||||
}
|
||||
|
||||
export function LighthouseContextControl({
|
||||
export function LighthouseCurrentContextBadge({
|
||||
context,
|
||||
pageLabel,
|
||||
enabled,
|
||||
selectionCount,
|
||||
onDisable,
|
||||
onEnable,
|
||||
}: LighthouseContextControlProps) {
|
||||
}: LighthouseCurrentContextBadgeProps) {
|
||||
if (!context) return null;
|
||||
const { pageLabel, selectionCount } = getContextBadgeContent(context);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge asChild variant={enabled ? "tag" : "outline"}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${enabled ? "Disable" : "Enable"} ${pageLabel} context`}
|
||||
aria-pressed={enabled}
|
||||
onClick={enabled ? onDisable : onEnable}
|
||||
>
|
||||
<Badge asChild variant="tag">
|
||||
<span tabIndex={0} aria-label={`${pageLabel} context`}>
|
||||
{buildContextLabel(pageLabel, selectionCount)}
|
||||
</button>
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{enabled
|
||||
? `Click to stop including ${pageLabel} context in new messages.`
|
||||
: `Click to include ${pageLabel} context in new messages.`}
|
||||
{pageLabel} context will be included in your next message.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -59,13 +43,7 @@ export function LighthouseContextBadge({
|
||||
}: {
|
||||
context: LighthouseContextEnvelope;
|
||||
}) {
|
||||
const page = context.items.find(
|
||||
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE,
|
||||
);
|
||||
const pageLabel = page?.label ?? "Context";
|
||||
const selectionCount = context.items.filter(
|
||||
(item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
).length;
|
||||
const { pageLabel, selectionCount } = getContextBadgeContent(context);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
@@ -81,6 +59,18 @@ export function LighthouseContextBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getContextBadgeContent(context: LighthouseContextEnvelope) {
|
||||
const page = context.items.find(
|
||||
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE,
|
||||
);
|
||||
const pageLabel = page?.label ?? "Context";
|
||||
const selectionCount = context.items.filter(
|
||||
(item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
).length;
|
||||
|
||||
return { pageLabel, selectionCount };
|
||||
}
|
||||
|
||||
function LighthouseContextTooltip({
|
||||
context,
|
||||
}: {
|
||||
|
||||
Reference in New Issue
Block a user