fix(ui): improve lighthouse session history

This commit is contained in:
alejandrobailo
2026-06-25 16:04:24 +02:00
parent bb7eb06746
commit f98efd0dc1
4 changed files with 342 additions and 82 deletions
+4 -8
View File
@@ -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);