fix(ui): make Lighthouse chat mode global

This commit is contained in:
alejandrobailo
2026-06-24 18:52:30 +02:00
parent c8a802ca30
commit c80dd98fde
13 changed files with 676 additions and 312 deletions
+17 -24
View File
@@ -7,12 +7,12 @@ import {
import {
getLighthouseV2Configurations,
getLighthouseV2Messages,
getLighthouseV2Sessions,
getLighthouseV2SupportedModels,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse-v1";
import { LighthouseV2ChatPage } from "@/components/lighthouse-v2/chat";
import { LighthouseV2NavigationModeSync } from "@/components/lighthouse-v2/navigation";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import type {
@@ -34,11 +34,7 @@ export default async function AIChatbot({
typeof params.session === "string" ? params.session : undefined;
if (isCloud()) {
const [configurationsResult, sessionsResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2Sessions(),
]);
const configurationsResult = await getLighthouseV2Configurations();
const configurations =
"data" in configurationsResult ? configurationsResult.data : [];
const connectedConfigurations = configurations.filter(
@@ -67,26 +63,23 @@ export default async function AIChatbot({
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
const initialMessages =
activeSessionId && "data" in sessionsResult
? await getLighthouseV2Messages(activeSessionId)
: { data: [] };
const initialMessages = activeSessionId
? await getLighthouseV2Messages(activeSessionId)
: { data: [] };
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8">
<LighthouseV2ChatPage
configurations={configurations}
modelsByProvider={modelsByProvider}
sessions={"data" in sessionsResult ? sessionsResult.data : []}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
}
initialPrompt={initialPrompt}
/>
</div>
</ContentLayout>
<div className="h-dvh min-h-0">
<LighthouseV2NavigationModeSync />
<LighthouseV2ChatPage
configurations={configurations}
modelsByProvider={modelsByProvider}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
}
initialPrompt={initialPrompt}
/>
</div>
);
}
@@ -1,23 +1,33 @@
"use client";
import { Bot, Loader2, Send, Square, UserRound } from "lucide-react";
import {
ArrowRight,
BookOpen,
Bot,
FileCheck2,
Loader2,
Network,
Settings,
ShieldAlert,
Square,
UserRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type FormEvent, useRef, useState } from "react";
import {
archiveLighthouseV2Session,
cancelLighthouseV2Run,
createLighthouseV2Session,
getLighthouseV2Messages,
getLighthouseV2Sessions,
sendLighthouseV2Message,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Button } from "@/components/shadcn/button/button";
import {
Select,
@@ -33,6 +43,7 @@ import {
type LighthouseV2StreamState,
reduceLighthouseV2Event,
} from "@/lib/lighthouse-v2/event-reducer";
import { notifyLighthouseV2SessionsChanged } from "@/lib/lighthouse-v2/session-events";
import { cn } from "@/lib/utils";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
@@ -42,34 +53,50 @@ import {
type LighthouseV2Configuration,
type LighthouseV2Message,
type LighthouseV2ProviderType,
type LighthouseV2Session,
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
} from "@/types/lighthouse-v2";
import { LighthouseV2SessionHistory } from "../history";
interface LighthouseV2ChatPageProps {
configurations: LighthouseV2Configuration[];
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
sessions: LighthouseV2Session[];
initialSessionId?: string;
initialMessages: LighthouseV2Message[];
initialPrompt?: string;
showHistory?: boolean;
}
const LIGHTHOUSE_V2_SUGGESTIONS = [
{
label: "Critical findings",
prompt: "Summarize my most critical open findings and what to fix first.",
icon: ShieldAlert,
},
{
label: "Compliance gaps",
prompt: "What are my highest-impact compliance gaps right now?",
icon: FileCheck2,
},
{
label: "Attack paths",
prompt: "Find risky attack paths and explain the exposure.",
icon: Network,
},
{
label: "Docs",
prompt: "Point me to the relevant Prowler documentation for this task.",
icon: BookOpen,
},
] as const;
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
sessions,
initialSessionId,
initialMessages,
initialPrompt,
showHistory = true,
}: LighthouseV2ChatPageProps) {
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
@@ -88,13 +115,11 @@ export function LighthouseV2ChatPage({
modelsByProvider[initialProvider]?.[0]?.id ??
"",
);
const [localSessions, setLocalSessions] = useState(sessions);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [search, setSearch] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [blockedByConflict, setBlockedByConflict] = useState(false);
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
@@ -130,15 +155,6 @@ export function LighthouseV2ChatPage({
}
};
const refreshSessions = async (nextSearch = search) => {
const result = await getLighthouseV2Sessions(
nextSearch ? { search: nextSearch } : undefined,
);
if ("data" in result) {
setLocalSessions(result.data);
}
};
const closeStream = () => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
@@ -156,7 +172,7 @@ export function LighthouseV2ChatPage({
closeStream();
setBlockedByConflict(false);
await refreshMessages(sessionId);
await refreshSessions();
notifyLighthouseV2SessionsChanged();
}
};
@@ -221,7 +237,7 @@ export function LighthouseV2ChatPage({
}
setActiveSessionId(result.data.id);
setLocalSessions((current) => [result.data, ...current]);
notifyLighthouseV2SessionsChanged();
router.push(`/lighthouse?session=${encodeURIComponent(result.data.id)}`);
return result.data.id;
};
@@ -262,7 +278,7 @@ export function LighthouseV2ChatPage({
if (result.data.streamUrl) {
startStream(result.data.streamUrl, sessionId);
}
await refreshSessions();
notifyLighthouseV2SessionsChanged();
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
@@ -283,51 +299,12 @@ export function LighthouseV2ChatPage({
);
setBlockedByConflict(false);
await refreshMessages(activeSessionId);
notifyLighthouseV2SessionsChanged();
if ("error" in result) {
setFeedback(result.error);
}
};
const handleOpenSession = async (sessionId: string) => {
closeStream();
setActiveSessionId(sessionId);
setStreamState(createInitialLighthouseV2StreamState());
setBlockedByConflict(false);
setFeedback(null);
router.push(`/lighthouse?session=${encodeURIComponent(sessionId)}`);
await refreshMessages(sessionId);
};
const handleNewSession = () => {
closeStream();
setActiveSessionId(null);
setMessages([]);
setInput("");
setFeedback(null);
setBlockedByConflict(false);
setStreamState(createInitialLighthouseV2StreamState());
router.push("/lighthouse");
};
const handleArchiveSession = async (sessionId: string) => {
const result = await archiveLighthouseV2Session(sessionId);
if ("error" in result) {
setFeedback(result.error);
return;
}
setLocalSessions((current) =>
current.filter((session) => session.id !== sessionId),
);
if (sessionId === activeSessionId) {
handleNewSession();
}
};
const handleSearchChange = (value: string) => {
setSearch(value);
void refreshSessions(value);
};
useMountEffect(() => {
if (initialPrompt && !initialPromptSentRef.current) {
initialPromptSentRef.current = true;
@@ -335,129 +312,285 @@ export function LighthouseV2ChatPage({
}
});
const hasConversation =
messages.length > 0 || Boolean(streamState.assistantText);
return (
<div className="grid h-full min-h-0 gap-4 lg:grid-cols-[300px_1fr]">
{showHistory && (
<LighthouseV2SessionHistory
sessions={localSessions}
activeSessionId={activeSessionId}
search={search}
onSearchChange={handleSearchChange}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onArchiveSession={handleArchiveSession}
/>
)}
<section className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-col rounded-[8px] border">
<Conversation className="min-h-0">
<ConversationContent>
{messages.length === 0 && !streamState.assistantText ? (
<ConversationEmptyState title="Lighthouse" description="" />
) : (
<>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{streamState.assistantText && (
<StreamingAssistantMessage streamState={streamState} />
)}
</>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="border-border-neutral-secondary border-t p-3">
{feedback && (
<div className="border-border-neutral-secondary mb-2 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{streamState.status === "disconnected" && lastSubmittedText && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => submitMessage(lastSubmittedText)}
>
Retry
</Button>
<section className="bg-background flex h-full min-h-0 flex-col">
{hasConversation ? (
<div className="flex min-h-0 flex-1 flex-col">
<Conversation className="min-h-0">
<ConversationContent className="mx-auto w-full max-w-4xl gap-5 px-4 py-8 md:px-8">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{streamState.assistantText && (
<StreamingAssistantMessage streamState={streamState} />
)}
</div>
)}
<div className="mb-2 flex flex-wrap gap-2">
<Select
value={selectedProvider}
onValueChange={(value) =>
handleProviderChange(value as LighthouseV2ProviderType)
}
>
<SelectTrigger className="h-9 w-[180px]">
<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={setSelectedModel}>
<SelectTrigger className="h-9 min-w-[220px]">
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent width="wide">
{providerModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
<Textarea
aria-label="Message"
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfiguration?.connected === true
? "Ask Lighthouse"
: "Connect a provider first"
}
className="max-h-40 min-h-12 flex-1"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitMessage(input);
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="bg-background px-4 pb-5 md:px-8">
<div className="mx-auto w-full max-w-4xl">
<LighthouseV2Feedback
feedback={feedback}
canRetry={
streamState.status === "disconnected" &&
lastSubmittedText !== null
}
}}
/>
{streamState.activeTaskId ? (
<Button type="button" variant="outline" onClick={handleStop}>
<Square />
Stop
</Button>
) : (
<Button type="submit" disabled={!canSend || !input.trim()}>
<Send />
Send
</Button>
)}
</form>
onRetry={() =>
lastSubmittedText
? void submitMessage(lastSubmittedText)
: undefined
}
/>
<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}
/>
</div>
</div>
</div>
</section>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center px-4 py-10 md:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5">
<LighthouseIcon className="size-12" />
<div className="space-y-2 text-center">
<h1 className="text-text-neutral-primary text-3xl font-semibold">
What do you want to know today?
</h1>
<p className="text-text-neutral-secondary text-base italic">
Understand and secure your cloud.
</p>
</div>
<div className="w-full max-w-4xl">
<LighthouseV2Feedback
feedback={feedback}
canRetry={
streamState.status === "disconnected" &&
lastSubmittedText !== null
}
onRetry={() =>
lastSubmittedText
? void submitMessage(lastSubmittedText)
: undefined
}
/>
<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}
/>
</div>
<div className="flex max-w-4xl flex-wrap items-center justify-center gap-2">
<span className="text-text-neutral-secondary basis-full text-center text-sm font-medium">
Try Lighthouse for...
</span>
{LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
const Icon = suggestion.icon;
return (
<Button
key={suggestion.label}
type="button"
variant="outline"
size="sm"
onClick={() => setInput(suggestion.prompt)}
>
<Icon className="size-4" />
{suggestion.label}
</Button>
);
})}
<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>
</div>
)}
</section>
);
}
interface LighthouseV2FeedbackProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
}
function LighthouseV2Feedback({
feedback,
canRetry,
onRetry,
}: LighthouseV2FeedbackProps) {
if (!feedback) return null;
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary mb-3 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}
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>;
}
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"
onSubmit={onSubmit}
>
<Textarea
aria-label="Message"
value={input}
onChange={(event) => onInputChange(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfigurationConnected
? "Ask a question"
: "Connect a provider first"
}
variant="ghost"
textareaSize="lg"
className="min-h-[104px] flex-1 rounded-b-none border-0 hover:bg-transparent focus:bg-transparent focus:ring-0"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void onSubmitText(input);
}
}}
/>
<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>
{isStreaming ? (
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={onStop}
>
<Square className="size-4" />
</Button>
) : (
<Button
type="submit"
size="icon-sm"
disabled={!canSend || !input.trim()}
>
<ArrowRight className="size-4" />
</Button>
)}
</div>
</form>
);
}
function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
return (
@@ -1,12 +1,20 @@
"use client";
import { Archive, MessageSquare, Plus } from "lucide-react";
import { Archive, Plus } from "lucide-react";
import { Button } from "@/components/shadcn/button/button";
import { SearchInput } from "@/components/shadcn/search-input/search-input";
import { cn } from "@/lib/utils";
import type { LighthouseV2Session } from "@/types/lighthouse-v2";
const SESSION_GROUP_ORDER = [
"Today",
"Yesterday",
"Last 7 days",
"Last 30 days",
"Older",
] as const;
interface LighthouseV2SessionHistoryProps {
sessions: LighthouseV2Session[];
activeSessionId?: string | null;
@@ -36,7 +44,7 @@ export function LighthouseV2SessionHistory({
<SearchInput
aria-label="Search Lighthouse sessions"
value={search}
placeholder="Search chats"
placeholder="Chat history"
size={compact ? "sm" : "default"}
onChange={(event) => onSearchChange(event.target.value)}
onClear={() => onSearchChange("")}
@@ -60,7 +68,7 @@ export function LighthouseV2SessionHistory({
<div className="flex flex-col gap-4">
{groups.map((group) => (
<section key={group.label} className="grid gap-1">
<h3 className="text-text-neutral-tertiary px-2 text-xs font-medium">
<h3 className="text-text-neutral-tertiary px-2 text-xs font-semibold tracking-wide uppercase">
{group.label}
</h3>
{group.sessions.map((session) => (
@@ -77,10 +85,12 @@ export function LighthouseV2SessionHistory({
className="hover:bg-bg-neutral-tertiary flex min-w-0 flex-1 items-center gap-2 rounded-[8px] px-2 py-2 text-left text-sm"
onClick={() => onOpenSession(session.id)}
>
<MessageSquare className="text-text-neutral-tertiary size-4 shrink-0" />
<span className="truncate">
<span className="min-w-0 flex-1 truncate">
{session.title || "Untitled chat"}
</span>
<span className="text-text-neutral-tertiary shrink-0 text-xs">
{formatAgeLabel(session.updatedAt)}
</span>
</button>
<Button
type="button"
@@ -109,20 +119,55 @@ interface SessionGroup {
}
function groupSessionsByDate(sessions: LighthouseV2Session[]): SessionGroup[] {
const formatter = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
const groups = new Map<string, LighthouseV2Session[]>();
sessions.forEach((session) => {
const label = formatter.format(new Date(session.updatedAt));
const label = getSessionGroupLabel(session.updatedAt);
groups.set(label, [...(groups.get(label) ?? []), session]);
});
return Array.from(groups.entries()).map(([label, groupSessions]) => ({
label,
sessions: groupSessions,
}));
return SESSION_GROUP_ORDER.filter((label) => groups.has(label)).map(
(label) => ({
label,
sessions: groups.get(label) ?? [],
}),
);
}
function getSessionGroupLabel(dateString: string) {
const ageInDays = getAgeInDays(dateString);
if (ageInDays === 0) return "Today";
if (ageInDays === 1) return "Yesterday";
if (ageInDays <= 7) return "Last 7 days";
if (ageInDays <= 30) return "Last 30 days";
return "Older";
}
function formatAgeLabel(dateString: string) {
const ageInDays = getAgeInDays(dateString);
if (ageInDays === 0) return "Today";
if (ageInDays === 1) return "1d";
return `${ageInDays}d`;
}
function getAgeInDays(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const startOfDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return Math.max(
0,
Math.floor(
(startOfToday.getTime() - startOfDate.getTime()) / millisecondsPerDay,
),
);
}
@@ -1 +1,2 @@
export { LighthouseV2NavigationModeSync } from "./lighthouse-v2-navigation-mode-sync";
export { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
@@ -0,0 +1,34 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
import { LighthouseV2NavigationModeSync } from "./lighthouse-v2-navigation-mode-sync";
const setNavigationModeMock = vi.fn();
vi.mock("@/hooks/use-sidebar", () => ({
SIDEBAR_NAVIGATION_MODE: {
BROWSE: "browse",
CHAT: "chat",
},
useSidebar: (
selector: (state: {
setNavigationMode: typeof setNavigationModeMock;
}) => unknown,
) => selector({ setNavigationMode: setNavigationModeMock }),
}));
describe("LighthouseV2NavigationModeSync", () => {
it("sets the sidebar navigation mode to chat on mount", async () => {
// Given / When
render(<LighthouseV2NavigationModeSync />);
// Then
await waitFor(() =>
expect(setNavigationModeMock).toHaveBeenCalledWith(
SIDEBAR_NAVIGATION_MODE.CHAT,
),
);
});
});
@@ -0,0 +1,14 @@
"use client";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
export function LighthouseV2NavigationModeSync() {
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
useMountEffect(() => {
setNavigationMode(SIDEBAR_NAVIGATION_MODE.CHAT);
});
return null;
}
@@ -2,7 +2,7 @@
import { MessageSquare, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useRef, useState } from "react";
import {
archiveLighthouseV2Session,
@@ -15,6 +15,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT } from "@/lib/lighthouse-v2/session-events";
import type { LighthouseV2Session } from "@/types/lighthouse-v2";
import { LighthouseV2SessionHistory } from "../history";
@@ -23,8 +24,9 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
const router = useRouter();
const [sessions, setSessions] = useState<LighthouseV2Session[]>([]);
const [search, setSearch] = useState("");
const searchRef = useRef("");
const refreshSessions = async (nextSearch = search) => {
const refreshSessions = async (nextSearch = searchRef.current) => {
const result = await getLighthouseV2Sessions(
nextSearch ? { search: nextSearch } : undefined,
);
@@ -35,6 +37,7 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
const handleSearchChange = (value: string) => {
setSearch(value);
searchRef.current = value;
void refreshSessions(value);
};
@@ -57,6 +60,11 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
useMountEffect(() => {
void refreshSessions();
const refresh = () => void refreshSessions(searchRef.current);
window.addEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
return () => {
window.removeEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
};
});
if (!isOpen) {
+80 -6
View File
@@ -1,15 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Menu } from "./menu";
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
const { openLaunchScanModalMock, pathnameValue } = vi.hoisted(() => ({
const { openLaunchScanModalMock, pathnameValue, pushMock } = vi.hoisted(() => ({
openLaunchScanModalMock: vi.fn(),
pathnameValue: { current: "/findings" },
pushMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => pathnameValue.current,
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: { user: { permissions: {} } },
status: "authenticated",
}),
}));
vi.mock("@/hooks", () => ({
@@ -22,17 +34,31 @@ vi.mock("@/lib/menu-list", () => ({
getMenuList: () => [],
}));
vi.mock("@/components/lighthouse-v2/navigation", () => ({
LighthouseV2SidebarChat: () => <div data-testid="lighthouse-chat-sidebar" />,
}));
vi.mock("@/store", () => ({
useScansStore: (
selector: (state: { openLaunchScanModal: () => void }) => unknown,
) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
}));
let MenuComponent: typeof import("./menu").Menu;
let SidebarNavigationModeToggleComponent: typeof import("./navigation-mode-toggle").SidebarNavigationModeToggle;
beforeAll(async () => {
MenuComponent = (await import("./menu")).Menu;
SidebarNavigationModeToggleComponent = (
await import("./navigation-mode-toggle")
).SidebarNavigationModeToggle;
});
describe("Menu", () => {
it("links scan to the scans page with the modal open", () => {
pathnameValue.current = "/findings";
render(<Menu isOpen />);
render(<MenuComponent isOpen />);
const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
const launchScanWrapper = launchScanLink.closest("div.flex.shrink-0");
@@ -51,7 +77,7 @@ describe("Menu", () => {
it("opens the launch scan modal without navigation when already on scans", async () => {
pathnameValue.current = "/scans";
render(<Menu isOpen />);
render(<MenuComponent isOpen />);
await screen.getByRole("button", { name: /launch scan/i }).click();
@@ -64,7 +90,7 @@ describe("Menu", () => {
it("shows the Prowler icon when the menu is collapsed", () => {
pathnameValue.current = "/findings";
render(<Menu isOpen={false} />);
render(<MenuComponent isOpen={false} />);
const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
@@ -75,3 +101,51 @@ describe("Menu", () => {
).toBeInTheDocument();
});
});
describe("SidebarNavigationModeToggle", () => {
beforeEach(() => {
pushMock.mockClear();
});
it("navigates to Lighthouse when Chat mode is selected", async () => {
// Given
const user = userEvent.setup();
const onChange = vi.fn();
render(
<SidebarNavigationModeToggleComponent
isOpen
value={SIDEBAR_NAVIGATION_MODE.BROWSE}
onChange={onChange}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Chat" }));
// Then
expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.CHAT);
expect(pushMock).toHaveBeenCalledWith("/lighthouse");
});
it("does not navigate when Browse mode is selected", async () => {
// Given
const user = userEvent.setup();
const onChange = vi.fn();
render(
<SidebarNavigationModeToggleComponent
isOpen
value={SIDEBAR_NAVIGATION_MODE.CHAT}
onChange={onChange}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Browse" }));
// Then
expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.BROWSE);
expect(pushMock).not.toHaveBeenCalled();
});
});
+2 -74
View File
@@ -1,7 +1,6 @@
"use client";
import { Divider } from "@heroui/divider";
import { Bot, LayoutDashboard } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -16,13 +15,10 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area/scroll-area";
import { CollapsibleMenu } from "@/components/ui/sidebar/collapsible-menu";
import { MenuItem } from "@/components/ui/sidebar/menu-item";
import { SidebarNavigationModeToggle } from "@/components/ui/sidebar/navigation-mode-toggle";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import {
SIDEBAR_NAVIGATION_MODE,
type SidebarNavigationMode,
useSidebar,
} from "@/hooks/use-sidebar";
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
import { getMenuList } from "@/lib/menu-list";
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
import { cn } from "@/lib/utils";
@@ -213,74 +209,6 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
);
};
function SidebarNavigationModeToggle({
isOpen,
value,
onChange,
}: {
isOpen: boolean;
value: SidebarNavigationMode;
onChange: (value: SidebarNavigationMode) => void;
}) {
const modes = [
{
value: SIDEBAR_NAVIGATION_MODE.BROWSE,
label: "Browse",
icon: LayoutDashboard,
},
{
value: SIDEBAR_NAVIGATION_MODE.CHAT,
label: "Chat",
icon: Bot,
},
] as const;
return (
<div className={cn("mt-3 shrink-0 px-2", !isOpen && "flex justify-center")}>
<div
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary flex rounded-[8px] border p-1",
isOpen ? "w-full" : "flex-col",
)}
>
{modes.map((mode) => {
const Icon = mode.icon;
const active = value === mode.value;
const button = (
<button
key={mode.value}
type="button"
aria-label={mode.label}
className={cn(
"flex h-8 items-center justify-center rounded-[6px] px-2 text-sm transition-colors",
isOpen ? "min-w-0 flex-1 gap-2" : "w-8",
active
? "bg-bg-neutral-tertiary text-text-neutral-primary"
: "text-text-neutral-secondary hover:text-text-neutral-primary",
)}
onClick={() => onChange(mode.value)}
>
<Icon className="size-4 shrink-0" />
{isOpen && <span className="truncate">{mode.label}</span>}
</button>
);
if (isOpen) {
return button;
}
return (
<Tooltip key={mode.value} delayDuration={100}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{mode.label}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
function LaunchScanButtonContent({ isOpen }: { isOpen: boolean }) {
return (
<span className={cn("flex items-center", isOpen && "gap-2.5")}>
@@ -0,0 +1,91 @@
"use client";
import { Bot, LayoutDashboard } from "lucide-react";
import { useRouter } from "next/navigation";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
SIDEBAR_NAVIGATION_MODE,
type SidebarNavigationMode,
} from "@/hooks/use-sidebar";
import { cn } from "@/lib/utils";
export function SidebarNavigationModeToggle({
isOpen,
value,
onChange,
}: {
isOpen: boolean;
value: SidebarNavigationMode;
onChange: (value: SidebarNavigationMode) => void;
}) {
const router = useRouter();
const modes = [
{
value: SIDEBAR_NAVIGATION_MODE.BROWSE,
label: "Browse",
icon: LayoutDashboard,
},
{
value: SIDEBAR_NAVIGATION_MODE.CHAT,
label: "Chat",
icon: Bot,
},
] as const;
const handleModeChange = (mode: SidebarNavigationMode) => {
onChange(mode);
if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) {
router.push("/lighthouse");
}
};
return (
<div className={cn("mt-3 shrink-0 px-2", !isOpen && "flex justify-center")}>
<div
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary flex rounded-[8px] border p-1",
isOpen ? "w-full" : "flex-col",
)}
>
{modes.map((mode) => {
const Icon = mode.icon;
const active = value === mode.value;
const button = (
<button
key={mode.value}
type="button"
aria-label={mode.label}
className={cn(
"flex h-8 items-center justify-center rounded-[6px] px-2 text-sm transition-colors",
isOpen ? "min-w-0 flex-1 gap-2" : "w-8",
active
? "bg-bg-neutral-tertiary text-text-neutral-primary"
: "text-text-neutral-secondary hover:text-text-neutral-primary",
)}
onClick={() => handleModeChange(mode.value)}
>
<Icon className="size-4 shrink-0" />
{isOpen && <span className="truncate">{mode.label}</span>}
</button>
);
if (isOpen) {
return button;
}
return (
<Tooltip key={mode.value} delayDuration={100}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{mode.label}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export const LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT =
"lighthouse-v2:sessions-changed";
export function notifyLighthouseV2SessionsChanged() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT));
}
+32
View File
@@ -19,6 +19,17 @@ const findApiReference = (options: Parameters<typeof getMenuList>[0]) =>
.flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === "API reference");
const getTopLevelLabels = () =>
getMenuList({ pathname: "/", apiDocsUrl: null }).flatMap((group) =>
group.menus.map((menu) => menu.label),
);
const getConfigurationLabels = () =>
getMenuList({ pathname: "/lighthouse/config", apiDocsUrl: null })
.flatMap((group) => group.menus)
.find((menu) => menu.label === "Configuration")
?.submenus?.map((submenu) => submenu.label) ?? [];
describe("getMenuList", () => {
afterEach(() => {
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
@@ -101,4 +112,25 @@ describe("getMenuList", () => {
expect.not.objectContaining({ highlight: true }),
);
});
it("should keep Lighthouse as a browse item in OSS", () => {
// Given / When
const labels = getTopLevelLabels();
// Then
expect(labels).toContain("Lighthouse AI");
});
it("should move Lighthouse out of the Cloud browse menu but keep its configuration entry", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const labels = getTopLevelLabels();
const configLabels = getConfigurationLabels();
// Then
expect(labels).not.toContain("Lighthouse AI");
expect(configLabels).toContain("Lighthouse AI");
});
});
+15 -11
View File
@@ -64,17 +64,21 @@ export const getMenuList = ({
},
],
},
{
groupLabel: "",
menus: [
{
href: "/lighthouse",
label: "Lighthouse AI",
icon: LighthouseIcon,
active: pathname === "/lighthouse",
},
],
},
...(isCloudEnv
? []
: [
{
groupLabel: "",
menus: [
{
href: "/lighthouse",
label: "Lighthouse AI",
icon: LighthouseIcon,
active: pathname === "/lighthouse",
},
],
},
]),
{
groupLabel: "",
menus: [