fix(ui): make Lighthouse page context automatic

This commit is contained in:
alejandrobailo
2026-07-22 16:40:08 +02:00
parent 6ab47ca811
commit 135f89527d
6 changed files with 38 additions and 233 deletions
@@ -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();
+2 -27
View File
@@ -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);
+10 -64
View File
@@ -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();
});
});
+21 -31
View File
@@ -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,
}: {