mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 12:24:45 +00:00
feat(ui): offer Lighthouse AI help in the add-provider wizard (#13178)
This commit is contained in:
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 : {};
|
||||
}
|
||||
Reference in new issue
Block a user