From a48aa37f87b774829a4f401fe9f84221c4f5395d Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:57:47 +0200 Subject: [PATCH] fix(ui): Lighthouse chat archive navigation, new chat button, and masked stored credentials (#11860) --- .../chat/lighthouse-v2-chat-page.test.tsx | 78 +++++++- .../chat/lighthouse-v2-chat-page.tsx | 58 ++++-- .../_components/config/configuration-form.tsx | 1 + .../_components/config/credential-fields.tsx | 12 ++ .../config/lighthouse-v2-config-page.test.tsx | 38 ++++ .../lighthouse-v2-session-history.test.tsx | 23 +++ .../history/lighthouse-v2-session-history.tsx | 24 ++- .../lighthouse-v2-sidebar-chat.test.tsx | 172 +++++++++++++++++- .../navigation/lighthouse-v2-sidebar-chat.tsx | 45 ++++- .../lighthouse/_lib/session-events.ts | 15 ++ 10 files changed, 441 insertions(+), 25 deletions(-) diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index 173b7fac62..85a242bbfe 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -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(); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx index 5580ac9715..a875cc3350 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx @@ -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( initialSessionId ?? null, ); + // Mirror for window listeners registered on mount, whose closures would + // otherwise keep the first render's activeSessionId. + const activeSessionIdRef = useRef(initialSessionId ?? null); const [messages, setMessages] = useState(initialMessages); const [input, setInput] = useState(""); const [feedback, setFeedback] = useState(initialError ?? null); @@ -146,6 +150,10 @@ export function LighthouseV2ChatPage({ const refreshMessages = async (sessionId: string): Promise => { 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) || diff --git a/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx b/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx index 1c1a7200af..5bb6780f33 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx @@ -258,6 +258,7 @@ export function LighthouseV2ConfigurationForm({ > diff --git a/ui/app/(prowler)/lighthouse/_components/config/credential-fields.tsx b/ui/app/(prowler)/lighthouse/_components/config/credential-fields.tsx index b8ae29443f..d850cd8029 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/credential-fields.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/credential-fields.tsx @@ -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 >["formState"]["errors"]; + hasStoredCredentials?: boolean; provider: LighthouseV2ProviderType; register: ReturnType< typeof useForm >["register"]; }) { + const secretPlaceholder = hasStoredCredentials + ? STORED_SECRET_PLACEHOLDER + : undefined; return (
{(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")} /> diff --git a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx index b5b7ae3de3..a1f8d1a5ab 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx @@ -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(); diff --git a/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.test.tsx b/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.test.tsx index e7242911f5..9f9bf755dd 100644 --- a/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.test.tsx @@ -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} />, ); diff --git a/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.tsx b/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.tsx index 9ccc3e6dd4..6dc5d8cf1c 100644 --- a/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.tsx +++ b/ui/app/(prowler)/lighthouse/_components/history/lighthouse-v2-session-history.tsx @@ -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("")} /> - + + + + + New chat +
diff --git a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.test.tsx b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.test.tsx index 327ad9a229..a9f9c85594 100644 --- a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + 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(); + + // 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(); + + // Then + expect( + await screen.findByRole("button", { name: "New chat" }), + ).toBeEnabled(); + }); }); function session( diff --git a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx index 1ffa980b8e..6148c5f946 100644 --- a/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx +++ b/ui/app/(prowler)/lighthouse/_components/navigation/lighthouse-v2-sidebar-chat.tsx @@ -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([]); 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} > @@ -110,7 +127,31 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) { onNewSession={handleNewSession} onOpenSession={handleOpenSession} onArchiveSession={handleArchiveSession} + newChatDisabled={isOnNewChat} />
); } + +// 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"); +} diff --git a/ui/app/(prowler)/lighthouse/_lib/session-events.ts b/ui/app/(prowler)/lighthouse/_lib/session-events.ts index 9f50b59762..ab23282c49 100644 --- a/ui/app/(prowler)/lighthouse/_lib/session-events.ts +++ b/ui/app/(prowler)/lighthouse/_lib/session-events.ts @@ -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")