From b5b61a05935888ffda37e623fa5d969908e6670a Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 18:26:47 +0200 Subject: [PATCH 01/12] feat: add compact button option for citation export --- src/components/CitationExport.tsx | 8 +++++--- src/lib/utils.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/CitationExport.tsx b/src/components/CitationExport.tsx index aeba6b6..f4ee087 100644 --- a/src/components/CitationExport.tsx +++ b/src/components/CitationExport.tsx @@ -6,6 +6,8 @@ import useMatomo from '../hooks/useMatomo'; interface CitationExportProps { dataset: BackendDataset; + // Smaller button, to sit in a list row next to Play and Source. + compact?: boolean; } type CitationFormat = 'bibtex' | 'ris' | 'csljson'; @@ -22,7 +24,7 @@ const GENERATORS: Record string> = { csljson: generateCSLJSON }; -export const CitationExport = ({dataset}: CitationExportProps) => { +export const CitationExport = ({dataset, compact = false}: CitationExportProps) => { const [open, setOpen] = useState(false); const [format, setFormat] = useState('bibtex'); const [copied, setCopied] = useState(false); @@ -115,9 +117,9 @@ export const CitationExport = ({dataset}: CitationExportProps) => { }} aria-haspopup="true" aria-expanded={open} - className="inline-flex items-center justify-center gap-1 rounded-md bg-gray-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 transition-colors cursor-pointer" + className={`inline-flex items-center justify-center gap-1 rounded-md bg-gray-600 ${compact ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm'} font-medium text-white shadow-sm hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 transition-colors cursor-pointer`} > - + Cite diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 047d70f..638113c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,4 @@ +import type {BackendDataset} from "@/types/commons.ts"; // Logger utility for error handling and messaging // Define isDev based on Vite's import.meta.env or Node's process.env @@ -181,3 +182,11 @@ export function prettyJson(raw: string): string { return trimmed; } } + +/** A dataset's publication date: the full date when the backend has one, otherwise the year. */ +export const publicationDateOf = (hit: BackendDataset): string | null => + hit.publication_date || hit._source.publicationYear || null; + +/** A bare year stays as it is; a full date renders as YYYY.MM.DD. */ +export const formatPublicationDate = (dateStr: string): string => + /^\d{4}$/.test(dateStr) ? dateStr : new Date(dateStr).toISOString().slice(0, 10).replace(/-/g, '.'); From 4a3e53c9a01d3b76bd9af346fef9834e2ce47617 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 18:27:06 +0200 Subject: [PATCH 02/12] feat: implement getProvenanceSource function for dataset origin identification --- src/lib/repoProvenance.test.ts | 19 ++++++++++++++++++- src/lib/repoProvenance.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/repoProvenance.test.ts b/src/lib/repoProvenance.test.ts index 1397aa9..598a9df 100644 --- a/src/lib/repoProvenance.test.ts +++ b/src/lib/repoProvenance.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect} from "vitest"; -import {getAggregator, getOwner, getRepository} from "./repoProvenance"; +import {getAggregator, getOwner, getProvenanceSource, getRepository} from "./repoProvenance"; import type {BackendDataset} from "../types/commons"; // Minimal hit builder — only the provenance-relevant fields matter here. @@ -97,3 +97,20 @@ describe("getOwner", () => { }))).toBeNull(); }); }); + +describe("getProvenanceSource", () => { + it("names the source repository of a directly harvested record", () => { + expect(getProvenanceSource(hit({_repo: "HAL"}))?.name).toBe("HAL Open Science"); + // Unknown code: still named, as text. + expect(getProvenanceSource(hit({_repo: "EMPIAR"}))?.name).toBe("EMPIAR"); + }); + + it("names the owner of an aggregated record, or the aggregator when the owner is unknown", () => { + expect(getProvenanceSource(hit({_repo: "ONE", creators: bgeeCreator}))?.name).toBe("Bgee"); + expect(getProvenanceSource(hit({_repo: "ONE", creators: personalCreator}))?.name).toBe("Onedata"); + }); + + it("is null when nothing is known", () => { + expect(getProvenanceSource(hit({}))).toBeNull(); + }); +}); diff --git a/src/lib/repoProvenance.ts b/src/lib/repoProvenance.ts index 797fc55..0128521 100644 --- a/src/lib/repoProvenance.ts +++ b/src/lib/repoProvenance.ts @@ -119,6 +119,18 @@ export function getRepository(hit: BackendDataset): RepoIdentity | null { return {code, name: src._repo as string, logo: null, href}; } +/** + * The single identity to name as "where this dataset comes from" when there is room for + * only one: the owner of an aggregated record (the aggregator itself when the owner is + * unknown), otherwise the source repository. Used by the chat's inline citations and + * the "Cited from" strip, which cannot fit the two-logo cluster. + */ +export function getProvenanceSource(hit: BackendDataset): RepoIdentity | null { + const aggregator = getAggregator(hit); + if (aggregator) return getOwner(hit) ?? aggregator; + return getRepository(hit); +} + /** * The institution that owns the dataset (left badge for aggregated records), from the DataCite * organizational creator with a URL nameIdentifier. Returns null when no such creator exists (e.g. From 8d23db7ebeb8c81bbd4df14a5ea3687e09c31818 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 18:27:19 +0200 Subject: [PATCH 03/12] feat: add collectCitations function to number cited datasets in messages --- src/lib/datasetCitations.test.ts | 33 ++++++++++++++++++++++++++-- src/lib/datasetCitations.ts | 37 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/lib/datasetCitations.test.ts b/src/lib/datasetCitations.test.ts index 13e7d7f..35c7760 100644 --- a/src/lib/datasetCitations.test.ts +++ b/src/lib/datasetCitations.test.ts @@ -1,9 +1,9 @@ import {describe, it, expect} from "vitest"; import {makeDataset} from "@/test/fixtures/datasets"; import type {SSEEvent} from "@/lib/api"; -import type {Message} from "@/types/chat"; +import type {Message, MessageBlock} from "@/types/chat"; import {applyChatEvent} from "./chatMessages"; -import {buildDatasetUrlMap, lookupDataset, normalizeDatasetUrl} from "./datasetCitations"; +import {buildDatasetUrlMap, collectCitations, lookupDataset, normalizeDatasetUrl} from "./datasetCitations"; const DATASET_URL = "https://doi.org/10.5281/zenodo.1234567"; const hit = makeDataset({dataset_url: DATASET_URL}); @@ -39,3 +39,32 @@ describe("dataset URL resolution", () => { expect(buildDatasetUrlMap(messages).size).toBe(0); }); }); + +describe("collectCitations", () => { + const SECOND_URL = "https://doi.org/10.5281/zenodo.7654321"; + const second = makeDataset({dataset_url: SECOND_URL, _id: SECOND_URL}); + const datasets = new Map([ + [normalizeDatasetUrl(DATASET_URL), hit], + [normalizeDatasetUrl(SECOND_URL), second], + ]); + const text = (t: string): MessageBlock => ({kind: "text", text: t}); + + it("numbers cited datasets by first mention across the message's text blocks", () => { + const citations = collectCitations([ + text(`Start with [B](${SECOND_URL}) then **[A](${DATASET_URL})**.`), + {kind: "tool", toolCall: {id: "c1", name: "search_data", args: ""}}, + // Repeats, one with the URL rewritten the way the model sometimes does. + text(`Again [B](${SECOND_URL}) and [A](http://dx.doi.org/10.5281/ZENODO.1234567/).`), + ], datasets); + expect(citations.map(c => [c.number, c.dataset])).toEqual([[1, second], [2, hit]]); + }); + + it("ignores links that resolve to no search hit", () => { + expect(collectCitations([text("See [the docs](https://example.org/docs).")], datasets)).toEqual([]); + }); + + it("does not count a link that is still streaming in", () => { + const citations = collectCitations([text(`Found [A](${DATASET_URL}) and [B](https://doi.org/10.5281/zen`)], datasets); + expect(citations.map(c => c.dataset)).toEqual([hit]); + }); +}); diff --git a/src/lib/datasetCitations.ts b/src/lib/datasetCitations.ts index d3b0855..0e75eb1 100644 --- a/src/lib/datasetCitations.ts +++ b/src/lib/datasetCitations.ts @@ -1,4 +1,4 @@ -import type {Message} from "@/types/chat.ts"; +import type {Message, MessageBlock} from "@/types/chat.ts"; import type {BackendDataset} from "@/types/commons.ts"; /** @@ -45,3 +45,38 @@ export const buildDatasetUrlMap = (messages: Message[]): Map, href: string): BackendDataset | null => map.get(normalizeDatasetUrl(href)) ?? null; + +export interface DatasetCitation { + // 1-based position in the message's reference list, by order of first mention. + number: number; + dataset: BackendDataset; +} + +// The link forms MessageMarkdown renders: [label](url), also inside **bold**. +const MARKDOWN_LINK = /\[(.+?)]\((.+?)\)/g; + +/** + * Numbers the datasets an assistant message cites, in order of first mention across + * its text blocks, so the answer can carry [n] markers and a matching reference list. + * A dataset cited twice keeps its first number; links that resolve to no search hit + * are not citations. A link still streaming in has no closing parenthesis yet, so it + * is simply not counted until it is complete, which keeps earlier numbers stable. + */ +export const collectCitations = (blocks: MessageBlock[], datasets: Map): DatasetCitation[] => { + const citations: DatasetCitation[] = []; + const seen = new Set(); + for (const block of blocks) { + if (block.kind !== 'text') continue; + const pattern = new RegExp(MARKDOWN_LINK.source, 'g'); + let match: RegExpExecArray | null; + while ((match = pattern.exec(block.text)) !== null) { + const key = normalizeDatasetUrl(match[2]); + if (seen.has(key)) continue; + const dataset = datasets.get(key); + if (!dataset) continue; + seen.add(key); + citations.push({number: citations.length + 1, dataset}); + } + } + return citations; +}; From a1c96071569ada2faa03ef5679f4fa2e38983351 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 18:27:32 +0200 Subject: [PATCH 04/12] feat: enhance citation handling with numbered references and improved dataset display --- src/components/BotMessageBody.tsx | 65 +++++++ src/components/CitedDatasets.test.tsx | 80 +++++++++ src/components/CitedDatasets.tsx | 188 ++++++++++++++++++++ src/components/DatasetReference.tsx | 107 ++++++------ src/components/MessageMarkdown.test.tsx | 38 ++-- src/components/MessageMarkdown.tsx | 22 ++- src/components/SearchResultItem.tsx | 222 ++++++++++++------------ src/pages/ChatPage.test.tsx | 20 ++- src/pages/ChatPage.tsx | 35 +--- 9 files changed, 553 insertions(+), 224 deletions(-) create mode 100644 src/components/BotMessageBody.tsx create mode 100644 src/components/CitedDatasets.test.tsx create mode 100644 src/components/CitedDatasets.tsx diff --git a/src/components/BotMessageBody.tsx b/src/components/BotMessageBody.tsx new file mode 100644 index 0000000..d12f46b --- /dev/null +++ b/src/components/BotMessageBody.tsx @@ -0,0 +1,65 @@ +import {useCallback, useMemo, useRef, useState} from "react"; +import type {Message} from "../types/chat.ts"; +import type {BackendDataset} from "../types/commons.ts"; +import {collectCitations} from "../lib/datasetCitations.ts"; +import {MessageMarkdown} from "./MessageMarkdown.tsx"; +import {ToolCallEntry} from "./ToolCallEntry.tsx"; +import {CitedDatasets, type JumpRequest} from "./CitedDatasets.tsx"; + +interface BotMessageBodyProps { + message: Message; + // Datasets citable in this thread, keyed by normalized URL (see buildDatasetUrlMap). + datasets: Map; + isLoggedIn: boolean; +} + +/** + * Body of an assistant message: its tool calls and text blocks in the order the + * agent produced them, followed by the reference list of every dataset the text + * cites. Citation numbers are assigned here, across all of the message's text + * blocks, so the [n] markers and the list always agree. + */ +export const BotMessageBody = ({message, datasets, isLoggedIn}: BotMessageBodyProps) => { + const blocks = useMemo( + () => message.blocks ?? (message.content ? [{kind: 'text' as const, text: message.content}] : []), + [message.blocks, message.content] + ); + const citations = useMemo(() => collectCitations(blocks, datasets), [blocks, datasets]); + const lastTextIndex = blocks.reduce((acc, b, i) => (b.kind === 'text' ? i : acc), -1); + + const [jump, setJump] = useState(null); + const jumpSeq = useRef(0); + const onCite = useCallback((number: number) => { + jumpSeq.current += 1; + setJump({number, seq: jumpSeq.current}); + }, []); + + return ( + <> + {blocks.map((block, blockIndex) => { + if (block.kind === 'tool') { + return ( + + ); + } + if (!block.text.trim()) return null; + return ( +
+ +
+ ); + })} + + + ); +}; diff --git a/src/components/CitedDatasets.test.tsx b/src/components/CitedDatasets.test.tsx new file mode 100644 index 0000000..8989d2c --- /dev/null +++ b/src/components/CitedDatasets.test.tsx @@ -0,0 +1,80 @@ +import {describe, it, expect, vi} from "vitest"; +import {render, screen, within} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import {MemoryRouter} from "react-router"; +import {makeDataset} from "@/test/fixtures/datasets"; +import type {DatasetCitation} from "@/lib/datasetCitations"; +import {CitedDatasets} from "./CitedDatasets"; + +const first = makeDataset({ + title: "Ocean Temperatures 2023", + dataset_url: "https://doi.org/10.5281/zenodo.1", _id: "https://doi.org/10.5281/zenodo.1", + _source: {_repo: "Zenodo"}, +}); +const second = makeDataset({ + title: "Soil Moisture 2021", + dataset_url: "https://doi.org/10.5281/zenodo.2", _id: "https://doi.org/10.5281/zenodo.2", + publication_date: "2021-03-02", + description: "x".repeat(400), + _source: {_repo: "Zenodo", creators: [{creatorName: "Ada"}, {creatorName: "Bo"}, {creatorName: "Cy"}]}, +}); +const third = makeDataset({ + title: "Reactor Steel SEM", + dataset_url: "https://doi.org/10.14278/rodare.1", _id: "https://doi.org/10.14278/rodare.1", + _source: {_repo: "PANOSC"}, +}); +const citations: DatasetCitation[] = [first, second, third].map((dataset, i) => ({number: i + 1, dataset})); + +// The Play button reads the search params. +const renderList = (ui = ) => render({ui}); + +describe("CitedDatasets", () => { + it("lists the cited datasets in citation order with number, title, byline and actions", () => { + renderList(); + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent("[1]"); + expect(items[0]).toHaveTextContent("Ocean Temperatures 2023"); + expect(items[1]).toHaveTextContent("2021 · Ada, Bo, +1 more"); + expect(within(items[2]).getByRole("link", {name: /source of dataset Reactor Steel SEM/})) + .toHaveAttribute("href", third._id); + }); + + it("counts the cited datasets per repository, by name", () => { + renderList(); + expect(screen.getByTitle("2 of 3 cited datasets from Zenodo")).toHaveTextContent("Zenodo2"); + expect(screen.getByTitle("1 of 3 cited datasets from PaNOSC")).toHaveTextContent("PaNOSC1"); + }); + + it("expands a row into the card details", async () => { + const user = userEvent.setup(); + renderList(); + expect(screen.queryByText(/^x{300}\.\.\.$/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", {name: "Show details of Soil Moisture 2021"})); + expect(screen.getByText(/^x{300}\.\.\.$/)).toBeInTheDocument(); + expect(screen.getByText("2021.03.02")).toBeInTheDocument(); + }); + + it("can be hidden, and a marker jump reopens it on the right entry", async () => { + const user = userEvent.setup(); + const {rerender} = renderList(); + await user.click(screen.getByRole("button", {name: /Hide list/})); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + expect(screen.getByRole("button", {name: /Show all 3/})).toBeInTheDocument(); + + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + rerender(); + + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(3); + expect(scrollIntoView).toHaveBeenCalled(); + expect(items[1]).toHaveFocus(); + expect(items[1].className).toContain("bg-blue-100"); + }); + + it("renders nothing without citations", () => { + const {container} = renderList(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/CitedDatasets.tsx b/src/components/CitedDatasets.tsx new file mode 100644 index 0000000..f9a00d6 --- /dev/null +++ b/src/components/CitedDatasets.tsx @@ -0,0 +1,188 @@ +import {useEffect, useRef, useState} from "react"; +import {ChevronDown, ChevronUp} from "lucide-react"; +import type {DatasetCitation} from "../lib/datasetCitations.ts"; +import type {SearchHitSrcCreator} from "../types/commons.ts"; +import {getProvenanceSource, type RepoIdentity} from "../lib/repoProvenance.ts"; +import {publicationDateOf} from "../lib/utils.ts"; +import {RepoProvenance} from "./RepoProvenance.tsx"; +import {DatasetActions, DatasetDetails, RelevanceBadge} from "./SearchResultItem.tsx"; + +/** One activation of a [n] marker; `seq` distinguishes repeated clicks on the same number. */ +export interface JumpRequest { + number: number; + seq: number; +} + +interface CitedDatasetsProps { + citations: DatasetCitation[]; + isLoggedIn?: boolean; + jump?: JumpRequest | null; +} + +/** + * How many of the cited datasets come from each repository, most first. Names only: + * the logos stay on the rows, where they have the room to be recognisable. + */ +const CitedFromStrip = ({citations}: { citations: DatasetCitation[] }) => { + const counts = new Map(); + for (const {dataset} of citations) { + const source = getProvenanceSource(dataset); + if (!source) continue; + const entry = counts.get(source.code) ?? {source, count: 0}; + entry.count += 1; + counts.set(source.code, entry); + } + if (counts.size === 0) return null; + + const items = [...counts.values()].sort((a, b) => b.count - a.count); + return ( +
+ Cited from + {items.map(({source, count}) => ( + + {source.name} + {count} + + ))} +
+ ); +}; + +interface CitedDatasetRowProps { + citation: DatasetCitation; + isLoggedIn: boolean; + highlighted: boolean; + register: (number: number, element: HTMLLIElement | null) => void; +} + +/** A compact reference entry; the chevron expands it into the full card details. */ +const CitedDatasetRow = ({citation, isLoggedIn, highlighted, register}: CitedDatasetRowProps) => { + const [expanded, setExpanded] = useState(false); + const {number, dataset} = citation; + + const creators: SearchHitSrcCreator[] = dataset._source.creators ?? []; + const year = publicationDateOf(dataset)?.slice(0, 4); + const byline = creators.slice(0, 2).map(c => c.creatorName).join(', ') + + (creators.length > 2 ? `, +${creators.length - 2} more` : ''); + const meta = [year, byline].filter(Boolean).join(' · '); + + return ( +
  • register(number, element)} + tabIndex={-1} + className={`grid grid-cols-[36px_minmax(0,1fr)] sm:grid-cols-[36px_96px_minmax(0,1fr)_auto] items-center gap-x-3.5 gap-y-2 px-3 py-2.5 border-t border-gray-100 first:border-t-0 transition-colors duration-700 ${highlighted ? 'bg-blue-100' : 'bg-transparent'}`} + > + + [{number}] + + + + +
    + {dataset.title} + {meta && {meta}} +
    +
    + + +
    + {expanded && ( +
    +
    + +
    + )} +
  • + ); +}; + +/** + * The reference list under an assistant answer: every dataset the answer cites, in + * the order of its [n] markers, as compact rows with the card's actions. A `jump` + * request from a marker scrolls its row into view and highlights it briefly, opening + * the list first if the reader had hidden it. + */ +export const CitedDatasets = ({citations, isLoggedIn = false, jump = null}: CitedDatasetsProps) => { + // The list is open unless the reader hid it, and a marker clicked after that + // reopens it: `hiddenAt` remembers which jump was current when it was hidden. + const [hiddenAt, setHiddenAt] = useState(null); + // The jump whose highlight has already faded. + const [fadedJump, setFadedJump] = useState(0); + const rows = useRef(new Map()); + + const open = hiddenAt === null || (jump !== null && jump.seq > hiddenAt); + const highlighted = jump !== null && jump.seq > fadedJump ? jump.number : null; + + useEffect(() => { + if (!jump) return; + const row = rows.current.get(jump.number); + const reduceMotion = typeof window !== 'undefined' + && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + row?.scrollIntoView?.({behavior: reduceMotion ? 'auto' : 'smooth', block: 'center'}); + row?.focus({preventScroll: true}); + const timer = setTimeout(() => setFadedJump(jump.seq), 1500); + return () => clearTimeout(timer); + }, [jump]); + + if (citations.length === 0) return null; + + const register = (number: number, element: HTMLLIElement | null) => { + if (element) rows.current.set(number, element); + else rows.current.delete(number); + }; + + return ( +
    +
    +

    + Datasets cited in this answer + + {citations.length} + +

    + +
    + + + + {open && ( +
      + {citations.map(citation => ( + + ))} +
    + )} +
    + ); +}; + diff --git a/src/components/DatasetReference.tsx b/src/components/DatasetReference.tsx index f17d03a..00745e8 100644 --- a/src/components/DatasetReference.tsx +++ b/src/components/DatasetReference.tsx @@ -1,81 +1,72 @@ -import {useRef, useState} from "react"; import {FileText} from "lucide-react"; import type {BackendDataset} from "../types/commons.ts"; -import {SearchResultItem} from "./SearchResultItem.tsx"; +import {getProvenanceSource} from "../lib/repoProvenance.ts"; +import useMatomo from "../hooks/useMatomo"; interface DatasetReferenceProps { // The dataset this citation resolved to (matched on the link's href). dataset: BackendDataset; // Link text chosen by the model (e.g. "Global Carbon Budget"); falls back to the dataset title. label?: string; - isLoggedIn?: boolean; + // Position in the message's reference list. Omitted when the message has no list. + number?: number; + // Called with the reference number when the [n] marker is activated. + onJump?: (number: number) => void; } /** - * Inline citation-style reference to a dataset from the search results. - * Renders a small interactive chip; hovering or clicking reveals the dataset card. + * Inline citation of a dataset from the search results, in the style of a numbered + * reference. The dataset name is a pill that links straight to the source, so a plain + * click opens it and a modifier-click opens it in a background tab; the pill names the + * repository the data comes from. The bracketed [n] in front of it jumps to the + * dataset's entry in the message's reference list, where the card details and + * actions live; leading with the number is what pairs the pill with that entry. + * Nothing opens on hover. */ -export const DatasetReference = ({dataset, label, isLoggedIn = false}: DatasetReferenceProps) => { - const [open, setOpen] = useState(false); - const [pinned, setPinned] = useState(false); - const closeTimer = useRef | null>(null); - - const cancelClose = () => { - if (closeTimer.current) { - clearTimeout(closeTimer.current); - closeTimer.current = null; - } - }; - - const scheduleClose = () => { - cancelClose(); - closeTimer.current = setTimeout(() => setOpen(false), 150); - }; +export const DatasetReference = ({dataset, label, number, onJump}: DatasetReferenceProps) => { + const {trackEvent} = useMatomo(); const title = dataset.title || dataset._source?.titles?.[0]?.title || 'dataset'; - const chipText = label || title; - const visible = open || pinned; + const href = dataset.dataset_url || dataset._id; + const source = getProvenanceSource(dataset); + const reference = number === undefined + ? null + : `Reference ${number}: ${title}${source ? ` (${source.name})` : ''}`; return ( - - { - setPinned(p => !p); - setOpen(true); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setPinned(p => !p); - setOpen(true); - } - }} - onMouseEnter={() => { - cancelClose(); - setOpen(true); - }} - onMouseLeave={scheduleClose} + <> + {reference && ( + + )} + trackEvent('Dataset', 'citation_source_clicked', title)} // Inline (not inline-flex) so a long title wraps across lines with the text; // box-decoration-clone keeps the pill background/border on every wrapped line. - className="mx-0.5 rounded bg-blue-50 px-1.5 py-px text-xs font-medium text-blue-700 border border-blue-200 hover:bg-blue-100 hover:border-blue-300 transition-colors cursor-pointer [box-decoration-break:clone]" + className="mx-0.5 rounded bg-blue-50 px-1.5 py-px text-xs font-medium text-blue-700 border border-blue-200 hover:bg-blue-100 hover:border-blue-300 transition-colors [box-decoration-break:clone]" > - {chipText} - - - {visible && ( -
    - -
    - )} -
    + {label || title} + {source && ( + + {source.name} + + )} + + ); }; diff --git a/src/components/MessageMarkdown.test.tsx b/src/components/MessageMarkdown.test.tsx index 8554901..0be5988 100644 --- a/src/components/MessageMarkdown.test.tsx +++ b/src/components/MessageMarkdown.test.tsx @@ -1,25 +1,43 @@ -import {describe, it, expect} from "vitest"; +import {describe, it, expect, vi} from "vitest"; import {render, screen} from "@testing-library/react"; -import {MemoryRouter} from "react-router"; +import userEvent from "@testing-library/user-event"; import {makeDataset} from "@/test/fixtures/datasets"; import type {BackendDataset} from "@/types/commons"; -import {normalizeDatasetUrl} from "@/lib/datasetCitations"; +import {normalizeDatasetUrl, type DatasetCitation} from "@/lib/datasetCitations"; import {MessageMarkdown} from "./MessageMarkdown"; const DATASET_URL = "https://doi.org/10.5281/zenodo.1234567"; -const hit = makeDataset({dataset_url: DATASET_URL, title: "Ocean Temperatures 2023"}); +const hit = makeDataset({dataset_url: DATASET_URL, title: "Ocean Temperatures 2023", _source: {_repo: "Zenodo"}}); const datasets = new Map([[normalizeDatasetUrl(DATASET_URL), hit]]); -const renderMarkdown = (text: string, streaming = false) => render( - // SearchResultItem (rendered by a dataset citation) reads the search params. - , +const renderMarkdown = ( + text: string, + streaming = false, + citations?: DatasetCitation[], + onCite?: (number: number) => void, +) => render( + , ); describe("MessageMarkdown", () => { - it("renders a link to a known dataset as a citation chip", () => { + it("renders a link to a known dataset as a pill linking to the source, tagged with its repository", () => { renderMarkdown(`See [Ocean temps](${DATASET_URL}) for details.`); - expect(screen.getByRole("button", {name: /Ocean temps/})).toBeInTheDocument(); - expect(screen.queryByRole("link")).not.toBeInTheDocument(); + const pill = screen.getByRole("link", {name: /Ocean temps/}); + expect(pill).toHaveAttribute("href", DATASET_URL); + expect(pill).toHaveAttribute("target", "_blank"); + expect(pill).toHaveAttribute("rel", "noopener noreferrer"); + expect(pill).toHaveTextContent("Zenodo"); + // Without the message's citations there is no reference list to point at. + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("follows a cited dataset with a [n] marker that reports its reference number", async () => { + const onCite = vi.fn(); + renderMarkdown(`See [Ocean temps](${DATASET_URL}).`, false, [{number: 3, dataset: hit}], onCite); + const marker = screen.getByRole("button", {name: "Reference 3: Ocean Temperatures 2023 (Zenodo)"}); + expect(marker).toHaveTextContent("[3]"); + await userEvent.click(marker); + expect(onCite).toHaveBeenCalledWith(3); }); it("renders an unknown link as a safe external link", () => { diff --git a/src/components/MessageMarkdown.tsx b/src/components/MessageMarkdown.tsx index d1b4152..0df5aba 100644 --- a/src/components/MessageMarkdown.tsx +++ b/src/components/MessageMarkdown.tsx @@ -1,6 +1,6 @@ -import {Fragment, JSX} from "react"; +import {Fragment, JSX, useMemo} from "react"; import type {BackendDataset} from "../types/commons.ts"; -import {lookupDataset} from "../lib/datasetCitations.ts"; +import {lookupDataset, type DatasetCitation} from "../lib/datasetCitations.ts"; import {trimTrailingPartialLink} from "../lib/utils.ts"; import {DatasetReference} from "./DatasetReference.tsx"; @@ -10,7 +10,11 @@ interface MessageMarkdownProps { datasets: Map; // True while this text is still streaming in. streaming?: boolean; - isLoggedIn?: boolean; + // The message's numbered citations (see collectCitations); a link to a cited + // dataset gets its [n] marker. Without them a matched link is a bare pill. + citations?: DatasetCitation[]; + // Called with the reference number when a [n] marker is activated. + onCite?: (number: number) => void; } const sanitizeLinkHref = (href: string): string | null => { @@ -27,10 +31,15 @@ const sanitizeLinkHref = (href: string): string | null => { /** * Renders the subset of Markdown the assistant produces: links, bold, and * ordered/unordered list lines. A link pointing at one of the thread's search - * hits becomes an interactive dataset citation; any other link stays an ordinary + * hits becomes a numbered dataset citation; any other link stays an ordinary * external link. */ -export const MessageMarkdown = ({text, datasets, streaming = false, isLoggedIn = false}: MessageMarkdownProps) => { +export const MessageMarkdown = ({text, datasets, streaming = false, citations = [], onCite}: MessageMarkdownProps) => { + const numbers = useMemo( + () => new Map(citations.map(citation => [citation.dataset, citation.number])), + [citations] + ); + const renderInline = (line: string, lineIndex: number) => { // Handles **[label](url)**, [label](url), and **bold** in a single pass. const tokenRegex = /\*\*\[(.+?)]\((.+?)\)\*\*|\[(.+?)]\((.+?)\)|\*\*(.+?)\*\*/g; @@ -60,7 +69,8 @@ export const MessageMarkdown = ({text, datasets, streaming = false, isLoggedIn = key={`md-${lineIndex}-${tokenIndex++}`} dataset={dataset} label={label} - isLoggedIn={isLoggedIn} + number={numbers.get(dataset)} + onJump={onCite} /> ) : ( { - const [searchParams] = useSearchParams(); - const {trackEvent} = useMatomo(); - - const cleanDescription = (html: string) => { - return stripHtml(html); - }; +/** + * OpenSearch's hybrid relevance score, normalised to 0-1 across the result set. Tool + * registry hits carry a raw rank (20, 19, 18…) instead, so this is clamped rather than + * rendered as "2000%". Renders nothing when the hit has no score. + */ +export const RelevanceBadge = ({hit}: { hit: BackendDataset }) => { + if (typeof hit._score !== 'number' || Number.isNaN(hit._score)) return null; + const scorePercent = Math.min(100, Math.max(0, hit._score * 100)); + return ( +
    + + + {scorePercent.toFixed(0)}% + +
    + ); +}; +/** Description, creators, publication date and subjects of a dataset, as shown on its card. */ +export const DatasetDetails = ({hit}: { hit: BackendDataset }) => { const [descExpanded, setDescExpanded] = useState(false); const [authorsExpanded, setAuthorsExpanded] = useState(false); - const handleDataplayer = () => { - if (!isLoggedIn) { - trackEvent('Auth', 'gate_triggered', 'data_player'); - loginWithReturn(); - return; - } - trackEvent('Dataset', 'play_clicked', hit.title); - const params = new URLSearchParams(); - params.set('datasetId', hit._id); - if (hit.title) { - params.set('title', hit.title); - } - // Preserve the search query for back navigation - const currentQuery = searchParams.get('q'); - if (currentQuery) { - params.set('q', currentQuery); - } - window.open(`/dataplayer?${params.toString()}`, '_blank', 'noopener,noreferrer'); - }; - - // OpenSearch's hybrid relevance score, normalised to 0-1 across the result set. Tool - // registry hits carry a raw rank (20, 19, 18…) instead, so this is clamped rather than - // rendered as "2000%". - const scorePercent = Math.min(100, Math.max(0, (hit._score ?? 0) * 100)); - - const getPublicationDate = (): string | null => { - // First priority: root-level publicationDate field (if not null) - if (hit.publication_date) { - return hit.publication_date; - } - - // Last resort: use publicationYear if available (show just the year) - if (hit._source.publicationYear) { - return hit._source.publicationYear; - } - - return null; - }; - - const publicationDate = getPublicationDate(); - - const formatDate = (dateStr: string): string => { - // If it's just a year (4 digits), return as-is - if (/^\d{4}$/.test(dateStr)) { - return dateStr; - } - // Otherwise format as YYYY.MM.DD - return new Date(dateStr).toISOString().slice(0, 10).replace(/-/g, '.'); - }; - - - const fullDescription = cleanDescription(hit.description || ''); + const fullDescription = stripHtml(hit.description || ''); const descLimit = 300; const isDescTruncated = fullDescription.length > descLimit; const visibleDescription = descExpanded || !isDescTruncated ? fullDescription : fullDescription.slice(0, descLimit) + '...'; - const creators = hit._source.creators || []; const baseAuthorsToShow = 3; const showAllAuthors = authorsExpanded || creators.length <= baseAuthorsToShow; const visibleCreators = showAllAuthors ? creators : creators.slice(0, baseAuthorsToShow); const remainingAuthors = Math.max(0, creators.length - baseAuthorsToShow); - return ( -
    -
    -

    - {hit.title} -

    - {typeof hit._score === 'number' && !Number.isNaN(hit._score) && ( -
    - - - {scorePercent.toFixed(0)}% - -
    - )} -
    + const publicationDate = publicationDateOf(hit); + return ( + <>

    {visibleDescription} @@ -148,12 +96,11 @@ export const SearchResultItem = ({hit, isLoggedIn = false}: SearchResultItemProp

    )} - {publicationDate && (
    - {formatDate(publicationDate)} + {formatPublicationDate(publicationDate)}
    )} @@ -177,38 +124,91 @@ export const SearchResultItem = ({hit, isLoggedIn = false}: SearchResultItemProp
    )} + + ); +}; + +interface DatasetActionsProps { + hit: BackendDataset; + isLoggedIn?: boolean; + // Smaller buttons, to sit in a list row. + compact?: boolean; +} -
    -
    -
    - - {!isLoggedIn && ( -
    - Sign in to use the data player -
    - )} +/** Play, Source and Cite for a dataset. */ +export const DatasetActions = ({hit, isLoggedIn = false, compact = false}: DatasetActionsProps) => { + const [searchParams] = useSearchParams(); + const {trackEvent} = useMatomo(); + + const handleDataplayer = () => { + if (!isLoggedIn) { + trackEvent('Auth', 'gate_triggered', 'data_player'); + loginWithReturn(); + return; + } + trackEvent('Dataset', 'play_clicked', hit.title); + const params = new URLSearchParams(); + params.set('datasetId', hit._id); + if (hit.title) { + params.set('title', hit.title); + } + // Preserve the search query for back navigation + const currentQuery = searchParams.get('q'); + if (currentQuery) { + params.set('q', currentQuery); + } + window.open(`/dataplayer?${params.toString()}`, '_blank', 'noopener,noreferrer'); + }; + + const size = compact ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm'; + const icon = compact ? 'h-3.5 w-3.5' : 'h-4 w-4'; + + return ( +
    + trackEvent('Dataset', 'source_clicked', hit.title)} + aria-label={`Redirect to the source of dataset ${hit.title}`} + className={`inline-flex items-center justify-center gap-1 rounded-md bg-blue-600 ${size} font-medium text-white shadow-sm hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-colors cursor-pointer`}> + + Source + +
    ); -}; \ No newline at end of file +}; + +export const SearchResultItem = ({hit, isLoggedIn = false}: SearchResultItemProps) => ( +
    +
    +

    + {hit.title} +

    + +
    + + + +
    + +
    + +
    +
    +
    +); diff --git a/src/pages/ChatPage.test.tsx b/src/pages/ChatPage.test.tsx index 9eb85eb..1d9d3c7 100644 --- a/src/pages/ChatPage.test.tsx +++ b/src/pages/ChatPage.test.tsx @@ -1,5 +1,5 @@ import {describe, it, expect, vi, beforeEach} from "vitest"; -import {render, screen, waitFor} from "@testing-library/react"; +import {render, screen, waitFor, within} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import {MemoryRouter, Route, Routes} from "react-router"; import {http, HttpResponse} from "msw"; @@ -83,7 +83,7 @@ describe("ChatPage", () => { expect(screen.getByText("Let me look that up.")).toBeInTheDocument(); }); - it("renders a cited dataset as an interactive reference and other links as plain links", async () => { + it("renders a cited dataset as a numbered reference with a list entry, and other links as plain links", async () => { const user = userEvent.setup(); renderChat(); @@ -96,10 +96,16 @@ describe("ChatPage", () => { expect(external).toHaveAttribute("target", "_blank"); expect(external).toHaveAttribute("rel", "noopener noreferrer"); - // Matched link: a chip that reveals the dataset card when clicked. - const citation = await screen.findByRole("button", {name: /Ocean temps/}); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - await user.click(citation); - expect(await screen.findByRole("dialog")).toHaveTextContent("Ocean Temperatures 2023"); + // Matched link: a pill linking straight to the source, followed by its [1] marker. + const pill = await screen.findByRole("link", {name: /Ocean temps/}); + expect(pill).toHaveAttribute("href", DATASET_URL); + expect(screen.getByRole("button", {name: /Reference 1: Ocean Temperatures 2023/})).toHaveTextContent("[1]"); + + // The reference list under the answer carries the same number and the card's actions. + const references = screen.getByRole("region", {name: "Datasets cited in this answer"}); + expect(within(references).getByText("[1]")).toBeInTheDocument(); + expect(within(references).getByText("Ocean Temperatures 2023")).toBeInTheDocument(); + expect(within(references).getByRole("link", {name: /source of dataset Ocean Temperatures 2023/})) + .toHaveAttribute("href", hit._id); }); }); diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 78c1dd6..150be28 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -9,8 +9,7 @@ import {buildDatasetUrlMap} from "@/lib/datasetCitations.ts"; import {getUserInitials} from "@/lib/userUtils.ts"; import dataCommonsIconBlue from '@/assets/data-commons-icon-blue.svg'; import {ChevronDown, ChevronUp, Loader2, Menu, MessageSquare, Plus, Send, User, X} from "lucide-react"; -import {MessageMarkdown} from "@/components/MessageMarkdown.tsx"; -import {ToolCallEntry} from "@/components/ToolCallEntry.tsx"; +import {BotMessageBody} from "@/components/BotMessageBody.tsx"; import {SearchInput} from "@/components/SearchInput.tsx"; import {DeleteConversationDialog} from "@/components/DeleteConversationDialog.tsx"; import {ConversationSidebarItem} from "@/components/ConversationSidebarItem.tsx"; @@ -369,35 +368,6 @@ const ChatPage: FC = () => { const lastMessage = messages[messages.length - 1]; const lastMessageIsStreaming = !!lastMessage?.isStreaming && (lastMessage.blocks?.length ?? 0) > 0; - /** Bot message body: tool calls and text in the order the agent produced them. */ - const renderBotMessage = (msg: Message, msgIndex: number) => { - const blocks = msg.blocks ?? (msg.content ? [{kind: 'text' as const, text: msg.content}] : []); - const lastTextIndex = blocks.reduce((acc, b, i) => (b.kind === 'text' ? i : acc), -1); - - return blocks.map((block, blockIndex) => { - if (block.kind === 'tool') { - return ( - - ); - } - if (!block.text.trim()) return null; - return ( -
    - -
    - ); - }); - }; - return (
    { Collapse
    - {renderBotMessage(msg, index)} +
    )}
    From a89cc5204b456e71826caeda202533d1370e67c0 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 18:27:55 +0200 Subject: [PATCH 05/12] docs: changelog entry for numbered dataset citations in chat --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb2989..62c081b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented in this file. +## [0.10.2] - 08/09/2026 + +Datasets in an AI answer are now cited the way a paper cites its references: + +- A dataset mentioned in an answer is a small link that names the repository it comes from and opens the dataset's + page in a new tab. Ctrl+click (Cmd+click on a Mac) opens it in a background tab, so several can be opened in a row. + Nothing pops up on hover any more. +- Each mention starts with a bracketed number, [1], [2] and so on, in order of first mention. Clicking the number + jumps to that dataset in a new "Datasets cited in this answer" list under the answer and highlights it. +- The list shows every cited dataset as a compact row: its number, the repository logo, title, year and authors, and + the same Play, Source and Cite buttons as a search result. Expand a row to see the full description, subjects and + relevance score. The list can be hidden, and comes back when a number is clicked. +- A "Cited from" line above the list shows which repositories the cited datasets come from, with a count for each. +- The list fills in while the answer is still streaming, and a number never changes once it has been assigned. +- Matomo records clicks on a cited dataset's link and on the bracketed numbers, as `citation_source_clicked` and + `citation_marker_clicked` under the Dataset and Chat categories. + ## [0.10.1] - 17/08/2026 File previews work again, and the chat is usable on a phone: From 01e2d2d81cfa2b049595940f0e330c56a0265c6c Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:12:25 +0200 Subject: [PATCH 06/12] feat: normalize blank line handling in markdown rendering to reduce excessive spacing --- src/components/MessageMarkdown.test.tsx | 18 ++++++++++++++++++ src/components/MessageMarkdown.tsx | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/MessageMarkdown.test.tsx b/src/components/MessageMarkdown.test.tsx index 0be5988..6944ad3 100644 --- a/src/components/MessageMarkdown.test.tsx +++ b/src/components/MessageMarkdown.test.tsx @@ -64,6 +64,24 @@ describe("MessageMarkdown", () => { expect(screen.getByText("1.")).toBeInTheDocument(); }); + // A blank line renders as an empty `min-h-6` paragraph, so unnormalized padding + // around the agent's text showed up as large white gaps between tool calls. + it("keeps one blank line as a paragraph break and drops the rest", () => { + const emptyParagraphs = (container: HTMLElement) => + Array.from(container.querySelectorAll("p")).filter(p => p.textContent === "").length; + + const padded = renderMarkdown("\n\nI'll search for datasets.\n\n\n\n").container; + expect(padded.querySelectorAll("p")).toHaveLength(1); + expect(emptyParagraphs(padded)).toBe(0); + + const runOfBlanks = renderMarkdown("First paragraph.\n\n\n\nSecond paragraph.").container; + expect(emptyParagraphs(runOfBlanks)).toBe(1); + + // A single deliberate break still separates the two paragraphs. + const singleBreak = renderMarkdown("First paragraph.\n\nSecond paragraph.").container; + expect(emptyParagraphs(singleBreak)).toBe(1); + }); + it("hides a trailing partial link only while streaming", () => { renderMarkdown("Found [Ocean te", true); expect(screen.getByText("Found")).toBeInTheDocument(); diff --git a/src/components/MessageMarkdown.tsx b/src/components/MessageMarkdown.tsx index 0df5aba..2c37eed 100644 --- a/src/components/MessageMarkdown.tsx +++ b/src/components/MessageMarkdown.tsx @@ -103,7 +103,13 @@ export const MessageMarkdown = ({text, datasets, streaming = false, citations = return nodes.map((node, idx) => {node}); }; - const content = streaming ? trimTrailingPartialLink(text) : text; + // Every line becomes its own `min-h-6` paragraph below, so a raw blank line is + // 24px of empty space. The agent pads its text with newlines around tool calls, + // which stacked up into large gaps; collapse a run of blanks to the single break + // it means and drop the padding at the edges, leaving the block spacing to the caller. + const content = (streaming ? trimTrailingPartialLink(text) : text) + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, ''); return ( <> From 36af1ed618d84f85c1c36c65d2ac2d4186f0c335 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:12:31 +0200 Subject: [PATCH 07/12] feat: add EMPIAR repository with logo and update tests for provenance source --- src/lib/repoProvenance.test.ts | 8 +++++++- src/lib/repoProvenance.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/repoProvenance.test.ts b/src/lib/repoProvenance.test.ts index 598a9df..6af2058 100644 --- a/src/lib/repoProvenance.test.ts +++ b/src/lib/repoProvenance.test.ts @@ -39,6 +39,11 @@ describe("getRepository", () => { it("maps known repo codes to a logo", () => { expect(getRepository(hit({_repo: "DANS"}))).toMatchObject({code: "DANS", name: "DANS"}); expect(getRepository(hit({_repo: "hal"}))?.name).toBe("HAL Open Science"); + expect(getRepository(hit({_repo: "EMPIAR"}))).toMatchObject({ + code: "EMPIAR", + name: "EMPIAR", + logo: "https://www.ebi.ac.uk/em_static/empiar/EMPIAR_logo_2017_imagemark.png", + }); }); it("links to the record's own landing page", () => { @@ -101,8 +106,9 @@ describe("getOwner", () => { describe("getProvenanceSource", () => { it("names the source repository of a directly harvested record", () => { expect(getProvenanceSource(hit({_repo: "HAL"}))?.name).toBe("HAL Open Science"); - // Unknown code: still named, as text. expect(getProvenanceSource(hit({_repo: "EMPIAR"}))?.name).toBe("EMPIAR"); + // Unknown code: still named, as text. + expect(getProvenanceSource(hit({_repo: "FOO"}))?.name).toBe("FOO"); }); it("names the owner of an aggregated record, or the aggregator when the owner is unknown", () => { diff --git a/src/lib/repoProvenance.ts b/src/lib/repoProvenance.ts index 0128521..805b28d 100644 --- a/src/lib/repoProvenance.ts +++ b/src/lib/repoProvenance.ts @@ -48,8 +48,9 @@ const PLATFORM_HOSTS: { suffix: string; code: string }[] = [ // ── Source repositories (single logo when harvested directly, no aggregator) ── // Keyed by the upstream `_repo` code. Official EOSC CDN assets from the Confluence "Data Model" page -// where they actually resolve. PaNOSC uses the project's own logo (its CDN copy 302s to the homepage -// — not uploaded). MDDB has no working asset yet (CDN SVG missing) so it falls back to text. +// where they actually resolve. PaNOSC and EMPIAR use the project's own logo (PaNOSC's CDN copy 302s +// to the homepage — not uploaded; EMPIAR has no CDN asset). MDDB has no working asset yet (CDN SVG +// missing) so it falls back to text. const REPOSITORIES: Record = { DANS: {name: "DANS", logo: `${CDN}/2025/04/DANS.png`}, HAL: {name: "HAL Open Science", logo: `${CDN}/2025/07/HAL.png`}, @@ -64,6 +65,10 @@ const REPOSITORIES: Record = { FINBIF: {name: "FinBIF", logo: `${CDN}/2025/07/FinBif.png`}, DASCH: {name: "DaSCH", logo: `${CDN}/2025/07/DASCH.png`}, EODC: {name: "EODC", logo: `${CDN}/2025/07/EODC-lightblue.png`}, + EMPIAR: { + name: "EMPIAR", + logo: "https://www.ebi.ac.uk/em_static/empiar/EMPIAR_logo_2017_black_font.png" + }, MDDB: {name: "MDDB", logo: null}, DATAVERSELV: {name: "DataverseLV", logo: null}, // https://dataverse.lv/en/ }; From fad8101cc1f11721a91b2f9a4248348dad2fbb66 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:20:01 +0200 Subject: [PATCH 08/12] feat: implement streaming anchor for bot messages and add tests for streaming behavior --- src/components/BotMessageBody.test.tsx | 40 ++++++++++++++++++++++++++ src/components/BotMessageBody.tsx | 5 +++- src/pages/ChatPage.tsx | 40 ++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/components/BotMessageBody.test.tsx diff --git a/src/components/BotMessageBody.test.tsx b/src/components/BotMessageBody.test.tsx new file mode 100644 index 0000000..5b25264 --- /dev/null +++ b/src/components/BotMessageBody.test.tsx @@ -0,0 +1,40 @@ +import {describe, it, expect} from "vitest"; +import {render} from "@testing-library/react"; +import {MemoryRouter} from "react-router"; +import {makeDataset} from "@/test/fixtures/datasets"; +import {normalizeDatasetUrl} from "@/lib/datasetCitations"; +import type {Message} from "@/types/chat"; +import {BotMessageBody} from "./BotMessageBody"; + +const DATASET_URL = "https://doi.org/10.5281/zenodo.1234567"; +const datasets = new Map([[normalizeDatasetUrl(DATASET_URL), makeDataset({dataset_url: DATASET_URL})]]); + +const message = (isStreaming: boolean): Message => ({ + sender: "bot", + content: "", + isStreaming, + blocks: [ + {kind: "text", text: "Looking."}, + {kind: "tool", toolCall: {id: "c1", name: "search_data", args: "", done: true}}, + {kind: "text", text: `Found [it](${DATASET_URL}).`}, + ], +}); + +const renderBody = (msg: Message) => render( + , +); + +describe("BotMessageBody", () => { + it("marks the end of the newest text only while the answer streams", () => { + const {container} = renderBody(message(true)); + const anchors = container.querySelectorAll("[data-streaming-end]"); + expect(anchors).toHaveLength(1); + // After the last text block, not the first one. + expect(anchors[0].previousElementSibling?.textContent).toContain("Found"); + }); + + it("has no streaming mark once the answer has ended", () => { + const {container} = renderBody(message(false)); + expect(container.querySelector("[data-streaming-end]")).toBeNull(); + }); +}); diff --git a/src/components/BotMessageBody.tsx b/src/components/BotMessageBody.tsx index d12f46b..a54065e 100644 --- a/src/components/BotMessageBody.tsx +++ b/src/components/BotMessageBody.tsx @@ -47,6 +47,7 @@ export const BotMessageBody = ({message, datasets, isLoggedIn}: BotMessageBodyPr ); } if (!block.text.trim()) return null; + const streaming = !!message.isStreaming && blockIndex === lastTextIndex; return (
    + {/* Where the words being written end; the chat page keeps this in view. */} + {streaming && ); })} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 150be28..530ca79 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -60,9 +60,19 @@ const ChatPage: FC = () => { // To prevent processing initial state multiple times const initialQueryProcessed = useRef(false); - // Whether the view is following the bottom of the thread; false once the user - // scrolls up, so streamed content does not yank them back down. + // Whether the view is following the streamed content; false once the user + // scrolls away from it, so new content does not yank them back. const followingRef = useRef(true); + // True once the current run's answer text has been followed, so the final + // update of the run does not pull the reference list into view. + const answerFollowedRef = useRef(false); + + // While an answer streams, BotMessageBody marks the end of its newest text. + // The view follows that mark rather than the end of the thread: the reference + // list under the answer grows with it and would otherwise fill the viewport + // while the words being written sit above the fold. + const streamingAnchor = () => + messagesContainerRef.current?.querySelector('[data-streaming-end]') ?? null; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView?.({behavior: "smooth"}); @@ -70,13 +80,32 @@ const ChatPage: FC = () => { const scrollToBottomIfFollowing = () => { if (!followingRef.current) return; - requestAnimationFrame(() => messagesEndRef.current?.scrollIntoView?.({block: 'end'})); + requestAnimationFrame(() => { + const anchor = streamingAnchor(); + if (anchor) { + anchor.scrollIntoView?.({block: 'end'}); + answerFollowedRef.current = true; + return; + } + // The answer has ended: leave the reader where its text ended. + if (answerFollowedRef.current) return; + messagesEndRef.current?.scrollIntoView?.({block: 'end'}); + }); }; const handleScroll = () => { - if (!messagesContainerRef.current) return; - const {scrollTop, scrollHeight, clientHeight} = messagesContainerRef.current; + const container = messagesContainerRef.current; + if (!container) return; + const {scrollTop, scrollHeight, clientHeight} = container; const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + const anchor = streamingAnchor(); + if (anchor) { + // Following the answer: its newest text is within the view or just below it. + const gap = anchor.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom; + followingRef.current = gap < 100 && gap > -clientHeight; + setShowScrollButton(!isNearBottom && !followingRef.current); + return; + } followingRef.current = isNearBottom; setShowScrollButton(!isNearBottom); }; @@ -275,6 +304,7 @@ const ChatPage: FC = () => { messages: updatedMessages, }); setIsSending(true); + answerFollowedRef.current = false; setTimeout(scrollToBottom, 50); // Turn a stream failure into a user-facing error bubble instead of a From 0602b7e5fa92cdad48c89c0dce44bdb153b0c8a2 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:20:07 +0200 Subject: [PATCH 09/12] feat: update EMPIAR logo URL in repository provenance tests --- src/lib/repoProvenance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/repoProvenance.test.ts b/src/lib/repoProvenance.test.ts index 6af2058..a393ab4 100644 --- a/src/lib/repoProvenance.test.ts +++ b/src/lib/repoProvenance.test.ts @@ -42,7 +42,7 @@ describe("getRepository", () => { expect(getRepository(hit({_repo: "EMPIAR"}))).toMatchObject({ code: "EMPIAR", name: "EMPIAR", - logo: "https://www.ebi.ac.uk/em_static/empiar/EMPIAR_logo_2017_imagemark.png", + logo: "https://www.ebi.ac.uk/em_static/empiar/EMPIAR_logo_2017_black_font.png", }); }); From f9ec64517b131a5a40a757eb7e5196e311808d52 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:43:20 +0200 Subject: [PATCH 10/12] feat: add logo handling for active repositories and update logo URLs for MDDB and DataverseLV --- src/lib/repoProvenance.test.ts | 10 ++++++++++ src/lib/repoProvenance.ts | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/repoProvenance.test.ts b/src/lib/repoProvenance.test.ts index a393ab4..1386c0d 100644 --- a/src/lib/repoProvenance.test.ts +++ b/src/lib/repoProvenance.test.ts @@ -46,6 +46,16 @@ describe("getRepository", () => { }); }); + // The repository codes the /stats endpoint reports as active, minus ONE (Onedata), + // which is an aggregator and so resolves through getAggregator instead. Each of these + // reaches the badge, where a missing logo shows as bare text next to the others' marks. + it("has a logo for every active repository", () => { + const active = ["DANS", "PANOSC", "HAL", "MDDB", "EMPIAR", "SWISSUBASE", + "ZENODO", "DABAR", "DATAVERSELV", "FINBIF", "DASCH"]; + const missing = active.filter(code => !getRepository(hit({_repo: code}))?.logo); + expect(missing).toEqual([]); + }); + it("links to the record's own landing page", () => { expect(getRepository(hit({_repo: "HAL", _id: "https://hal.inrae.fr/hal-1"}))?.href) .toBe("https://hal.inrae.fr/hal-1"); diff --git a/src/lib/repoProvenance.ts b/src/lib/repoProvenance.ts index 805b28d..d40fdd3 100644 --- a/src/lib/repoProvenance.ts +++ b/src/lib/repoProvenance.ts @@ -48,9 +48,11 @@ const PLATFORM_HOSTS: { suffix: string; code: string }[] = [ // ── Source repositories (single logo when harvested directly, no aggregator) ── // Keyed by the upstream `_repo` code. Official EOSC CDN assets from the Confluence "Data Model" page -// where they actually resolve. PaNOSC and EMPIAR use the project's own logo (PaNOSC's CDN copy 302s -// to the homepage — not uploaded; EMPIAR has no CDN asset). MDDB has no working asset yet (CDN SVG -// missing) so it falls back to text. +// where they actually resolve. PaNOSC, EMPIAR, MDDB and DataverseLV have no usable CDN asset, so they +// hotlink the project's own logo instead: PaNOSC's CDN copy 302s to the homepage (never uploaded), +// EMPIAR has none, and the MDDB / DataverseLV marks are SVG, which the CDN's WordPress rejects on +// upload. A hotlinked URL can move without notice; LogoImg falls back to the repository name as text +// when an image fails to load, so that degrades quietly rather than breaking the badge. const REPOSITORIES: Record = { DANS: {name: "DANS", logo: `${CDN}/2025/04/DANS.png`}, HAL: {name: "HAL Open Science", logo: `${CDN}/2025/07/HAL.png`}, @@ -69,8 +71,14 @@ const REPOSITORIES: Record = { name: "EMPIAR", logo: "https://www.ebi.ac.uk/em_static/empiar/EMPIAR_logo_2017_black_font.png" }, - MDDB: {name: "MDDB", logo: null}, - DATAVERSELV: {name: "DataverseLV", logo: null}, // https://dataverse.lv/en/ + MDDB: { + name: "MDDB", + logo: "https://mddbr.eu/wp-content/uploads/2023/06/MDDB_Logo_colour.svg" + }, + DATAVERSELV: { + name: "DataverseLV", + logo: "https://dataverse.lv/wp-content/uploads/2025/03/dataverseLV-1.svg" + }, }; // ── Owner logos (left, for aggregated records) ─────────────────────────────── From b378840b179848cd0b6bbaa056286113f3f19c5a Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:45:50 +0200 Subject: [PATCH 11/12] chore: update package-lock.json with dependency version upgrades --- package-lock.json | 112 +++++++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 169faf8..5237e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1194,29 +1194,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4875,9 +4889,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4987,9 +5001,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -5006,11 +5020,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -5093,9 +5107,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -5701,9 +5715,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -5766,9 +5780,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7838,10 +7852,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "8.1.0", @@ -8415,12 +8432,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -8857,14 +8875,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8876,13 +8894,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9550,9 +9568,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", From e756ed76fb6ea389eb819b2c5e1809bc1056f4e2 Mon Sep 17 00:00:00 2001 From: Ritwik Shanker Date: Tue, 8 Sep 2026 19:56:54 +0200 Subject: [PATCH 12/12] feat: implement sanitizeLinkHref utility to ensure safe linking of dataset URLs --- src/components/DatasetActions.test.tsx | 31 +++++++++++++ src/components/DatasetReference.test.tsx | 41 ++++++++++++++++ src/components/DatasetReference.tsx | 54 ++++++++++++++-------- src/components/MessageMarkdown.tsx | 13 +----- src/components/SearchResultItem.tsx | 21 +++++---- src/lib/utils.test.ts | 59 +++++++++++++++++++++++- src/lib/utils.ts | 39 ++++++++++++++-- 7 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 src/components/DatasetActions.test.tsx create mode 100644 src/components/DatasetReference.test.tsx diff --git a/src/components/DatasetActions.test.tsx b/src/components/DatasetActions.test.tsx new file mode 100644 index 0000000..29dc137 --- /dev/null +++ b/src/components/DatasetActions.test.tsx @@ -0,0 +1,31 @@ +import {describe, it, expect} from "vitest"; +import {render, screen} from "@testing-library/react"; +import {MemoryRouter} from "react-router"; +import {makeDataset} from "@/test/fixtures/datasets"; +import type {BackendDataset} from "@/types/commons"; +import {DatasetActions} from "./SearchResultItem"; + +// The Play button reads the search params. +const renderActions = (hit: BackendDataset) => + render(); + +describe("DatasetActions", () => { + it("links Source to the dataset id", () => { + const hit = makeDataset({_id: "https://doi.org/10.5281/zenodo.1", title: "Ocean Temperatures 2023"}); + renderActions(hit); + expect(screen.getByRole("link", {name: /source of dataset Ocean Temperatures 2023/})) + .toHaveAttribute("href", "https://doi.org/10.5281/zenodo.1"); + }); + + it("drops Source when the id is not an http(s) URL, keeping the other actions", () => { + const {container} = renderActions(makeDataset({_id: "javascript:alert(1)"})); + expect(screen.queryByRole("link", {name: /source of dataset/})).not.toBeInTheDocument(); + expect(container.innerHTML).not.toContain("javascript:"); + expect(screen.getByRole("button", {name: /data player/})).toBeInTheDocument(); + }); + + it("drops Source when the id is a bare identifier", () => { + renderActions(makeDataset({_id: "ds-1"})); + expect(screen.queryByRole("link", {name: /source of dataset/})).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DatasetReference.test.tsx b/src/components/DatasetReference.test.tsx new file mode 100644 index 0000000..ca462de --- /dev/null +++ b/src/components/DatasetReference.test.tsx @@ -0,0 +1,41 @@ +import {describe, it, expect} from "vitest"; +import {render, screen} from "@testing-library/react"; +import {makeDataset} from "@/test/fixtures/datasets"; +import {DatasetReference} from "./DatasetReference"; + +const DATASET_URL = "https://doi.org/10.5281/zenodo.1234567"; + +describe("DatasetReference", () => { + it("links the pill to the dataset's source URL", () => { + render(); + const pill = screen.getByRole("link", {name: /Ocean Temperatures 2023/}); + expect(pill).toHaveAttribute("href", DATASET_URL); + expect(pill).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("falls back to the id when the dataset carries no URL", () => { + render(); + expect(screen.getByRole("link", {name: /Test Dataset Title/})).toHaveAttribute("href", DATASET_URL); + }); + + it("names the dataset without linking it when the backend URL is not http(s)", () => { + const {container} = render( + , + ); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByTitle("Ocean Temperatures 2023")).toHaveTextContent("Ocean Temperatures 2023"); + expect(container.innerHTML).not.toContain("javascript:"); + }); + + it("does not link an id that is not a URL at all", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByTitle("Test Dataset Title")).toBeInTheDocument(); + }); + + it("keeps the [n] marker working when the pill cannot be linked", () => { + render(); + expect(screen.getByRole("button", {name: /^Reference 2:/})).toHaveTextContent("[2]"); + }); +}); diff --git a/src/components/DatasetReference.tsx b/src/components/DatasetReference.tsx index 00745e8..50df85b 100644 --- a/src/components/DatasetReference.tsx +++ b/src/components/DatasetReference.tsx @@ -1,6 +1,7 @@ import {FileText} from "lucide-react"; import type {BackendDataset} from "../types/commons.ts"; import {getProvenanceSource} from "../lib/repoProvenance.ts"; +import {sanitizeLinkHref} from "../lib/utils.ts"; import useMatomo from "../hooks/useMatomo"; interface DatasetReferenceProps { @@ -27,12 +28,29 @@ export const DatasetReference = ({dataset, label, number, onJump}: DatasetRefere const {trackEvent} = useMatomo(); const title = dataset.title || dataset._source?.titles?.[0]?.title || 'dataset'; - const href = dataset.dataset_url || dataset._id; + // Both the URL and the id are backend data, so neither is trusted as a link target. + const href = sanitizeLinkHref(dataset.dataset_url || dataset._id); const source = getProvenanceSource(dataset); const reference = number === undefined ? null : `Reference ${number}: ${title}${source ? ` (${source.name})` : ''}`; + // Inline (not inline-flex) so a long title wraps across lines with the text; + // box-decoration-clone keeps the pill background/border on every wrapped line. + const pillClass = 'mx-0.5 rounded bg-blue-50 px-1.5 py-px text-xs font-medium text-blue-700 ' + + 'border border-blue-200 [box-decoration-break:clone]'; + const pill = ( + <> + + {label || title} + {source && ( + + {source.name} + + )} + + ); + return ( <> {reference && ( @@ -49,24 +67,22 @@ export const DatasetReference = ({dataset, label, number, onJump}: DatasetRefere [{number}] )} - trackEvent('Dataset', 'citation_source_clicked', title)} - // Inline (not inline-flex) so a long title wraps across lines with the text; - // box-decoration-clone keeps the pill background/border on every wrapped line. - className="mx-0.5 rounded bg-blue-50 px-1.5 py-px text-xs font-medium text-blue-700 border border-blue-200 hover:bg-blue-100 hover:border-blue-300 transition-colors [box-decoration-break:clone]" - > - - {label || title} - {source && ( - - {source.name} - - )} - + {href ? ( + trackEvent('Dataset', 'citation_source_clicked', title)} + className={`${pillClass} hover:bg-blue-100 hover:border-blue-300 transition-colors`} + > + {pill} + + ) : ( + // No usable source URL: the citation still names the dataset, it just does + // not pretend to be clickable. + {pill} + )} ); }; diff --git a/src/components/MessageMarkdown.tsx b/src/components/MessageMarkdown.tsx index 2c37eed..546ab99 100644 --- a/src/components/MessageMarkdown.tsx +++ b/src/components/MessageMarkdown.tsx @@ -1,7 +1,7 @@ import {Fragment, JSX, useMemo} from "react"; import type {BackendDataset} from "../types/commons.ts"; import {lookupDataset, type DatasetCitation} from "../lib/datasetCitations.ts"; -import {trimTrailingPartialLink} from "../lib/utils.ts"; +import {sanitizeLinkHref, trimTrailingPartialLink} from "../lib/utils.ts"; import {DatasetReference} from "./DatasetReference.tsx"; interface MessageMarkdownProps { @@ -17,17 +17,6 @@ interface MessageMarkdownProps { onCite?: (number: number) => void; } -const sanitizeLinkHref = (href: string): string | null => { - const trimmed = href.trim(); - if (!trimmed) return null; - try { - const parsed = new URL(trimmed); - return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null; - } catch { - return null; - } -}; - /** * Renders the subset of Markdown the assistant produces: links, bold, and * ordered/unordered list lines. A link pointing at one of the thread's search diff --git a/src/components/SearchResultItem.tsx b/src/components/SearchResultItem.tsx index f0e94c2..78160f0 100644 --- a/src/components/SearchResultItem.tsx +++ b/src/components/SearchResultItem.tsx @@ -2,7 +2,7 @@ import type {BackendDataset} from "../types/commons.ts"; import {CalendarIcon, UserIcon, ExternalLinkIcon, TagIcon, Rocket} from "lucide-react"; import {ProportionalStar} from './ProportionalStar'; import {CitationExport} from './CitationExport'; -import {formatPublicationDate, publicationDateOf, stripHtml} from "../lib/utils"; +import {formatPublicationDate, publicationDateOf, sanitizeLinkHref, stripHtml} from "../lib/utils"; import {loginWithReturn} from "../lib/authRedirect"; import {useState} from 'react'; import {useSearchParams} from 'react-router'; @@ -162,6 +162,9 @@ export const DatasetActions = ({hit, isLoggedIn = false, compact = false}: Datas const size = compact ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm'; const icon = compact ? 'h-3.5 w-3.5' : 'h-4 w-4'; + // The id doubles as the dataset's source URL, but it is backend data: link to it only + // once it is known to be an http(s) address, otherwise leave the button out. + const sourceHref = sanitizeLinkHref(hit._id); return (
    @@ -180,13 +183,15 @@ export const DatasetActions = ({hit, isLoggedIn = false, compact = false}: Datas
    )}
    - trackEvent('Dataset', 'source_clicked', hit.title)} - aria-label={`Redirect to the source of dataset ${hit.title}`} - className={`inline-flex items-center justify-center gap-1 rounded-md bg-blue-600 ${size} font-medium text-white shadow-sm hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-colors cursor-pointer`}> - - Source - + {sourceHref && ( + trackEvent('Dataset', 'source_clicked', hit.title)} + aria-label={`Redirect to the source of dataset ${hit.title}`} + className={`inline-flex items-center justify-center gap-1 rounded-md bg-blue-600 ${size} font-medium text-white shadow-sm hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-colors cursor-pointer`}> + + Source + + )} ); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index e9e1133..4ad3438 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,5 +1,16 @@ import {describe, it, expect, vi, afterEach} from "vitest"; -import {getUserErrorMessage, stripHtml, stripMarkdown, fetchWithTimeout, prettyJson, trimTrailingPartialLink, formatFileSize, describeResultCount} from "./utils"; +import { + getUserErrorMessage, + stripHtml, + stripMarkdown, + fetchWithTimeout, + prettyJson, + trimTrailingPartialLink, + formatFileSize, + describeResultCount, + formatPublicationDate, + sanitizeLinkHref +} from "./utils"; describe("describeResultCount", () => { it("signals more results without quoting the inflated match total", () => { @@ -163,3 +174,49 @@ describe("trimTrailingPartialLink", () => { expect(trimTrailingPartialLink("no links here")).toBe("no links here"); }); }); + +describe("formatPublicationDate", () => { + it("keeps a bare year and dots a full date", () => { + expect(formatPublicationDate("2023")).toBe("2023"); + expect(formatPublicationDate("2023-05-17")).toBe("2023.05.17"); + }); + + it("reads the ISO prefix rather than parsing, so a timezone cannot shift the day", () => { + expect(formatPublicationDate("2023-05-01T23:00:00+05:00")).toBe("2023.05.01"); + expect(formatPublicationDate("2023-05-01T00:30:00-06:00")).toBe("2023.05.01"); + }); + + it("returns an unparseable date unchanged instead of throwing", () => { + expect(() => formatPublicationDate("n/a")).not.toThrow(); + expect(formatPublicationDate("n/a")).toBe("n/a"); + expect(formatPublicationDate("")).toBe(""); + expect(formatPublicationDate("unknown")).toBe("unknown"); + }); + + it("still formats a non-ISO date the backend can produce", () => { + expect(formatPublicationDate("May 17, 2023")).toBe("2023.05.17"); + }); +}); + +describe("sanitizeLinkHref", () => { + it("passes http and https through", () => { + expect(sanitizeLinkHref("https://doi.org/10.5281/zenodo.1")).toBe("https://doi.org/10.5281/zenodo.1"); + expect(sanitizeLinkHref("http://example.org/ds")).toBe("http://example.org/ds"); + expect(sanitizeLinkHref(" https://example.org/ds ")).toBe("https://example.org/ds"); + }); + + it("rejects schemes that would run or embed content", () => { + expect(sanitizeLinkHref("javascript:alert(1)")).toBeNull(); + expect(sanitizeLinkHref("JavaScript:alert(1)")).toBeNull(); + expect(sanitizeLinkHref("data:text/html,")).toBeNull(); + expect(sanitizeLinkHref("vbscript:msgbox(1)")).toBeNull(); + }); + + it("rejects values that are not URLs at all", () => { + expect(sanitizeLinkHref("ds-1")).toBeNull(); + expect(sanitizeLinkHref("")).toBeNull(); + expect(sanitizeLinkHref(" ")).toBeNull(); + expect(sanitizeLinkHref(null)).toBeNull(); + expect(sanitizeLinkHref(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 638113c..55dd867 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -187,6 +187,39 @@ export function prettyJson(raw: string): string { export const publicationDateOf = (hit: BackendDataset): string | null => hit.publication_date || hit._source.publicationYear || null; -/** A bare year stays as it is; a full date renders as YYYY.MM.DD. */ -export const formatPublicationDate = (dateStr: string): string => - /^\d{4}$/.test(dateStr) ? dateStr : new Date(dateStr).toISOString().slice(0, 10).replace(/-/g, '.'); +/** + * A bare year stays as it is; a full date renders as YYYY.MM.DD. The value comes from the + * backend unvalidated, so an ISO prefix is read straight off the string rather than parsed + * (parsing shifts a date that carries a timezone offset onto the neighbouring day), and a + * string that is no date at all is shown as it came instead of throwing mid-render. + */ +export const formatPublicationDate = (dateStr: string): string => { + const trimmed = dateStr.trim(); + if (/^\d{4}$/.test(trimmed)) return trimmed; + + const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed); + if (iso) return `${iso[1]}.${iso[2]}.${iso[3]}`; + + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return trimmed; + // Local components, not toISOString(): a format like "May 17, 2023" parses to local + // midnight, which UTC would render as the day before west of Greenwich. + const pad = (n: number) => String(n).padStart(2, '0'); + return `${parsed.getFullYear()}.${pad(parsed.getMonth() + 1)}.${pad(parsed.getDate())}`; +}; + +/** + * An href safe to hand to the DOM: http(s) only, so a `javascript:` or `data:` URL coming + * from backend data cannot become a live link. Returns null when the value is not a usable + * web address, leaving the caller to render the label without linking it. + */ +export const sanitizeLinkHref = (href: string | null | undefined): string | null => { + const trimmed = href?.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null; + } catch { + return null; + } +};