mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
fix(ui): Lighthouse chat archive navigation, new chat button, and masked stored credentials (#11860)
This commit is contained in:
@@ -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();
|
||||
|
||||
+23
@@ -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">
|
||||
|
||||
+171
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user