/** * Activity in history mode: the persisted queries the URL's filters select, * paged by keyset cursor. * * The filters arrive already applied — the URL is the applied state — so this * only reads them. Everything about how the reader got here lives one level up. */ import { useInfiniteQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import * as api from "@/lib/api"; import CoverageNotice from "@/lib/CoverageNotice"; import InlineError from "@/lib/InlineError"; import { queriesInfiniteQuery } from "@/lib/queries"; import type { QueryRow } from "@/lib/types"; import { useClientNames } from "@/features/clients/clientNames"; import { summarizeRow } from "@/features/provenance/querySummary"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; import { queriesFilterOf, type ActivitySearch } from "./search"; const styles = stylex.create({ empty: { marginTop: "1.5rem", minHeight: "6rem", display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "0.75rem", color: colors.textMuted, }, /** About a default page of rows, so the table does not jump in under the reader. */ loading: { minHeight: "24rem", }, tableWrap: { marginTop: "1rem", overflowX: "auto", borderRadius: "0.25rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.border, }, table: { width: "100%", fontSize: "0.875rem", lineHeight: "1.25rem", }, /** `divide-y`: a hairline between rows, so the first row carries none. */ row: { borderTopWidth: { default: 1, ":first-child": 0 }, borderTopStyle: "solid", borderTopColor: colors.border, }, footer: { marginTop: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem", }, note: { fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.textMuted, }, /** The gap a line standing on its own needs from the block above it. */ spacedTop: { marginTop: "0.75rem", }, moreButton: { fontWeight: 500, }, moreError: { marginTop: "0.5rem", fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.dangerText, }, }); function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } interface Props { search: ActivitySearch; /** The toolbar's own Clear, so the empty state offers the way out it names. */ onClear: () => void; } export default function HistoryActivity({ search, onClear }: Props) { const filter = queriesFilterOf(search); const base = useInfiniteQuery(queriesInfiniteQuery(filter)); const clientNames = useClientNames(); const pages = base.data?.pages ?? []; const rows: QueryRow[] = pages.flatMap((page) => page.queries); // Only from the settled response. Placeholder pages belong to the previous // filter, and a watermark is a claim about the window being displayed. const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage; const filterActive = Object.keys(filter).length > 0; // `base.hasNextPage` reads the query state, which is empty while placeholder // data stands in for a filter change; derive the cursor from what is on // screen so the button keeps its place instead of flashing "end of log". const lastPage = pages[pages.length - 1]; const hasMore = lastPage !== undefined && lastPage.next_before !== null; // A 401 is already redirecting via the cache-level handleUnauthorized. const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401; const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null; function loadMore() { if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return; void base.fetchNextPage(); } // The loader starts this fetch but does not wait for it, so both the first // paint and a failed first page are this component's to render. if (base.status === "error" && base.data === undefined) { return void base.refetch()} />; } if (base.data === undefined) { return (

Loading activity…

); } return ( <> {/* * A quiet line, never a skeleton: the rows on screen stay put while a * filter change is in flight, so a keystroke must not blank the table * it is narrowing. */} {base.isFetching && (

Updating…

)} {rows.length === 0 ? (

{filterActive ? "No queries match the current filters." : "No queries logged yet."}

{filterActive && ( )} {coverage !== undefined && }
) : ( <>
{rows.map((row) => ( id === null ? ( children ) : ( {children} ) } /> ))}

{/* The count is the one part of this line that moves as pages load. */} Showing {rows.length}{" "} {rows.length === 1 ? "query" : "queries"} {hasMore ? "" : " — end of log"}

{coverage !== undefined && } {hasMore && ( )}
{moreError !== null && (

Failed to load more: {moreError}

)} )} ); }