feat(ui): polish lighthouse v2 chat messages

Collapse the chain-of-thought by default with a pulsing Thinking header,
summarize tool calls as 'N tools called', and hide success outcomes.
Add a copy button and hover timestamps under each message (agent left,
user right).

Stream client: subscribe before POST without the open-event gate
(avoids a deadlock) and set the session URL with history.replaceState
instead of router.push (avoids remounting mid-stream).
This commit is contained in:
alejandrobailo
2026-06-25 19:04:36 +02:00
parent 23b8b6a655
commit cc1dd9b581
3 changed files with 660 additions and 356 deletions
@@ -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<string, Set<EventListener>>;
addEventListener: (type: string, cb: EventListener) => void;
close: ReturnType<typeof vi.fn>;
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<EventListener>();
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<Parameters<typeof LighthouseV2ChatPage>[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<typeof LighthouseV2ChatPage>[0];
return render(<LighthouseV2ChatPage {...componentProps} />);
}
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",
},
],
};
}
@@ -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<EventSource | null>(null);
const initialPromptSentRef = useRef(false);
const connectedConfigurations = configurations.filter(
@@ -113,23 +126,27 @@ export function LighthouseV2ChatPage({
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [blockedByConflict, setBlockedByConflict] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
null,
);
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
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<boolean> => {
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<HTMLFormElement>) => {
@@ -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 (
<Card
@@ -312,7 +370,7 @@ export function LighthouseV2ChatPage({
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{streamState.assistantText && (
{hasLiveAssistantActivity && (
<StreamingAssistantMessage streamState={streamState} />
)}
</ConversationContent>
@@ -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 (
<article
className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}
className={cn(
"group flex gap-3",
isUser ? "justify-end" : "justify-start",
)}
>
{!isUser && <Bot className="text-text-neutral-tertiary mt-1 size-5" />}
<div
className={cn(
"max-w-[min(760px,85%)] rounded-[8px] px-4 py-3 text-sm",
isUser
? "bg-button-primary text-black"
: "bg-bg-neutral-tertiary text-text-neutral-primary",
"flex max-w-[min(760px,85%)] flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
{message.parts.map((part) => (
<div key={part.id || `${message.id}-${part.type}`}>
{part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT ? (
<p className="whitespace-pre-wrap">
{getTextContent(part.content)}
</p>
) : (
<p className="text-text-neutral-secondary text-xs">{part.type}</p>
)}
</div>
))}
<div
className={cn(
"rounded-[8px] px-4 py-3 text-sm",
isUser
? "bg-button-primary text-black"
: "bg-bg-neutral-tertiary text-text-neutral-primary",
)}
>
{toolCallCount > 0 && (
<p className="text-text-neutral-secondary mb-2 flex items-center gap-1.5 text-xs">
<Wrench className="size-3.5" />
{toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called
</p>
)}
{textParts.map((part) => (
<p
key={part.id || `${message.id}-text`}
className="whitespace-pre-wrap"
>
{getTextContent(part.content)}
</p>
))}
</div>
<MessageMeta
isUser={isUser}
text={messageText}
insertedAt={message.insertedAt}
/>
</div>
{isUser && (
<UserRound className="text-text-neutral-tertiary mt-1 size-5" />
@@ -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 (
<div
className={cn(
"flex items-center gap-1 px-1",
isUser && "flex-row-reverse",
)}
>
<CopyMessageButton text={text} />
<time
dateTime={insertedAt}
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
>
{formatMessageTimestamp(insertedAt)}
</time>
</div>
);
}
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 (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Copy message"
onClick={handleCopy}
className="text-text-neutral-tertiary hover:text-text-neutral-primary size-6"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
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 (
<article className="flex justify-start gap-3">
<Bot className="text-text-neutral-tertiary mt-1 size-5" />
<div className="bg-bg-neutral-tertiary text-text-neutral-primary max-w-[min(760px,85%)] rounded-[8px] px-4 py-3 text-sm">
<p className="whitespace-pre-wrap">{streamState.assistantText}</p>
{streamState.toolCalls.length > 0 && (
<div className="mt-3 grid gap-1">
{streamState.toolCalls.map((toolCall) => (
<div
key={toolCall.id}
className="text-text-neutral-secondary flex items-center gap-2 text-xs"
>
{toolCall.status === "running" && (
<Loader2 className="size-3 animate-spin" />
)}
<span>{toolCall.name}</span>
{toolCall.outcome && <span>{toolCall.outcome}</span>}
</div>
))}
</div>
{hasActivity && <StreamingActivity streamState={streamState} />}
{streamState.assistantText && (
<p className={cn("whitespace-pre-wrap", hasActivity && "mt-3")}>
{streamState.assistantText}
</p>
)}
</div>
</article>
);
}
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 (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className={cn(!isDisconnected && "animate-pulse")}>
{getActivityHeader(streamState)}
</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
<ChainOfThoughtStep
label="Preparing response"
status={thinkingStatus}
/>
{isDisconnected && (
<ChainOfThoughtStep label="Reconnecting stream" status="active" />
)}
{streamState.toolCalls.map((toolCall) => (
<ChainOfThoughtStep
key={toolCall.id}
description={
toolCall.outcome && toolCall.outcome.toLowerCase() !== "success"
? toolCall.outcome
: undefined
}
icon={toolCall.status === "running" ? Loader2 : undefined}
label={getToolCallLabel(toolCall)}
status={toolCall.status === "running" ? "active" : "complete"}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
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"],
@@ -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<string, unknown>,
) {
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<Parameters<typeof LighthouseV2ChatPage>[0]>,
) {
return render(
<LighthouseV2ChatPage
configurations={props?.configurations ?? configurations}
modelsByProvider={props?.modelsByProvider ?? modelsByProvider}
initialSessionId={props?.initialSessionId}
initialMessages={props?.initialMessages ?? []}
initialPrompt={props?.initialPrompt}
/>,
);
}
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",
},
],
};
}