mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): make Lighthouse chat mode global
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user