fix(ui): simplify Lighthouse chat composer

This commit is contained in:
alejandrobailo
2026-06-25 13:07:31 +02:00
parent 14edac25a1
commit 508720679a
2 changed files with 187 additions and 100 deletions
@@ -0,0 +1,176 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
LighthouseV2Configuration,
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",
},
},
});
});
it("uses the neutral page background instead of the global app background token", () => {
// Given / When
const { container } = renderPage();
// Then
expect(container.firstElementChild).toHaveClass("bg-bg-neutral-primary");
expect(container.firstElementChild).not.toHaveClass("bg-background");
});
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/config");
});
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");
});
});
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,
};
}
@@ -29,13 +29,6 @@ import {
} from "@/components/ai-elements/conversation";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Button } from "@/components/shadcn/button/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
@@ -104,17 +97,13 @@ export function LighthouseV2ChatPage({
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
const initialProvider =
connectedConfigurations[0]?.providerType ??
configurations[0]?.providerType ??
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
const [selectedProvider, setSelectedProvider] =
useState<LighthouseV2ProviderType>(initialProvider);
const [selectedModel, setSelectedModel] = useState(
connectedConfigurations[0]?.defaultModel ??
modelsByProvider[initialProvider]?.[0]?.id ??
"",
);
const selectedConfiguration = connectedConfigurations[0] ?? configurations[0];
const selectedProvider =
selectedConfiguration?.providerType ?? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
const selectedModel =
selectedConfiguration?.defaultModel ??
modelsByProvider[selectedProvider]?.[0]?.id ??
"";
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
@@ -129,25 +118,11 @@ export function LighthouseV2ChatPage({
createInitialLighthouseV2StreamState(),
);
const selectedConfiguration = configurations.find(
(configuration) => configuration.providerType === selectedProvider,
);
const providerModels = modelsByProvider[selectedProvider] ?? [];
const canSend =
selectedConfiguration?.connected === true &&
!streamState.activeTaskId &&
!blockedByConflict;
const handleProviderChange = (provider: LighthouseV2ProviderType) => {
const nextConfig = configurations.find(
(configuration) => configuration.providerType === provider,
);
setSelectedProvider(provider);
setSelectedModel(
nextConfig?.defaultModel ?? modelsByProvider[provider]?.[0]?.id ?? "",
);
};
const refreshMessages = async (sessionId: string) => {
const result = await getLighthouseV2Messages(sessionId);
if ("data" in result) {
@@ -316,7 +291,7 @@ export function LighthouseV2ChatPage({
messages.length > 0 || Boolean(streamState.assistantText);
return (
<section className="bg-background flex h-full min-h-0 flex-col">
<section className="bg-bg-neutral-primary flex h-full min-h-0 flex-col">
{hasConversation ? (
<div className="flex min-h-0 flex-1 flex-col">
<Conversation className="min-h-0">
@@ -330,7 +305,7 @@ export function LighthouseV2ChatPage({
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="bg-background px-4 pb-5 md:px-8">
<div className="bg-bg-neutral-primary px-4 pb-5 md:px-8">
<div className="mx-auto w-full max-w-4xl">
<LighthouseV2Feedback
feedback={feedback}
@@ -346,18 +321,12 @@ export function LighthouseV2ChatPage({
/>
<LighthouseV2Composer
canSend={canSend}
configurations={configurations}
input={input}
isStreaming={Boolean(streamState.activeTaskId)}
models={providerModels}
selectedConfigurationConnected={
selectedConfiguration?.connected === true
}
selectedModel={selectedModel}
selectedProvider={selectedProvider}
onInputChange={setInput}
onProviderChange={handleProviderChange}
onModelChange={setSelectedModel}
onStop={handleStop}
onSubmit={handleSubmit}
onSubmitText={submitMessage}
@@ -392,18 +361,12 @@ export function LighthouseV2ChatPage({
/>
<LighthouseV2Composer
canSend={canSend}
configurations={configurations}
input={input}
isStreaming={Boolean(streamState.activeTaskId)}
models={providerModels}
selectedConfigurationConnected={
selectedConfiguration?.connected === true
}
selectedModel={selectedModel}
selectedProvider={selectedProvider}
onInputChange={setInput}
onProviderChange={handleProviderChange}
onModelChange={setSelectedModel}
onStop={handleStop}
onSubmit={handleSubmit}
onSubmitText={submitMessage}
@@ -471,16 +434,10 @@ function LighthouseV2Feedback({
interface LighthouseV2ComposerProps {
canSend: boolean;
configurations: LighthouseV2Configuration[];
input: string;
isStreaming: boolean;
models: LighthouseV2SupportedModel[];
selectedConfigurationConnected: boolean;
selectedModel: string;
selectedProvider: LighthouseV2ProviderType;
onInputChange: (value: string) => void;
onModelChange: (value: string) => void;
onProviderChange: (provider: LighthouseV2ProviderType) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
@@ -488,23 +445,17 @@ interface LighthouseV2ComposerProps {
function LighthouseV2Composer({
canSend,
configurations,
input,
isStreaming,
models,
selectedConfigurationConnected,
selectedModel,
selectedProvider,
onInputChange,
onModelChange,
onProviderChange,
onStop,
onSubmit,
onSubmitText,
}: LighthouseV2ComposerProps) {
return (
<form
className="border-border-neutral-secondary bg-bg-neutral-primary flex min-h-[150px] w-full flex-col rounded-[8px] border shadow-xs"
className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-[150px] w-full flex-col rounded-[8px] border shadow-xs"
onSubmit={onSubmit}
>
<Textarea
@@ -527,47 +478,7 @@ function LighthouseV2Composer({
}
}}
/>
<div className="flex flex-wrap items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={selectedProvider}
onValueChange={(value) =>
onProviderChange(value as LighthouseV2ProviderType)
}
>
<SelectTrigger size="sm" iconSize="sm" className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{configurations.map((configuration) => (
<SelectItem
key={configuration.providerType}
value={configuration.providerType}
disabled={configuration.connected !== true}
>
{configuration.providerType}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedModel} onValueChange={onModelChange}>
<SelectTrigger size="sm" iconSize="sm" className="h-8 w-[220px]">
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent width="wide">
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link href="/lighthouse/config" aria-label="Lighthouse settings">
<Settings className="size-4" />
</Link>
</Button>
</div>
<div className="flex items-center justify-end px-3 pb-3">
{isStreaming ? (
<Button
type="button"