feat(ui): offer Lighthouse AI help in the add-provider wizard (#13178)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-27 16:55:26 +01:00
1 parent d7bffba4ac
commit 4eb468619f
50 files changed
+2214 -170

No files matched your search

@@ -0,0 +1,168 @@
import type { ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "@/__tests__/render-browser";
import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip";
import { getSkillById } from "@/lib/lighthouse/skills/registry";
import {
SIDE_PANEL_DEFAULT_WIDTH,
SIDE_PANEL_MIN_WIDTH,
} from "@/lib/ui-layout";
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
import { ChatComposerPanel } from "./composer";
import { CurrentModelDisplay } from "./current-model-display";
import { ModelSelect } from "./model-select";
import { SkillComposerPill } from "./skill-composer-pill";
const CONNECTION_GUIDE = getSkillById(
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
)!;
const PROVIDERS_CONTEXT: LighthouseContextEnvelope = {
schemaVersion: 1,
transport: "inline",
items: [
{
kind: "page",
id: "providers",
source: "automatic",
scopeKey: "providers:/providers",
label: "Providers",
path: "/providers",
filters: {},
},
],
};
// The two model controls the chat view can put in the footer.
const MODEL_CONTROLS = [
{
name: "the current model",
control: (
<CurrentModelDisplay
provider="openai"
providerName="OpenAI"
modelName="GPT-5.2"
/>
),
locate: () => page.getByLabelText("Current model: OpenAI GPT-5.2"),
// The part of the control that must stay readable.
readableText: () => page.getByText("OpenAI", { exact: true }),
},
{
name: "the model picker",
control: (
<ModelSelect
value="openai:gpt-5.2-codex-max"
onValueChange={() => {}}
groups={[
{
heading: "OpenAI",
options: [
{ value: "openai:gpt-5.2-codex-max", label: "GPT-5.2 Codex Max" },
],
},
]}
isSaving={false}
/>
),
locate: () => page.getByRole("combobox", { name: "Model" }),
readableText: () => page.getByText("GPT-5.2 Codex Max"),
},
];
const PANEL_WIDTHS = [
{ panel: "the narrowest", width: SIDE_PANEL_MIN_WIDTH },
{ panel: "the default", width: SIDE_PANEL_DEFAULT_WIDTH },
];
interface ComposerInPanelProps {
width: number;
modelSelector: ReactNode;
}
function ComposerInPanel({ width, modelSelector }: ComposerInPanelProps) {
return (
<div style={{ width }}>
<ChatComposerPanel
feedback={null}
canRetry={false}
onRetry={() => {}}
onDismissFeedback={() => {}}
canSend
input=""
isStreaming
modelSelector={modelSelector}
contextControl={
<>
<SkillComposerPill skill={CONNECTION_GUIDE} />
<LighthouseCurrentContextBadge context={PROVIDERS_CONTEXT} />
</>
}
selectedConfigurationConnected
onInputChange={() => {}}
onSubmit={(event) => event.preventDefault()}
onSubmitText={async () => {}}
/>
</div>
);
}
function overlaps(a: DOMRect, b: DOMRect) {
return (
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
);
}
function isInside(inner: DOMRect, outer: DOMRect) {
return inner.left >= outer.left && inner.right <= outer.right;
}
describe("Lighthouse composer footer", () => {
it.each(
MODEL_CONTROLS.flatMap((model) =>
PANEL_WIDTHS.map((panel) => ({ ...model, ...panel })),
),
)(
"keeps $name, the running skill and the context apart in $panel side panel",
async ({ control, locate, readableText, width }) => {
// Given / When: a skill runs while the chat sits in the side panel
render(<ComposerInPanel width={width - 32} modelSelector={control} />);
const model = locate();
await expect.element(model).toBeVisible();
// Then: no control covers another or leaves the composer
const composerBox = page
.getByLabelText("Message")
.element()
.closest("form")!
.getBoundingClientRect();
const modelBox = model.element().getBoundingClientRect();
const skillBox = page
.getByText("Skill · Connection Guide")
.element()
.closest('[role="status"]')!
.getBoundingClientRect();
const contextBox = page
.getByLabelText("Providers context")
.element()
.getBoundingClientRect();
expect(overlaps(modelBox, skillBox)).toBe(false);
expect(overlaps(modelBox, contextBox)).toBe(false);
expect(overlaps(skillBox, contextBox)).toBe(false);
for (const box of [modelBox, skillBox, contextBox]) {
expect(isInside(box, composerBox)).toBe(true);
}
// And the model stays readable: whole, and inside its own control
const text = readableText().element() as HTMLElement;
expect(text.scrollWidth).toBeLessThanOrEqual(text.clientWidth);
expect(text.getBoundingClientRect().right).toBeLessThanOrEqual(
modelBox.right,
);
},
);
});
@@ -167,8 +167,9 @@ function ChatComposer({
}
}}
/>
<div className="flex items-center justify-between gap-3 px-3 pb-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex items-end justify-between gap-3 px-3 pb-3">
{/* Chips wrap to a new line instead of squeezing the model control. */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link
href={LIGHTHOUSE_ROUTE.SETTINGS}
@@ -178,7 +179,11 @@ function ChatComposer({
</Link>
</Button>
{modelSelector}
{contextControl}
{contextControl && (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{contextControl}
</div>
)}
</div>
{isStreaming ? (
<div
@@ -0,0 +1,33 @@
import type { LighthouseV2ProviderType } from "@/app/(prowler)/lighthouse/_types";
import { ProviderIcon } from "../config/provider-icon";
interface CurrentModelDisplayProps {
provider: LighthouseV2ProviderType;
providerName: string;
modelName: string;
}
export function CurrentModelDisplay({
provider,
providerName,
modelName,
}: CurrentModelDisplayProps) {
return (
<div
aria-label={`Current model: ${providerName} ${modelName}`}
className="border-border-neutral-secondary bg-bg-neutral-secondary flex h-8 max-w-48 min-w-0 items-center gap-2 overflow-hidden rounded-lg border px-2.5 text-sm"
>
<ProviderIcon
provider={provider}
className="text-text-neutral-secondary size-3.5 shrink-0"
/>
<span className="text-text-neutral-tertiary shrink-0">
{providerName}
</span>
<span className="text-text-neutral-primary min-w-0 truncate font-medium">
{modelName}
</span>
</div>
);
}
@@ -29,17 +29,13 @@ import {
} from "@/app/(prowler)/lighthouse/_types";
import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip";
import { Card } from "@/components/shadcn";
import {
Combobox,
type ComboboxGroup,
} from "@/components/shadcn/combobox/combobox";
import { type ComboboxGroup } from "@/components/shadcn/combobox/combobox";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { ProviderIcon } from "../config/provider-icon";
import { ChatComposerPanel } from "./composer";
import { CurrentModelDisplay } from "./current-model-display";
import { ChatEmptyState } from "./empty-state";
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
import {
@@ -47,6 +43,7 @@ import {
type LighthouseFeedbackSurvey,
} from "./lighthouse-feedback-survey";
import { MessageBubble } from "./message-bubble";
import { ModelSelect } from "./model-select";
import { SkillComposerPill } from "./skill-composer-pill";
import { SkillRunProgress } from "./skill-run-progress";
import { StreamingAssistantMessage } from "./streaming-message";
@@ -183,19 +180,12 @@ export function LighthouseV2ChatView({
modelName={selectedModelLabel}
/>
) : (
<div className="min-w-0 flex-1 sm:max-w-48">
<Combobox
aria-label="Model"
value={selectedModelValue}
onValueChange={handleModelValueChange}
groups={modelSelectorGroups}
disabled={modelSelectorGroups.length === 0 || modelPreferenceSaving}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
size="sm"
/>
</div>
<ModelSelect
value={selectedModelValue}
onValueChange={handleModelValueChange}
groups={modelSelectorGroups}
isSaving={modelPreferenceSaving}
/>
),
selectedConfigurationConnected: selectedConfiguration?.connected === true,
onInputChange: setInput,
@@ -323,36 +313,6 @@ function SessionLoadingState() {
);
}
interface CurrentModelDisplayProps {
provider: LighthouseV2ProviderType;
providerName: string;
modelName: string;
}
function CurrentModelDisplay({
provider,
providerName,
modelName,
}: CurrentModelDisplayProps) {
return (
<div
aria-label={`Current model: ${providerName} ${modelName}`}
className="border-border-neutral-secondary bg-bg-neutral-secondary flex h-8 max-w-48 min-w-0 items-center gap-2 rounded-lg border px-2.5 text-sm"
>
<ProviderIcon
provider={provider}
className="text-text-neutral-secondary size-3.5 shrink-0"
/>
<span className="text-text-neutral-tertiary shrink-0">
{providerName}
</span>
<span className="text-text-neutral-primary min-w-0 truncate font-medium">
{modelName}
</span>
</div>
);
}
function buildModelSelectorGroups(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
@@ -0,0 +1,35 @@
import {
Combobox,
type ComboboxGroup,
} from "@/components/shadcn/combobox/combobox";
interface ModelSelectProps {
value: string;
onValueChange: (value: string) => void;
groups: ComboboxGroup[];
isSaving: boolean;
}
// Keeps a minimum width so the composer chips wrap instead of squeezing it.
export function ModelSelect({
value,
onValueChange,
groups,
isSaving,
}: ModelSelectProps) {
return (
<div className="min-w-36 flex-1 sm:max-w-48">
<Combobox
aria-label="Model"
value={value}
onValueChange={onValueChange}
groups={groups}
disabled={groups.length === 0 || isSaving}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
size="sm"
/>
</div>
);
}
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
import { SkillMessageCard } from "./skill-message-card";
describe("SkillMessageCard", () => {
it("names the provider and wizard step a connection guide was asked for", () => {
// Given
const context: LighthouseContextEnvelope = {
schemaVersion: 1,
transport: "inline",
items: [
{
kind: "provider",
id: "provider-wizard",
source: "focused",
scopeKey: "providers:wizard",
label: "Amazon Web Services · Link a Provider",
providerType: "aws",
},
],
};
// When
render(
<SkillMessageCard
skillRef={{
skillId: "provider-connection-guide",
name: "Connection Guide",
version: 1,
}}
context={context}
/>,
);
// Then
expect(screen.getByText("Connection Guide")).toBeInTheDocument();
expect(
screen.getByText("Amazon Web Services · Link a Provider"),
).toBeInTheDocument();
});
});
@@ -18,8 +18,11 @@ interface SkillMessageCardProps {
export function SkillMessageCard({ skillRef, context }: SkillMessageCardProps) {
const definition = getSkillById(skillRef.skillId);
const Icon = definition?.icon ?? Sparkles;
const findingLabel = context?.items.find(
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING,
// The subject the skill ran on: a finding, or the provider being connected.
const subjectLabel = context?.items.find(
(item) =>
item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING ||
item.kind === LIGHTHOUSE_CONTEXT_KIND.PROVIDER,
)?.label;
return (
@@ -34,9 +37,9 @@ export function SkillMessageCard({ skillRef, context }: SkillMessageCardProps) {
{skillRef.name}
</span>
</span>
{findingLabel && (
{subjectLabel && (
<span className="text-text-neutral-secondary truncate text-xs">
{findingLabel}
{subjectLabel}
</span>
)}
</div>
@@ -20,6 +20,7 @@ import {
} from "@/lib/lighthouse/context/contributions";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
import { useSidePanelStore } from "@/store/side-panel";
import type { LighthouseContextEnvelope } from "@/types/lighthouse-context";
import {
@@ -112,6 +113,7 @@ describe("LighthousePanelChat", () => {
resetPanelChatStoreForTests();
resetPanelChatConfigCacheForTests();
resetLighthouseContextStore();
useSidePanelStore.setState({ modalHostCount: 0 });
getConfigurationsMock.mockResolvedValue({ data: configurations });
getSupportedProvidersMock.mockResolvedValue({
@@ -182,6 +184,30 @@ describe("LighthousePanelChat", () => {
).toHaveAttribute("href", "/lighthouse/settings");
});
it("opens its links in a new tab while a dialog shares the screen with the panel", async () => {
// Given: the add-provider wizard is open beside the panel
getConfigurationsMock.mockResolvedValue({
data: [{ ...configurations[0], connected: false }],
});
useSidePanelStore.getState().registerModalHost();
// When
render(
<>
<LighthousePanelHeaderActions />
<LighthousePanelChat />
</>,
);
// Then: following a link never navigates the wizard away
expect(
await screen.findByRole("link", { name: "Connect an LLM provider" }),
).toHaveAttribute("target", "_blank");
expect(
screen.getByRole("link", { name: "Open Lighthouse AI full page" }),
).toHaveAttribute("target", "_blank");
});
it("renders the chat composer and recent chats once ready", async () => {
// Given
getSessionsMock.mockResolvedValue({
@@ -45,6 +45,7 @@ import {
} from "../chat/lighthouse-v2-chat-view";
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
import { usePanelLinkTarget } from "./use-panel-link-target";
const PANEL_CHAT_STATUS = {
LOADING: "loading",
@@ -301,6 +302,7 @@ function PanelChatError({ message, onRetry }: PanelChatErrorProps) {
}
function PanelChatConnectCta() {
const linkTarget = usePanelLinkTarget();
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<LighthouseIconWithAura className="size-16" />
@@ -314,7 +316,9 @@ function PanelChatConnectCta() {
</p>
</div>
<Button asChild>
<Link href={LIGHTHOUSE_ROUTE.SETTINGS}>Connect an LLM provider</Link>
<Link href={LIGHTHOUSE_ROUTE.SETTINGS} {...linkTarget}>
Connect an LLM provider
</Link>
</Button>
</div>
);
@@ -19,6 +19,8 @@ import {
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { cn } from "@/lib/utils";
import { usePanelLinkTarget } from "./use-panel-link-target";
export function LighthousePanelHeaderActions() {
const hasMessages = useSyncExternalStore(
subscribePanelChatHasMessages,
@@ -30,6 +32,7 @@ export function LighthousePanelHeaderActions() {
getPanelChatActiveSessionId,
() => null,
);
const linkTarget = usePanelLinkTarget();
const fullPageHref = activeSessionId
? `${LIGHTHOUSE_ROUTE.CHAT}?session=${encodeURIComponent(activeSessionId)}`
: LIGHTHOUSE_ROUTE.CHAT;
@@ -62,7 +65,11 @@ export function LighthousePanelHeaderActions() {
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button asChild variant="ghost" size="icon-sm">
<Link href={fullPageHref} aria-label="Open Lighthouse AI full page">
<Link
href={fullPageHref}
aria-label="Open Lighthouse AI full page"
{...linkTarget}
>
<Maximize2 />
</Link>
</Button>
@@ -0,0 +1,15 @@
"use client";
import { selectHasModalHost, useSidePanelStore } from "@/store/side-panel";
const NEW_TAB_LINK_PROPS = {
target: "_blank",
rel: "noopener noreferrer",
} as const;
// While a dialog shares the screen with the panel, panel links open in a new
// tab so following one never navigates that dialog away.
export function usePanelLinkTarget() {
const hasModalHost = useSidePanelStore(selectHasModalHost);
return hasModalHost ? NEW_TAB_LINK_PROPS : {};
}