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 new file mode 100644 index 0000000000..f660564ecc --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -0,0 +1,370 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + LighthouseV2Configuration, + LighthouseV2Message, + LighthouseV2SupportedModel, +} from "@/app/(prowler)/lighthouse/_types"; + +import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page"; + +// Controllable EventSource mock: records each instance so tests can drive +// named SSE events and connection failures, while still being a vi.fn so +// `expect(EventSource).toHaveBeenCalledWith(...)` keeps working. +interface MockEventSource { + url: string; + readyState: number; + onerror: ((event: Event) => void) | null; + listeners: Map>; + addEventListener: (type: string, cb: EventListener) => void; + close: ReturnType; + emit: (type: string, data: unknown) => void; + fail: (readyState: number) => void; +} + +let eventSources: MockEventSource[] = []; + +function stubEventSource() { + eventSources = []; + const EventSourceMock = vi.fn(function (this: MockEventSource, url: string) { + this.url = url; + this.readyState = 0; + this.onerror = null; + this.listeners = new Map(); + this.addEventListener = (type: string, cb: EventListener) => { + const set = this.listeners.get(type) ?? new Set(); + set.add(cb); + this.listeners.set(type, set); + }; + this.close = vi.fn(() => { + this.readyState = 2; + }); + this.emit = (type: string, data: unknown) => { + const event = new MessageEvent(type, { data: JSON.stringify(data) }); + this.listeners.get(type)?.forEach((cb) => cb(event)); + }; + this.fail = (readyState: number) => { + this.readyState = readyState; + this.onerror?.(new Event("error")); + }; + eventSources.push(this); + }); + Object.assign(EventSourceMock, { CONNECTING: 0, OPEN: 1, CLOSED: 2 }); + vi.stubGlobal("EventSource", EventSourceMock); +} + +const { cancelRunMock, createSessionMock, getMessagesMock, sendMessageMock } = + vi.hoisted(() => ({ + cancelRunMock: vi.fn(), + createSessionMock: vi.fn(), + getMessagesMock: vi.fn(), + sendMessageMock: vi.fn(), + })); + +vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ + cancelLighthouseV2Run: cancelRunMock, + createLighthouseV2Session: createSessionMock, + getLighthouseV2Messages: getMessagesMock, + sendLighthouseV2Message: sendMessageMock, +})); + +const configurations: LighthouseV2Configuration[] = [ + { + id: "config-openai", + providerType: "openai", + baseUrl: null, + defaultModel: "gpt-5.1", + businessContext: "Production account", + connected: true, + connectionLastCheckedAt: "2026-06-24T10:00:00Z", + insertedAt: "2026-06-24T09:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + }, + { + id: "config-bedrock", + providerType: "bedrock", + baseUrl: null, + defaultModel: "anthropic.claude-4", + businessContext: "AWS landing zone", + connected: true, + connectionLastCheckedAt: "2026-06-23T10:00:00Z", + insertedAt: "2026-06-23T09:00:00Z", + updatedAt: "2026-06-23T10:00:00Z", + }, +]; + +const modelsByProvider = { + openai: [model("gpt-5.1")], + bedrock: [model("anthropic.claude-4")], + "openai-compatible": [model("llama-3.3")], +}; + +describe("LighthouseV2ChatPage", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + }, + ); + cancelRunMock.mockReset(); + createSessionMock.mockReset(); + getMessagesMock.mockReset(); + sendMessageMock.mockReset(); + // The mock never fires "open": the client must POST the message without + // waiting for it (the backend sends no bytes until the worker emits, which + // only happens after the POST). This is the regression guard for the + // open-gate deadlock. + stubEventSource(); + + createSessionMock.mockResolvedValue({ + data: { + id: "session-1", + title: "Summarize findings", + isArchived: false, + insertedAt: "2026-06-24T10:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + activeTaskId: null, + }, + }); + getMessagesMock.mockResolvedValue({ data: [] }); + sendMessageMock.mockResolvedValue({ + data: { + task: { + id: "task-1", + name: "lighthouse-run", + state: "executing", + }, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not render provider or model selectors in the chat composer", () => { + // Given / When + renderPage(); + + // Then + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Lighthouse settings" }), + ).toHaveAttribute("href", "/lighthouse/settings"); + }); + + it("uses the tuned scrollbar and bottom fade without a composer separator", () => { + // Given / When + const { container } = renderPage({ + initialMessages: [message("message-1", "assistant", "Existing answer")], + }); + + // Then + const conversation = screen.getByRole("log"); + const scrollViewport = conversation.firstElementChild as HTMLElement; + const content = scrollViewport.firstElementChild as HTMLElement; + const scrollFade = container.querySelector( + '[data-slot="lighthouse-v2-chat-scroll-fade"]', + ); + + expect(conversation).toHaveClass("h-full", "min-h-0"); + expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden"); + expect(scrollViewport).toHaveClass( + "minimal-scrollbar", + "overflow-x-hidden", + "overflow-y-auto", + ); + expect(content).toHaveClass("pb-20"); + expect(scrollFade).toHaveClass( + "pointer-events-none", + "absolute", + "bottom-0", + "right-2", + "h-16", + "bg-gradient-to-t", + "from-bg-neutral-secondary", + "to-transparent", + ); + expect( + container.querySelector( + '[data-slot="lighthouse-v2-chat-composer-panel"]', + ), + ).not.toHaveClass("border-t"); + }); + + it("sends messages with the connected default provider and model from configuration", async () => { + // Given + const user = userEvent.setup(); + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + renderPage(); + + // When + await user.type( + screen.getByRole("textbox", { name: "Message" }), + ["Summarize findings", "{Enter}"].join(""), + ); + + // Then: the message is sent even though EventSource never fires "open" + await waitFor(() => + expect(sendMessageMock).toHaveBeenCalledWith({ + sessionId: "session-1", + text: "Summarize findings", + provider: "openai", + model: "gpt-5.1", + }), + ); + expect(createSessionMock).toHaveBeenCalledWith("Summarize findings"); + // The session URL is set in place (no router navigation / remount) + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/lighthouse?session=session-1", + ); + // The stream is opened against our same-origin SSE proxy (not the + // cross-origin API host), so the browser EventSource can actually connect. + expect(EventSource).toHaveBeenCalledWith( + "/api/lighthouse/v2/sessions/session-1/event-stream", + ); + replaceStateSpy.mockRestore(); + }); + + it("subscribes to the stream before sending the message (no replay buffer)", async () => { + // Given + const user = userEvent.setup(); + renderPage(); + + // When + await user.type( + screen.getByRole("textbox", { name: "Message" }), + ["Summarize findings", "{Enter}"].join(""), + ); + + // Then: the EventSource must be constructed before the POST, otherwise + // early tokens emitted by the worker would be lost (backend has no replay). + await waitFor(() => expect(sendMessageMock).toHaveBeenCalled()); + const eventSourceOrder = vi.mocked(EventSource).mock.invocationCallOrder[0]; + const sendOrder = sendMessageMock.mock.invocationCallOrder[0]; + expect(eventSourceOrder).toBeLessThan(sendOrder); + }); + + it("renders a copy button (copies text) and an ISO timestamp under each message", async () => { + // Given + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + const { container } = renderPage({ + initialMessages: [message("message-1", "assistant", "Existing answer")], + }); + + // When + await user.click(screen.getByRole("button", { name: "Copy message" })); + + // Then: copies the message text, and the timestamp carries the raw ISO + expect(writeText).toHaveBeenCalledWith("Existing answer"); + expect(container.querySelector("time")).toHaveAttribute( + "datetime", + "2026-06-25T10:00:00Z", + ); + }); + + it("renders streamed deltas and reloads persisted messages on message.end", async () => { + // Given + const user = userEvent.setup(); + renderPage(); + await user.type( + screen.getByRole("textbox", { name: "Message" }), + ["Summarize findings", "{Enter}"].join(""), + ); + await waitFor(() => expect(eventSources).toHaveLength(1)); + const source = eventSources[0]; + + // When a delta arrives, it renders live + act(() => source.emit("message.delta", { content: "Hello there" })); + expect(await screen.findByText("Hello there")).toBeInTheDocument(); + + // When the run ends, the full persisted message is reloaded from the DB + act(() => source.emit("message.end", { message_id: "message-1" })); + await waitFor(() => + expect(getMessagesMock).toHaveBeenCalledWith("session-1"), + ); + expect(source.close).toHaveBeenCalled(); + }); + + it("surfaces a connection error when the stream closes without retrying", async () => { + // Given + const user = userEvent.setup(); + renderPage(); + await user.type( + screen.getByRole("textbox", { name: "Message" }), + ["Summarize findings", "{Enter}"].join(""), + ); + await waitFor(() => expect(eventSources).toHaveLength(1)); + + // When the EventSource fails terminally (e.g. 401/404 on the SSE GET) + act(() => eventSources[0].fail(2 /* EventSource.CLOSED */)); + + // Then a clear error is shown instead of an endless "Reconnecting" spinner + expect( + await screen.findByText("Unable to connect to the response stream."), + ).toBeInTheDocument(); + }); +}); + +type RenderPageProps = Partial[0]>; + +function renderPage(props?: RenderPageProps) { + const componentProps = { + configurations: props?.configurations ?? configurations, + modelsByProvider: props?.modelsByProvider ?? modelsByProvider, + initialSessionId: props?.initialSessionId, + initialMessages: props?.initialMessages ?? [], + initialPrompt: props?.initialPrompt, + initialActiveTaskId: props?.initialActiveTaskId, + initialStreamUrl: props?.initialStreamUrl, + } satisfies Parameters[0]; + + return render(); +} + +function model(id: string): LighthouseV2SupportedModel { + return { + id, + maxInputTokens: null, + maxOutputTokens: null, + supportsFunctionCalling: null, + supportsVision: null, + supportsReasoning: null, + }; +} + +function message( + id: string, + role: LighthouseV2Message["role"], + content: string, +): LighthouseV2Message { + return { + id, + role, + model: null, + tokenUsage: null, + insertedAt: "2026-06-25T10:00:00Z", + parts: [ + { + id: `${id}-part`, + type: "text", + content, + toolCallOutcome: null, + insertedAt: "2026-06-25T10:00:00Z", + updatedAt: "2026-06-25T10:00:00Z", + }, + ], + }; +} diff --git a/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx similarity index 65% rename from ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx rename to ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx index f3677bb5c1..43cc82ccb5 100644 --- a/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx @@ -1,9 +1,12 @@ "use client"; +import { format } from "date-fns"; import { ArrowRight, BookOpen, Bot, + Check, + Copy, FileCheck2, Loader2, Network, @@ -11,9 +14,9 @@ import { ShieldAlert, Square, UserRound, + Wrench, } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { type FormEvent, useRef, useState } from "react"; import { @@ -21,23 +24,26 @@ import { createLighthouseV2Session, getLighthouseV2Messages, sendLighthouseV2Message, -} from "@/actions/lighthouse-v2/lighthouse-v2"; +} from "@/app/(prowler)/lighthouse/_actions"; +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtStep, +} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought"; import { Conversation, ConversationContent, ConversationScrollButton, -} from "@/components/ai-elements/conversation"; -import { LighthouseIcon } from "@/components/icons/Icons"; -import { Button } from "@/components/shadcn/button/button"; -import { Textarea } from "@/components/shadcn/textarea/textarea"; -import { useMountEffect } from "@/hooks/use-mount-effect"; +} from "@/app/(prowler)/lighthouse/_components/ai-elements/conversation"; import { createInitialLighthouseV2StreamState, + LIGHTHOUSE_V2_STREAM_STATUS, type LighthouseV2StreamState, reduceLighthouseV2Event, -} from "@/lib/lighthouse-v2/event-reducer"; -import { notifyLighthouseV2SessionsChanged } from "@/lib/lighthouse-v2/session-events"; -import { cn } from "@/lib/utils"; +} from "@/app/(prowler)/lighthouse/_lib/event-reducer"; +import { notifyLighthouseV2SessionsChanged } from "@/app/(prowler)/lighthouse/_lib/session-events"; +import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url"; import { LIGHTHOUSE_V2_MESSAGE_ROLE, LIGHTHOUSE_V2_PART_TYPE, @@ -48,9 +54,13 @@ import { type LighthouseV2ProviderType, type LighthouseV2SSEEvent, type LighthouseV2SupportedModel, -} from "@/types/lighthouse-v2"; - -import { Card } from "../../shadcn"; +} from "@/app/(prowler)/lighthouse/_types"; +import { LighthouseIcon } from "@/components/icons/Icons"; +import { Card } from "@/components/shadcn"; +import { Button } from "@/components/shadcn/button/button"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { cn } from "@/lib/utils"; interface LighthouseV2ChatPageProps { configurations: LighthouseV2Configuration[]; @@ -60,6 +70,8 @@ interface LighthouseV2ChatPageProps { >; initialSessionId?: string; initialMessages: LighthouseV2Message[]; + initialActiveTaskId?: string | null; + initialStreamUrl?: string; initialPrompt?: string; } @@ -91,9 +103,10 @@ export function LighthouseV2ChatPage({ modelsByProvider, initialSessionId, initialMessages, + initialActiveTaskId, + initialStreamUrl, initialPrompt, }: LighthouseV2ChatPageProps) { - const router = useRouter(); const eventSourceRef = useRef(null); const initialPromptSentRef = useRef(false); const connectedConfigurations = configurations.filter( @@ -113,23 +126,27 @@ export function LighthouseV2ChatPage({ const [input, setInput] = useState(""); const [feedback, setFeedback] = useState(null); const [blockedByConflict, setBlockedByConflict] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [lastSubmittedText, setLastSubmittedText] = useState( null, ); const [streamState, setStreamState] = useState(() => - createInitialLighthouseV2StreamState(), + createInitialLighthouseV2StreamState(initialActiveTaskId ?? null), ); const canSend = selectedConfiguration?.connected === true && !streamState.activeTaskId && - !blockedByConflict; + !blockedByConflict && + !isSubmitting; - const refreshMessages = async (sessionId: string) => { + const refreshMessages = async (sessionId: string): Promise => { const result = await getLighthouseV2Messages(sessionId); if ("data" in result) { setMessages(result.data); + return true; } + return false; }; const closeStream = () => { @@ -148,7 +165,13 @@ export function LighthouseV2ChatPage({ ) { closeStream(); setBlockedByConflict(false); - await refreshMessages(sessionId); + if (event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR) { + setFeedback(event.detail || "Agent run failed."); + } + const refreshed = await refreshMessages(sessionId); + if (refreshed) { + setStreamState(createInitialLighthouseV2StreamState()); + } notifyLighthouseV2SessionsChanged(); } }; @@ -191,13 +214,19 @@ export function LighthouseV2ChatPage({ applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.ERROR)); } }); + // The browser fires `onerror` both on a transient drop (it auto-reconnects) + // and on a non-retryable failure such as a 401/404 on the SSE GET. Only the + // latter leaves the source CLOSED, so surface a connection error there and + // treat everything else as a reconnect. source.onerror = () => { - closeStream(); + if (eventSourceRef.current !== source) return; + if (source.readyState === EventSource.CLOSED) { + closeStream(); + setFeedback("Unable to connect to the response stream."); + } setStreamState((current) => reduceLighthouseV2Event(current, { type: "disconnect" }), ); - void refreshMessages(sessionId); - setFeedback("Stream disconnected. Messages were refreshed."); }; }; @@ -215,7 +244,14 @@ export function LighthouseV2ChatPage({ setActiveSessionId(result.data.id); notifyLighthouseV2SessionsChanged(); - router.push(`/lighthouse?session=${encodeURIComponent(result.data.id)}`); + // Update the URL in place (not router.push) so the force-dynamic server + // component is NOT re-run mid-submit. A re-run would change `key` in + // page.tsx and remount this component, tearing down the open EventSource. + window.history.replaceState( + null, + "", + `/lighthouse?session=${encodeURIComponent(result.data.id)}`, + ); return result.data.id; }; @@ -223,39 +259,54 @@ export function LighthouseV2ChatPage({ const trimmedText = text.trim(); if (!trimmedText || !canSend) return; - const sessionId = await ensureSession(trimmedText); - if (!sessionId) return; + setIsSubmitting(true); + try { + const sessionId = await ensureSession(trimmedText); + if (!sessionId) return; - setFeedback(null); - setBlockedByConflict(false); - setLastSubmittedText(trimmedText); - setInput(""); - setMessages((current) => [ - ...current, - buildOptimisticMessage("user", trimmedText), - ]); + const provisionalTaskId = `pending-${Date.now()}`; + setFeedback(null); + setBlockedByConflict(false); + setLastSubmittedText(trimmedText); + setInput(""); + setMessages((current) => [ + ...current, + buildOptimisticMessage("user", trimmedText), + ]); + setStreamState(createInitialLighthouseV2StreamState(provisionalTaskId)); - const result = await sendLighthouseV2Message({ - sessionId, - text: trimmedText, - provider: selectedProvider, - model: selectedModel || null, - }); + // Subscribe to the same-origin SSE proxy BEFORE sending the message: the + // backend has no replay buffer, so the listener must be attached before + // the worker starts emitting. + startStream(buildLighthouseV2StreamUrl(sessionId), sessionId); - if ("error" in result) { - setFeedback(result.error); - if (result.status === 409) { - setBlockedByConflict(true); - await refreshMessages(sessionId); + const result = await sendLighthouseV2Message({ + sessionId, + text: trimmedText, + provider: selectedProvider, + model: selectedModel || null, + }); + + if ("error" in result) { + closeStream(); + setStreamState(createInitialLighthouseV2StreamState()); + setFeedback(result.error); + if (result.status === 409) { + setBlockedByConflict(true); + await refreshMessages(sessionId); + } + return; } - return; - } - setStreamState(createInitialLighthouseV2StreamState(result.data.task.id)); - if (result.data.streamUrl) { - startStream(result.data.streamUrl, sessionId); + setStreamState((current) => + current.activeTaskId === provisionalTaskId + ? { ...current, activeTaskId: result.data.task.id } + : current, + ); + notifyLighthouseV2SessionsChanged(); + } finally { + setIsSubmitting(false); } - notifyLighthouseV2SessionsChanged(); }; const handleSubmit = (event: FormEvent) => { @@ -283,6 +334,10 @@ export function LighthouseV2ChatPage({ }; useMountEffect(() => { + if (initialSessionId && initialActiveTaskId && initialStreamUrl) { + startStream(initialStreamUrl, initialSessionId); + } + return () => closeStream(); }); @@ -293,8 +348,11 @@ export function LighthouseV2ChatPage({ } }); - const hasConversation = - messages.length > 0 || Boolean(streamState.assistantText); + const hasLiveAssistantActivity = + Boolean(streamState.activeTaskId) || + Boolean(streamState.assistantText) || + streamState.toolCalls.length > 0; + const hasConversation = messages.length > 0 || hasLiveAssistantActivity; return ( ( ))} - {streamState.assistantText && ( + {hasLiveAssistantActivity && ( )} @@ -525,30 +583,59 @@ function LighthouseV2Composer({ function MessageBubble({ message }: { message: LighthouseV2Message }) { const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER; + const textParts = message.parts.filter( + (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT, + ); + const toolCallCount = message.parts.filter( + (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL, + ).length; + const messageText = textParts + .map((part) => getTextContent(part.content)) + .filter(Boolean) + .join("\n\n"); + return (
{!isUser && }
- {message.parts.map((part) => ( -
- {part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT ? ( -

- {getTextContent(part.content)} -

- ) : ( -

{part.type}

- )} -
- ))} +
+ {toolCallCount > 0 && ( +

+ + {toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called +

+ )} + {textParts.map((part) => ( +

+ {getTextContent(part.content)} +

+ ))} +
+
{isUser && ( @@ -557,37 +644,156 @@ function MessageBubble({ message }: { message: LighthouseV2Message }) { ); } +function MessageMeta({ + isUser, + text, + insertedAt, +}: { + isUser: boolean; + text: string; + insertedAt: string; +}) { + // Copy is always shown; the timestamp only reveals on hover over the message. + // Agent footer reads left-to-right ([copy] [time]); user footer mirrors it. + return ( +
+ + +
+ ); +} + +function CopyMessageButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can reject (e.g. permissions); nothing to recover. + } + }; + + return ( + + ); +} + +function formatMessageTimestamp(insertedAt: string): string { + const date = new Date(insertedAt); + if (Number.isNaN(date.getTime())) { + return ""; + } + return format(date, "EEEE h:mm a"); +} + function StreamingAssistantMessage({ streamState, }: { streamState: LighthouseV2StreamState; }) { + const hasActivity = + Boolean(streamState.activeTaskId) || streamState.toolCalls.length > 0; + return (
-

{streamState.assistantText}

- {streamState.toolCalls.length > 0 && ( -
- {streamState.toolCalls.map((toolCall) => ( -
- {toolCall.status === "running" && ( - - )} - {toolCall.name} - {toolCall.outcome && {toolCall.outcome}} -
- ))} -
+ {hasActivity && } + {streamState.assistantText && ( +

+ {streamState.assistantText} +

)}
); } +function StreamingActivity({ + streamState, +}: { + streamState: LighthouseV2StreamState; +}) { + const hasAssistantText = Boolean(streamState.assistantText); + const hasToolCalls = streamState.toolCalls.length > 0; + const isDisconnected = + streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED; + const thinkingStatus = + hasAssistantText || hasToolCalls ? "complete" : "active"; + + return ( + + + + {getActivityHeader(streamState)} + + + + + {isDisconnected && ( + + )} + {streamState.toolCalls.map((toolCall) => ( + + ))} + + + ); +} + +function getActivityHeader(streamState: LighthouseV2StreamState): string { + if (streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED) { + return "Reconnecting"; + } + if (streamState.toolCalls.some((toolCall) => toolCall.status === "running")) { + return "Using tools"; + } + return "Thinking"; +} + +function getToolCallLabel( + toolCall: LighthouseV2StreamState["toolCalls"][number], +): string { + return `${toolCall.status === "running" ? "Calling" : "Called"} ${ + toolCall.name + }`; +} + function parseStreamEvent( event: Event, type: LighthouseV2SSEEvent["type"], diff --git a/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.test.tsx b/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.test.tsx deleted file mode 100644 index 9318ce7305..0000000000 --- a/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.test.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { - LighthouseV2Configuration, - LighthouseV2Message, - LighthouseV2SupportedModel, -} from "@/types/lighthouse-v2"; - -import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page"; - -const { - cancelRunMock, - createSessionMock, - getMessagesMock, - pushMock, - sendMessageMock, -} = vi.hoisted(() => ({ - cancelRunMock: vi.fn(), - createSessionMock: vi.fn(), - getMessagesMock: vi.fn(), - pushMock: vi.fn(), - sendMessageMock: vi.fn(), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: pushMock, - }), -})); - -vi.mock("@/actions/lighthouse-v2/lighthouse-v2", () => ({ - cancelLighthouseV2Run: cancelRunMock, - createLighthouseV2Session: createSessionMock, - getLighthouseV2Messages: getMessagesMock, - sendLighthouseV2Message: sendMessageMock, -})); - -const configurations: LighthouseV2Configuration[] = [ - { - id: "config-openai", - providerType: "openai", - baseUrl: null, - defaultModel: "gpt-5.1", - businessContext: "Production account", - connected: true, - connectionLastCheckedAt: "2026-06-24T10:00:00Z", - insertedAt: "2026-06-24T09:00:00Z", - updatedAt: "2026-06-24T10:00:00Z", - }, - { - id: "config-bedrock", - providerType: "bedrock", - baseUrl: null, - defaultModel: "anthropic.claude-4", - businessContext: "AWS landing zone", - connected: true, - connectionLastCheckedAt: "2026-06-23T10:00:00Z", - insertedAt: "2026-06-23T09:00:00Z", - updatedAt: "2026-06-23T10:00:00Z", - }, -]; - -const modelsByProvider = { - openai: [model("gpt-5.1")], - bedrock: [model("anthropic.claude-4")], - "openai-compatible": [model("llama-3.3")], -}; - -describe("LighthouseV2ChatPage", () => { - beforeEach(() => { - vi.stubGlobal( - "ResizeObserver", - class ResizeObserver { - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - }, - ); - cancelRunMock.mockReset(); - createSessionMock.mockReset(); - getMessagesMock.mockReset(); - pushMock.mockReset(); - sendMessageMock.mockReset(); - - createSessionMock.mockResolvedValue({ - data: { - id: "session-1", - title: "Summarize findings", - isArchived: false, - insertedAt: "2026-06-24T10:00:00Z", - updatedAt: "2026-06-24T10:00:00Z", - activeTaskId: null, - }, - }); - getMessagesMock.mockResolvedValue({ data: [] }); - sendMessageMock.mockResolvedValue({ - data: { - task: { - id: "task-1", - name: "lighthouse-run", - state: "executing", - }, - }, - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("does not render provider or model selectors in the chat composer", () => { - // Given / When - renderPage(); - - // Then - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - expect( - screen.getByRole("link", { name: "Lighthouse settings" }), - ).toHaveAttribute("href", "/lighthouse/settings"); - }); - - it("uses the tuned scrollbar and bottom fade without a composer separator", () => { - // Given / When - const { container } = renderPage({ - initialMessages: [message("message-1", "assistant", "Existing answer")], - }); - - // Then - const conversation = screen.getByRole("log"); - const scrollViewport = conversation.firstElementChild as HTMLElement; - const content = scrollViewport.firstElementChild as HTMLElement; - const scrollFade = container.querySelector( - '[data-slot="lighthouse-v2-chat-scroll-fade"]', - ); - - expect(conversation).toHaveClass("h-full", "min-h-0"); - expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden"); - expect(scrollViewport).toHaveClass( - "minimal-scrollbar", - "overflow-x-hidden", - "overflow-y-auto", - ); - expect(content).toHaveClass("pb-20"); - expect(scrollFade).toHaveClass( - "pointer-events-none", - "absolute", - "bottom-0", - "right-2", - "h-16", - "bg-gradient-to-t", - "from-bg-neutral-secondary", - "to-transparent", - ); - expect( - container.querySelector( - '[data-slot="lighthouse-v2-chat-composer-panel"]', - ), - ).not.toHaveClass("border-t"); - }); - - it("sends messages with the connected default provider and model from configuration", async () => { - // Given - const user = userEvent.setup(); - renderPage(); - - // When - await user.type( - screen.getByRole("textbox", { name: "Message" }), - ["Summarize findings", "{Enter}"].join(""), - ); - - // Then - await waitFor(() => - expect(sendMessageMock).toHaveBeenCalledWith({ - sessionId: "session-1", - text: "Summarize findings", - provider: "openai", - model: "gpt-5.1", - }), - ); - expect(createSessionMock).toHaveBeenCalledWith("Summarize findings"); - expect(pushMock).toHaveBeenCalledWith("/lighthouse?session=session-1"); - }); - - it("closes an active stream when the chat unmounts", async () => { - // Given - const user = userEvent.setup(); - const closeMock = vi.fn(); - const eventSourceMock = vi.fn(function MockEventSource( - this: Record, - ) { - this.addEventListener = vi.fn(); - this.close = closeMock; - }); - vi.stubGlobal("EventSource", eventSourceMock); - sendMessageMock.mockResolvedValue({ - data: { - task: { - id: "task-1", - name: "lighthouse-run", - state: "executing", - }, - streamUrl: "/api/stream", - }, - }); - const { unmount } = renderPage({ initialSessionId: "session-1" }); - - // When - await user.type( - screen.getByRole("textbox", { name: "Message" }), - ["Summarize findings", "{Enter}"].join(""), - ); - await waitFor(() => - expect(eventSourceMock).toHaveBeenCalledWith("/api/stream"), - ); - unmount(); - - // Then - expect(closeMock).toHaveBeenCalledTimes(1); - }); -}); - -function renderPage( - props?: Partial[0]>, -) { - return render( - , - ); -} - -function model(id: string): LighthouseV2SupportedModel { - return { - id, - maxInputTokens: null, - maxOutputTokens: null, - supportsFunctionCalling: null, - supportsVision: null, - supportsReasoning: null, - }; -} - -function message( - id: string, - role: LighthouseV2Message["role"], - content: string, -): LighthouseV2Message { - return { - id, - role, - model: null, - tokenUsage: null, - insertedAt: "2026-06-25T10:00:00Z", - parts: [ - { - id: `${id}-part`, - type: "text", - content, - toolCallOutcome: null, - insertedAt: "2026-06-25T10:00:00Z", - updatedAt: "2026-06-25T10:00:00Z", - }, - ], - }; -}