diff --git a/ui/actions/lighthouse-v2/lighthouse-v2.ts b/ui/actions/lighthouse-v2/lighthouse-v2.ts index 068ef69a9b..fdf751b672 100644 --- a/ui/actions/lighthouse-v2/lighthouse-v2.ts +++ b/ui/actions/lighthouse-v2/lighthouse-v2.ts @@ -132,14 +132,10 @@ export async function getLighthouseV2SupportedModels( ); } -export async function getLighthouseV2Sessions(params?: { - search?: string; -}): Promise> { - const url = buildApiUrl("/lighthouse/sessions"); - if (params?.search) { - url.searchParams.set("search", params.search); - } - return getCollectionFromUrl(url, mapLighthouseV2Session); +export async function getLighthouseV2Sessions(): Promise< + LighthouseV2ActionResult +> { + return getCollection("/lighthouse/sessions", mapLighthouseV2Session); } export async function getLighthouseV2Session( diff --git a/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.test.tsx b/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.test.tsx new file mode 100644 index 0000000000..dca5c5ecfd --- /dev/null +++ b/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.test.tsx @@ -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(''); + }); +}); + +function renderHistory( + props?: Partial[0]>, +) { + return render( + , + ); +} + +function session( + overrides: Partial = {}, +): LighthouseV2Session { + return { + id: "session-1", + title: "Session", + isArchived: false, + insertedAt: "2026-06-25T09:00:00Z", + updatedAt: "2026-06-25T09:00:00Z", + activeTaskId: null, + ...overrides, + }; +} diff --git a/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.tsx b/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.tsx index 70b3ba62c1..3c1832105e 100644 --- a/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.tsx +++ b/ui/components/lighthouse-v2/history/lighthouse-v2-session-history.tsx @@ -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 ( -