mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): improve lighthouse session history
This commit is contained in:
@@ -132,14 +132,10 @@ export async function getLighthouseV2SupportedModels(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLighthouseV2Sessions(params?: {
|
||||
search?: string;
|
||||
}): Promise<LighthouseV2ActionResult<LighthouseV2Session[]>> {
|
||||
const url = buildApiUrl("/lighthouse/sessions");
|
||||
if (params?.search) {
|
||||
url.searchParams.set("search", params.search);
|
||||
}
|
||||
return getCollectionFromUrl(url, mapLighthouseV2Session);
|
||||
export async function getLighthouseV2Sessions(): Promise<
|
||||
LighthouseV2ActionResult<LighthouseV2Session[]>
|
||||
> {
|
||||
return getCollection("/lighthouse/sessions", mapLighthouseV2Session);
|
||||
}
|
||||
|
||||
export async function getLighthouseV2Session(
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { LighthouseV2Session } from "@/types/lighthouse-v2";
|
||||
|
||||
import { LighthouseV2SessionHistory } from "./lighthouse-v2-session-history";
|
||||
|
||||
describe("LighthouseV2SessionHistory", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(
|
||||
path.join(currentDir, "lighthouse-v2-session-history.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the session title truncated and the age label visible without horizontal overflow", () => {
|
||||
// Given / When
|
||||
renderHistory({
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-today",
|
||||
title:
|
||||
"This is a very long Lighthouse conversation title that must fit next to the age label",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
const sessionButton = screen.getByRole("button", {
|
||||
name: /This is a very long Lighthouse conversation title.*Today/,
|
||||
});
|
||||
const title = within(sessionButton).getByText(
|
||||
/This is a very long Lighthouse conversation title/i,
|
||||
);
|
||||
const age = within(sessionButton).getByText("Today");
|
||||
|
||||
expect(sessionButton).toHaveClass("min-w-0", "overflow-hidden");
|
||||
expect(sessionButton.parentElement).toHaveClass(
|
||||
"min-w-0",
|
||||
"overflow-hidden",
|
||||
);
|
||||
expect(title).toHaveClass("min-w-0", "flex-1", "truncate");
|
||||
expect(age).toHaveClass("shrink-0", "whitespace-nowrap");
|
||||
});
|
||||
|
||||
it("renders session age as numeric day labels instead of compact counters", () => {
|
||||
// Given / When
|
||||
renderHistory({
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-today",
|
||||
title: "Today session",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
session({
|
||||
id: "session-one-day",
|
||||
title: "One day session",
|
||||
updatedAt: "2026-06-24T09:00:00Z",
|
||||
}),
|
||||
session({
|
||||
id: "session-two-days",
|
||||
title: "Two days session",
|
||||
updatedAt: "2026-06-23T09:00:00Z",
|
||||
}),
|
||||
session({
|
||||
id: "session-thirty-days",
|
||||
title: "Thirty days session",
|
||||
updatedAt: "2026-05-26T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 day")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("30 days")).toBeInTheDocument();
|
||||
expect(screen.queryByText("today")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("1d")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("2d")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("30d")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("one day")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("thirty days")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a single Older heading with balanced vertical spacing", () => {
|
||||
// Given / When
|
||||
renderHistory({
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-today",
|
||||
title: "Today session",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
session({
|
||||
id: "session-thirty-days",
|
||||
title: "Thirty days session",
|
||||
updatedAt: "2026-05-26T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
const heading = screen.getByRole("heading", { name: "Older" });
|
||||
|
||||
expect(heading).toHaveClass("py-1");
|
||||
expect(screen.getAllByRole("heading")).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Today" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Last 30 days" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters visible sessions by the current search value", () => {
|
||||
// Given / When
|
||||
renderHistory({
|
||||
search: "threat",
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-threat",
|
||||
title: "Threat model review",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
session({
|
||||
id: "session-compliance",
|
||||
title: "Compliance gap analysis",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Threat model review")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Compliance gap analysis"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces the age label with the archive action on row hover", () => {
|
||||
// Given / When
|
||||
renderHistory({
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-today",
|
||||
title: "Threat model review",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Then
|
||||
const sessionButton = screen.getByRole("button", {
|
||||
name: /Threat model review.*Today/,
|
||||
});
|
||||
const row = sessionButton.parentElement;
|
||||
const age = within(sessionButton).getByText("Today");
|
||||
const archiveButton = screen.getByRole("button", {
|
||||
name: "Archive Threat model review",
|
||||
});
|
||||
|
||||
expect(row).toHaveClass("hover:bg-bg-neutral-tertiary");
|
||||
expect(sessionButton).not.toHaveClass("hover:bg-bg-neutral-tertiary");
|
||||
expect(age).toHaveClass(
|
||||
"transition-opacity",
|
||||
"group-hover:opacity-0",
|
||||
"group-focus-within:opacity-0",
|
||||
);
|
||||
expect(archiveButton).toHaveClass(
|
||||
"absolute",
|
||||
"right-1",
|
||||
"opacity-0",
|
||||
"group-hover:opacity-100",
|
||||
"group-focus-within:opacity-100",
|
||||
"hover:text-text-neutral-secondary",
|
||||
"active:text-text-neutral-secondary",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the full trimmed title in a right-side tooltip", async () => {
|
||||
// Given
|
||||
const fullTitle =
|
||||
"This is the complete Lighthouse conversation title shown in the tooltip";
|
||||
renderHistory({
|
||||
sessions: [
|
||||
session({
|
||||
id: "session-tooltip",
|
||||
title: fullTitle,
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const sessionButton = screen.getByRole("button", {
|
||||
name: new RegExp(`${fullTitle}.*Today`),
|
||||
});
|
||||
vi.useRealTimers();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// When
|
||||
await user.hover(sessionButton);
|
||||
|
||||
// Then
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent(fullTitle);
|
||||
expect(source).toContain('<TooltipContent side="right">');
|
||||
});
|
||||
});
|
||||
|
||||
function renderHistory(
|
||||
props?: Partial<Parameters<typeof LighthouseV2SessionHistory>[0]>,
|
||||
) {
|
||||
return render(
|
||||
<LighthouseV2SessionHistory
|
||||
sessions={props?.sessions ?? []}
|
||||
activeSessionId={props?.activeSessionId}
|
||||
search={props?.search ?? ""}
|
||||
onSearchChange={props?.onSearchChange ?? vi.fn()}
|
||||
onNewSession={props?.onNewSession ?? vi.fn()}
|
||||
onOpenSession={props?.onOpenSession ?? vi.fn()}
|
||||
onArchiveSession={props?.onArchiveSession ?? vi.fn()}
|
||||
compact={props?.compact}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function session(
|
||||
overrides: Partial<LighthouseV2Session> = {},
|
||||
): LighthouseV2Session {
|
||||
return {
|
||||
id: "session-1",
|
||||
title: "Session",
|
||||
isArchived: false,
|
||||
insertedAt: "2026-06-25T09:00:00Z",
|
||||
updatedAt: "2026-06-25T09:00:00Z",
|
||||
activeTaskId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,15 @@ import { Archive, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { SearchInput } from "@/components/shadcn/search-input/search-input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
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;
|
||||
const SESSION_HISTORY_GROUP_LABEL = "Older";
|
||||
|
||||
interface LighthouseV2SessionHistoryProps {
|
||||
sessions: LighthouseV2Session[];
|
||||
@@ -36,10 +35,16 @@ export function LighthouseV2SessionHistory({
|
||||
onArchiveSession,
|
||||
compact = false,
|
||||
}: LighthouseV2SessionHistoryProps) {
|
||||
const groups = groupSessionsByDate(sessions);
|
||||
const visibleSessions = filterSessionsBySearch(sessions, search);
|
||||
const groups = groupSessionsByDate(visibleSessions);
|
||||
|
||||
return (
|
||||
<aside className={cn("flex min-h-0 flex-col gap-3", compact && "gap-2")}>
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 w-full min-w-0 flex-col gap-3 overflow-hidden",
|
||||
compact && "gap-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
aria-label="Search Lighthouse sessions"
|
||||
@@ -59,7 +64,7 @@ export function LighthouseV2SessionHistory({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="minimal-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-text-neutral-secondary px-2 py-8 text-center text-sm">
|
||||
No chats
|
||||
@@ -67,43 +72,54 @@ 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-semibold tracking-wide uppercase">
|
||||
<section key={group.label} className="grid min-w-0">
|
||||
<h3 className="text-text-neutral-tertiary px-2 py-1 text-xs font-semibold tracking-wide uppercase">
|
||||
{group.label}
|
||||
</h3>
|
||||
{group.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-[8px]",
|
||||
activeSessionId === session.id &&
|
||||
"bg-bg-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
{group.sessions.map((session) => {
|
||||
const sessionTitle = session.title || "Untitled chat";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"hover:bg-bg-neutral-tertiary group relative flex min-w-0 items-center overflow-hidden rounded-[8px] transition-colors",
|
||||
activeSessionId === session.id &&
|
||||
"bg-bg-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
aria-label={`Archive ${session.title || "chat"}`}
|
||||
variant="bare"
|
||||
size="icon-xs"
|
||||
className="mr-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onArchiveSession(session.id)}
|
||||
>
|
||||
<Archive />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-[8px] px-2 py-2 text-left text-sm"
|
||||
onClick={() => onOpenSession(session.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{sessionTitle}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary min-w-[3.25rem] shrink-0 text-right text-xs whitespace-nowrap transition-opacity group-focus-within:opacity-0 group-hover:opacity-0">
|
||||
{formatAgeLabel(session.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{sessionTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={`Archive ${sessionTitle}`}
|
||||
variant="bare"
|
||||
size="icon-xs"
|
||||
className="hover:text-text-neutral-secondary active:text-text-neutral-secondary absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onArchiveSession(session.id)}
|
||||
>
|
||||
<Archive />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
@@ -119,35 +135,35 @@ interface SessionGroup {
|
||||
}
|
||||
|
||||
function groupSessionsByDate(sessions: LighthouseV2Session[]): SessionGroup[] {
|
||||
const groups = new Map<string, LighthouseV2Session[]>();
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const label = getSessionGroupLabel(session.updatedAt);
|
||||
groups.set(label, [...(groups.get(label) ?? []), session]);
|
||||
});
|
||||
|
||||
return SESSION_GROUP_ORDER.filter((label) => groups.has(label)).map(
|
||||
(label) => ({
|
||||
label,
|
||||
sessions: groups.get(label) ?? [],
|
||||
}),
|
||||
);
|
||||
return [
|
||||
{
|
||||
label: SESSION_HISTORY_GROUP_LABEL,
|
||||
sessions,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 filterSessionsBySearch(
|
||||
sessions: LighthouseV2Session[],
|
||||
search: string,
|
||||
): LighthouseV2Session[] {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
if (!normalizedSearch) return sessions;
|
||||
|
||||
return sessions.filter((session) =>
|
||||
(session.title || "Untitled chat")
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgeLabel(dateString: string) {
|
||||
const ageInDays = getAgeInDays(dateString);
|
||||
if (ageInDays === 0) return "Today";
|
||||
if (ageInDays === 1) return "1d";
|
||||
return `${ageInDays}d`;
|
||||
|
||||
return ageInDays === 1 ? "1 day" : `${ageInDays} days`;
|
||||
}
|
||||
|
||||
function getAgeInDays(dateString: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { MessageSquare, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
archiveLighthouseV2Session,
|
||||
@@ -24,12 +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 = searchRef.current) => {
|
||||
const result = await getLighthouseV2Sessions(
|
||||
nextSearch ? { search: nextSearch } : undefined,
|
||||
);
|
||||
const refreshSessions = async () => {
|
||||
const result = await getLighthouseV2Sessions();
|
||||
if ("data" in result) {
|
||||
setSessions(result.data);
|
||||
}
|
||||
@@ -37,8 +34,6 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
searchRef.current = value;
|
||||
void refreshSessions(value);
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
@@ -60,7 +55,7 @@ export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
|
||||
|
||||
useMountEffect(() => {
|
||||
void refreshSessions();
|
||||
const refresh = () => void refreshSessions(searchRef.current);
|
||||
const refresh = () => void refreshSessions();
|
||||
window.addEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
window.removeEventListener(LIGHTHOUSE_V2_SESSIONS_CHANGED_EVENT, refresh);
|
||||
|
||||
Reference in New Issue
Block a user