fix(ui): Lighthouse chat archive navigation, new chat button, and masked stored credentials (#11860)

This commit is contained in:
Alejandro Bailo
2026-07-07 09:57:47 +02:00
committed by GitHub
parent 3cc8f86780
commit a48aa37f87
10 changed files with 441 additions and 25 deletions
@@ -3,7 +3,10 @@ import userEvent from "@testing-library/user-event";
import { type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT } from "@/app/(prowler)/lighthouse/_lib/session-events";
import {
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
notifyLighthouseV2SessionArchived,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type {
LighthouseV2Configuration,
LighthouseV2Message,
@@ -596,6 +599,79 @@ describe("LighthouseV2ChatPage", () => {
expect(source.close).toHaveBeenCalled();
});
it("resets to a new chat when the live-created session is archived from the sidebar", async () => {
// Given: a session created in this chat (its URL was set via replaceState,
// so the sidebar cannot see it in Next's search params)
const user = userEvent.setup();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(sendMessageMock).toHaveBeenCalled());
// When: the sidebar archives that same session
act(() => notifyLighthouseV2SessionArchived("session-1"));
// Then: the chat resets in place and the URL leaves the dead session
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/lighthouse"),
);
expect(screen.queryByText("Summarize findings")).not.toBeInTheDocument();
expect(eventSources[0].close).toHaveBeenCalled();
replaceStateSpy.mockRestore();
});
it("drops a stale message reload that resolves after the session is archived", async () => {
// Given: a live session whose message.end reload is still in flight
const user = userEvent.setup();
let resolveReload: (value: unknown) => void = () => {};
getMessagesMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveReload = resolve;
}),
);
renderPage();
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
await waitFor(() => expect(eventSources).toHaveLength(1));
// When: the run ends (starting the async reload) and, before it resolves,
// the open session is archived and the chat resets
act(() => eventSources[0].emit("message.end", { message_id: "message-1" }));
await waitFor(() =>
expect(getMessagesMock).toHaveBeenCalledWith("session-1"),
);
act(() => notifyLighthouseV2SessionArchived("session-1"));
// The reload finally resolves with the (now archived) session's messages
await act(async () => {
resolveReload({
data: [message("message-1", "assistant", "Archived answer")],
});
});
// Then: the stale reload is ignored so the reset chat is not repopulated
expect(screen.queryByText("Archived answer")).not.toBeInTheDocument();
});
it("keeps the conversation when a different session is archived", async () => {
// Given
renderPage({
initialSessionId: "session-2",
initialMessages: [message("message-1", "assistant", "Existing answer")],
});
// When
act(() => notifyLighthouseV2SessionArchived("session-other"));
// Then
expect(screen.getByText("Existing answer")).toBeInTheDocument();
});
it("surfaces a connection error when the stream closes without retrying", async () => {
// Given
const user = userEvent.setup();
@@ -29,6 +29,7 @@ import {
} from "@/app/(prowler)/lighthouse/_lib/model-selection";
import {
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
notifyLighthouseV2SessionsChanged,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
@@ -93,6 +94,9 @@ export function LighthouseV2ChatPage({
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
// Mirror for window listeners registered on mount, whose closures would
// otherwise keep the first render's activeSessionId.
const activeSessionIdRef = useRef<string | null>(initialSessionId ?? null);
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState<string | null>(initialError ?? null);
@@ -146,6 +150,10 @@ export function LighthouseV2ChatPage({
const refreshMessages = async (sessionId: string): Promise<boolean> => {
const result = await getLighthouseV2Messages(sessionId);
// The fetch is async, so a reset (new chat, or archiving this session) can
// land while it is in flight. Drop the stale result instead of repopulating
// a chat that no longer points at this session.
if (sessionId !== activeSessionIdRef.current) return false;
if ("data" in result) {
setMessages(result.data);
return true;
@@ -245,6 +253,7 @@ export function LighthouseV2ChatPage({
// page.tsx and remount this component, tearing down the open EventSource.
replaceLighthouseV2SessionUrl(result.data.id);
setActiveSessionId(result.data.id);
activeSessionIdRef.current = result.data.id;
notifyLighthouseV2SessionsChanged();
return result.data.id;
};
@@ -358,27 +367,50 @@ export function LighthouseV2ChatPage({
}
});
const resetToNewChat = () => {
closeStream();
setActiveSessionId(null);
activeSessionIdRef.current = null;
setMessages([]);
setInput("");
setFeedback(null);
setBlockedByConflict(false);
setIsSubmitting(false);
setLastSubmittedText(null);
setStreamState(createInitialLighthouseV2StreamState());
replaceLighthouseV2SessionUrl(null);
};
// The sidebar "+" can't rely on routing to reset the latest conversation (its
// URL was set via replaceState, invisible to Next's router), so reset in place.
useMountEffect(() => {
const resetToNewChat = () => {
closeStream();
setActiveSessionId(null);
setMessages([]);
setInput("");
setFeedback(null);
setBlockedByConflict(false);
setIsSubmitting(false);
setLastSubmittedText(null);
setStreamState(createInitialLighthouseV2StreamState());
replaceLighthouseV2SessionUrl(null);
};
window.addEventListener(LIGHTHOUSE_V2_NEW_CHAT_EVENT, resetToNewChat);
return () =>
window.removeEventListener(LIGHTHOUSE_V2_NEW_CHAT_EVENT, resetToNewChat);
});
// Archiving deletes the session; when it's the open one, fall back to a new
// chat instead of leaving a dead conversation and its URL on screen.
useMountEffect(() => {
const handleSessionArchived = (event: Event) => {
const archivedId = (event as CustomEvent<{ sessionId: string }>).detail
?.sessionId;
if (archivedId && archivedId === activeSessionIdRef.current) {
resetToNewChat();
}
};
window.addEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
handleSessionArchived,
);
return () =>
window.removeEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
handleSessionArchived,
);
});
const hasLiveAssistantActivity =
Boolean(streamState.activeTaskId) ||
Boolean(streamState.assistantText) ||
@@ -258,6 +258,7 @@ export function LighthouseV2ConfigurationForm({
>
<CredentialFields
errors={form.formState.errors}
hasStoredCredentials={hasConfiguration}
provider={providerType}
register={form.register}
/>
@@ -8,19 +8,28 @@ import {
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
// Stored secrets are never sent back by the API, so the fields would look
// empty even when a key exists; the masked placeholder stands in for it.
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
export function CredentialFields({
errors,
hasStoredCredentials = false,
provider,
register,
}: {
errors: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["formState"]["errors"];
hasStoredCredentials?: boolean;
provider: LighthouseV2ProviderType;
register: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
const secretPlaceholder = hasStoredCredentials
? STORED_SECRET_PLACEHOLDER
: undefined;
return (
<div className="grid gap-4">
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
@@ -31,6 +40,7 @@ export function CredentialFields({
id="lighthouse-v2-api-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.apiKey)}
{...register("apiKey")}
/>
@@ -65,6 +75,7 @@ export function CredentialFields({
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
@@ -81,6 +92,7 @@ export function CredentialFields({
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
@@ -266,6 +266,44 @@ describe("LighthouseV2ConfigPage", () => {
).toBeInTheDocument();
});
it("hints at stored credentials with a masked placeholder", async () => {
// Given / When: OpenAI and Bedrock already have stored configurations
const user = userEvent.setup();
renderPage();
// Then: secret fields simulate the hidden stored key instead of looking
// empty, at a length resembling a real API key
const maskedKey = "•".repeat(36);
expect(screen.getByLabelText("API key")).toHaveAttribute(
"placeholder",
maskedKey,
);
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
"placeholder",
maskedKey,
);
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
"placeholder",
maskedKey,
);
});
it("shows no masked placeholder before credentials are stored", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When: OpenAI-compatible has no configuration yet
await user.click(
screen.getByRole("button", { name: /OpenAI-compatible/i }),
);
// Then
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
});
it("blocks OpenAI-compatible save when base URL is missing", async () => {
// Given
const user = userEvent.setup();
@@ -194,6 +194,28 @@ describe("LighthouseV2SessionHistory", () => {
).not.toBeInTheDocument();
});
it("explains the new chat button in a tooltip", async () => {
// Given
renderHistory();
vi.useRealTimers();
const user = userEvent.setup();
// When
await user.hover(screen.getByRole("button", { name: "New chat" }));
// Then
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("New chat");
});
it("disables the new chat button while already on a new chat", () => {
// Given / When
renderHistory({ newChatDisabled: true });
// Then
expect(screen.getByRole("button", { name: "New chat" })).toBeDisabled();
});
it("shows the full trimmed title in a right-side tooltip", async () => {
// Given
const fullTitle =
@@ -234,6 +256,7 @@ function renderHistory(
onNewSession={props?.onNewSession ?? vi.fn()}
onOpenSession={props?.onOpenSession ?? vi.fn()}
onArchiveSession={props?.onArchiveSession ?? vi.fn()}
newChatDisabled={props?.newChatDisabled}
compact={props?.compact}
/>,
);
@@ -23,6 +23,7 @@ interface LighthouseV2SessionHistoryProps {
onNewSession: () => void;
onOpenSession: (sessionId: string) => void;
onArchiveSession: (sessionId: string) => void;
newChatDisabled?: boolean;
compact?: boolean;
}
@@ -34,6 +35,7 @@ export function LighthouseV2SessionHistory({
onNewSession,
onOpenSession,
onArchiveSession,
newChatDisabled = false,
compact = false,
}: LighthouseV2SessionHistoryProps) {
const [sessionPendingArchive, setSessionPendingArchive] =
@@ -69,14 +71,20 @@ export function LighthouseV2SessionHistory({
onChange={(event) => onSearchChange(event.target.value)}
onClear={() => onSearchChange("")}
/>
<Button
type="button"
aria-label="New chat"
size={compact ? "icon-sm" : "icon"}
onClick={onNewSession}
>
<Plus />
</Button>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
aria-label="New chat"
size={compact ? "icon-sm" : "icon"}
disabled={newChatDisabled}
onClick={onNewSession}
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New chat</TooltipContent>
</Tooltip>
</div>
<div className="minimal-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
@@ -1,6 +1,11 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
@@ -8,9 +13,11 @@ import { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
const navigationMocks = vi.hoisted(() => ({
push: vi.fn(),
searchParams: "",
pathname: "/lighthouse",
}));
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
useRouter: () => ({ push: navigationMocks.push }),
useSearchParams: () => new URLSearchParams(navigationMocks.searchParams),
}));
@@ -29,8 +36,10 @@ describe("LighthouseV2SidebarChat", () => {
beforeEach(() => {
navigationMocks.push.mockReset();
navigationMocks.searchParams = "";
navigationMocks.pathname = "/lighthouse";
actions.archiveLighthouseV2Session.mockReset();
actions.getLighthouseV2Sessions.mockReset();
window.history.replaceState(null, "", "/lighthouse");
});
it("marks the URL session as active in chat history", async () => {
@@ -61,6 +70,167 @@ describe("LighthouseV2SidebarChat", () => {
"bg-bg-neutral-tertiary",
);
});
it("navigates back to a new chat when archiving the open session", async () => {
// Given: the archived session is the one currently open (in the URL)
const user = userEvent.setup();
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [session({ id: "session-active", title: "Active chat" })],
});
actions.archiveLighthouseV2Session.mockResolvedValue({
data: session({ id: "session-active", isArchived: true }),
});
const archivedIds: string[] = [];
const recordArchivedId = (event: Event) => {
archivedIds.push(
(event as CustomEvent<{ sessionId: string }>).detail.sessionId,
);
};
try {
window.addEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
recordArchivedId,
);
render(<LighthouseV2SidebarChat isOpen />);
// When
await user.click(
await screen.findByRole("button", { name: "Archive Active chat" }),
);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Archive",
}),
);
// Then: the URL no longer points at the archived (deleted) conversation,
// and the chat page is told which session died (it may hold a
// live-created session invisible to the router).
await waitFor(() =>
expect(navigationMocks.push).toHaveBeenCalledWith("/lighthouse"),
);
expect(archivedIds).toEqual(["session-active"]);
} finally {
window.removeEventListener(
LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT,
recordArchivedId,
);
}
});
it("stays on the open session when archiving a different one", async () => {
// Given
const user = userEvent.setup();
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [
session({ id: "session-active", title: "Active chat" }),
session({ id: "session-other", title: "Other chat" }),
],
});
actions.archiveLighthouseV2Session.mockResolvedValue({
data: session({ id: "session-other", isArchived: true }),
});
render(<LighthouseV2SidebarChat isOpen />);
// When
await user.click(
await screen.findByRole("button", { name: "Archive Other chat" }),
);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Archive",
}),
);
// Then
await waitFor(() =>
expect(actions.archiveLighthouseV2Session).toHaveBeenCalledWith(
"session-other",
),
);
expect(navigationMocks.push).not.toHaveBeenCalled();
});
it("disables the new chat button while already on a pristine new chat", async () => {
// Given: /lighthouse with no session in any URL
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeDisabled();
});
it("enables the new chat button when a conversation is open", async () => {
// Given
navigationMocks.searchParams = "session=session-active";
actions.getLighthouseV2Sessions.mockResolvedValue({
data: [session({ id: "session-active", title: "Active chat" })],
});
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeEnabled();
});
it("enables the new chat button when the chat creates its session in place", async () => {
// Given: a pristine new chat
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
render(<LighthouseV2SidebarChat isOpen />);
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeDisabled();
// When: the first message creates the session via replaceState (Next's
// router never sees this URL) and history listeners are notified
act(() => {
window.history.replaceState(null, "", "/lighthouse?session=live-1");
window.dispatchEvent(new Event(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT));
});
// Then
await waitFor(() =>
expect(screen.getByRole("button", { name: "New chat" })).toBeEnabled(),
);
});
it("disables the collapsed new chat button too on a pristine new chat", async () => {
// Given
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen={false} />);
// Then
await waitFor(() =>
expect(screen.getByRole("button", { name: "New chat" })).toBeDisabled(),
);
});
it("keeps the new chat button enabled outside the chat page", async () => {
// Given: chat sidebar mode active while browsing another page
navigationMocks.pathname = "/findings";
window.history.replaceState(null, "", "/findings");
actions.getLighthouseV2Sessions.mockResolvedValue({ data: [] });
// When
render(<LighthouseV2SidebarChat isOpen />);
// Then
expect(
await screen.findByRole("button", { name: "New chat" }),
).toBeEnabled();
});
});
function session(
@@ -1,8 +1,8 @@
"use client";
import { MessageSquare, Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState, useSyncExternalStore } from "react";
import {
archiveLighthouseV2Session,
@@ -11,6 +11,7 @@ import {
import {
LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT,
notifyLighthouseV2NewChat,
notifyLighthouseV2SessionArchived,
} from "@/app/(prowler)/lighthouse/_lib/session-events";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
@@ -20,13 +21,22 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { LighthouseV2SessionHistory } from "../history";
export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeSessionId = searchParams.get("session");
const browserUrlSessionId = useBrowserUrlSessionId();
// A pristine new chat is the chat route with no session anywhere; there,
// starting yet another new chat is a no-op.
const isOnNewChat =
pathname === LIGHTHOUSE_ROUTE.CHAT &&
!activeSessionId &&
!browserUrlSessionId;
const [sessions, setSessions] = useState<LighthouseV2Session[]>([]);
const [search, setSearch] = useState("");
@@ -63,6 +73,12 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
setSessions((current) =>
current.filter((session) => session.id !== sessionId),
);
// Covers live-created sessions the router can't see (replaceState URL).
notifyLighthouseV2SessionArchived(sessionId);
if (sessionId === activeSessionId) {
// The archived session no longer exists; leave its URL.
router.push("/lighthouse");
}
}
} catch {
// Archiving is recoverable from the sidebar; ignore transient failures.
@@ -87,6 +103,7 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
type="button"
aria-label="New chat"
size="icon"
disabled={isOnNewChat}
onClick={handleNewSession}
>
<Plus />
@@ -110,7 +127,31 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onArchiveSession={handleArchiveSession}
newChatDisabled={isOnNewChat}
/>
</div>
);
}
// Sessions created live set their URL via replaceState, invisible to Next's
// router, so the real browser URL is the only reliable session source.
function useBrowserUrlSessionId() {
return useSyncExternalStore(
subscribeToSessionUrl,
readBrowserUrlSessionId,
() => null,
);
}
function subscribeToSessionUrl(onChange: () => void) {
window.addEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, onChange);
window.addEventListener("popstate", onChange);
return () => {
window.removeEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, onChange);
window.removeEventListener("popstate", onChange);
};
}
function readBrowserUrlSessionId() {
return new URLSearchParams(window.location.search).get("session");
}
@@ -6,6 +6,21 @@ export function notifyLighthouseV2SessionsChanged() {
window.dispatchEvent(new Event(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT));
}
export const LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT =
"lighthouse-v2:session-archived";
// Carries the archived session id so the chat page can reset itself when its
// open session is archived. Needed because sessions created live set their URL
// via replaceState, so the sidebar can't spot them through useSearchParams.
export function notifyLighthouseV2SessionArchived(sessionId: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(LIGHTHOUSE_V2_SESSION_ARCHIVED_EVENT, {
detail: { sessionId },
}),
);
}
export const LIGHTHOUSE_V2_NEW_CHAT_EVENT = "lighthouse-v2:new-chat";
// Lets the sidebar reset an already-mounted chat page. router.push("/lighthouse")