Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions narratives/ui/src/components/card_response.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,13 @@ import {
textOf,
type MarkdownNode,
type TableAnalysis,
} from "../utils/markdown_table";
import { rehypeStreamWords } from "../utils/rehype_stream_words";
import type { ProvenanceItem } from "../hooks/use_sse_chat";
} from "@/src/utils/markdown_table";
import { rehypeStreamWords } from "@/src/utils/rehype_stream_words";
import type { ProvenanceItem } from "@/src/hooks/use_sse_chat";

interface ResponseCardProps {
title: string;
body: string;
streaming?: boolean;
// The answer's provenance list, so an inline [n] chip can name the source
// it points at rather than showing a bare number. Position n is chip [n] --
// the same order the synthesis prompt numbered and CitationsList renders.
Expand All @@ -61,6 +60,9 @@ interface ResponseCardProps {
// When set, the content animates in over this many milliseconds. When
// unset, it appears immediately.
streamMs?: number;
// Delay before the first word animates, in milliseconds, so a body that
// follows another in one reveal starts where the previous one ends.
streamStartMs?: number;
// Whether the animation is still running. Once it ends the animation class
// is dropped, so a copy of the DOM (the PDF export) does not replay it.
revealing?: boolean;
Expand All @@ -72,10 +74,10 @@ const LINK_COLOR = "var(--color-theme-primary)";
export function ResponseCard({
title,
body,
streaming,
sources,
bare = false,
streamMs,
streamStartMs,
revealing = streamMs !== undefined,
}: ResponseCardProps) {
const markdown = (
Expand All @@ -84,7 +86,12 @@ export function ResponseCard({
revealing ? " markdown-stream" : ""
}`}
>
<ReactMarkdownInner body={body} sources={sources} streamMs={streamMs} />
<ReactMarkdownInner
body={body}
sources={sources}
streamMs={streamMs}
streamStartMs={streamStartMs}
/>
</div>
);

Expand All @@ -96,12 +103,6 @@ export function ResponseCard({
<div className="self-start w-full border border-outline rounded-card overflow-hidden shadow-sm bg-surface-narrative">
<div className="px-6 py-4 border-b border-outline-variant flex justify-between items-center bg-surface">
<h3 className="text-label-large text-on-surface">{title}</h3>
{streaming && (
<span className="inline-flex items-center gap-1 text-xs text-on-surface-variant">
<span className="w-2 h-2 rounded-full bg-theme-primary animate-pulse" />
streaming
</span>
)}
</div>
{markdown}
</div>
Expand All @@ -116,10 +117,12 @@ function ReactMarkdownInner({
body,
sources,
streamMs,
streamStartMs,
}: {
body: string;
sources?: ProvenanceItem[];
streamMs?: number;
streamStartMs?: number;
}) {
// Memoized so streaming updates don't remount the answer and restart its
// reveal.
Expand Down Expand Up @@ -243,7 +246,9 @@ function ReactMarkdownInner({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={
streamMs === undefined ? [] : [[rehypeStreamWords, streamMs]]
streamMs === undefined
? []
: [[rehypeStreamWords, streamMs, streamStartMs]]
}
components={components}
>
Expand Down
78 changes: 78 additions & 0 deletions narratives/ui/src/components/chip_citation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
* permissions and limitations under the License.
*/

import { isValidElement, type ReactNode } from "react";
import { describe, expect, it } from "vitest";
import {
CitationChip,
citationAnchorId,
displayCitationNumber,
isCitable,
sourceLabel,
splitWithCitations,
} from "./chip_citation";
import type { ProvenanceItem } from "../hooks/use_sse_chat";

Expand Down Expand Up @@ -119,3 +122,78 @@ describe("citationAnchorId", () => {
expect(citationAnchorId(2, 4)).toBe(citationAnchorId(2, 4));
});
});

/** Reads the citation number from a CitationChip element, or returns the node as-is. */
function readChipNumberOrText(node: ReactNode): number | ReactNode {
if (isValidElement<{ n: number }>(node) && node.type === CitationChip) {
return node.props.n;
}
return node;
}

describe("splitWithCitations", () => {
it("renders one chip per number of a composite marker", () => {
// Test: Composite citation markers.
// Situation: A figure derived from two tables carries `[1, 2]`, a later
// figure carries `[3]`, and two markers sit side by side.
// Expectation: `[1, 2]` becomes a chip for 1, a space, and a chip for 2,
// each single marker becomes one chip, and the text between the markers
// is kept in place.
const nodes = splitWithCitations(
"Rate rose [1, 2] and fell [3][1].",
SOURCES,
);

expect(nodes.map(readChipNumberOrText)).toEqual([
"Rate rose ",
1,
" ",
2,
" and fell ",
3,
1,
".",
]);
});

it("accepts spacing around the comma of a composite marker", () => {
// Test: Marker spacing.
// Situation: Composite markers formatted without spaces (`[1,2]`) and with
// spaces around the comma (`[2 , 3]`).
// Expectation: Both split into one chip per number.
const tight = splitWithCitations("a [1,2] b", SOURCES);
const loose = splitWithCitations("a [2 , 3] b", SOURCES);

expect(tight.map(readChipNumberOrText)).toEqual(["a ", 1, " ", 2, " b"]);
expect(loose.map(readChipNumberOrText)).toEqual(["a ", 2, " ", 3, " b"]);
});

it("renders one chip for a marker that repeats a number", () => {
// Test: Repeated numbers inside one marker.
// Situation: Prose carries the marker `[1, 1]`.
// Expectation: A single chip for 1, with no separator.
const nodes = splitWithCitations("a [1, 1] b", SOURCES);

expect(nodes.map(readChipNumberOrText)).toEqual(["a ", 1, " b"]);
});

it("drops every number of a composite marker when citations are hidden", () => {
// Test: Hidden markers.
// Situation: A table cell whose column citation was hoisted onto the
// header holds a value and the composite marker `[1, 2]`.
// Expectation: The value stays and no chip or separator is rendered.
const nodes = splitWithCitations("value [1, 2]", SOURCES, true);

expect(nodes).toEqual(["value "]);
});

it("leaves text without a marker untouched", () => {
// Test: The no-marker path.
// Situation: Plain text, and text with brackets that are not a marker.
// Expectation: The input string comes back as the only node.
expect(splitWithCitations("no markers", SOURCES)).toEqual(["no markers"]);
expect(splitWithCitations("see [a] and [1, b]", SOURCES)).toEqual([
"see [a] and [1, b]",
]);
});
});
32 changes: 20 additions & 12 deletions narratives/ui/src/components/chip_citation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
* @fileoverview Renders an inline numbered citation chip.
*/

import { createContext, useContext } from "react";
import type { ProvenanceItem } from "../hooks/use_sse_chat";
import { createContext, useContext, type ReactNode } from "react";
import type { ProvenanceItem } from "@/src/hooks/use_sse_chat";
import {
createCitationMarkerPattern,
parseCitationNumbers,
} from "@/src/utils/citation_marker";
import { renderNumbers } from "./value_numeric";
import { Tooltip } from "./tooltip";

Expand Down Expand Up @@ -193,17 +197,18 @@ function ChildWithCitations({
}

/**
* Split a string into a mixed array of text fragments, shortened numbers and
* CitationChip elements, matching `[N]` where N is 1-99. Handles multiple
* adjacent citations like `[1] [2]` or `[1][2]`.
* Splits a string into a mixed array of text fragments, shortened numbers, and
* CitationChip elements, rendering one chip per number in each marker so a
* composite `[1, 2]` links to both of its sources. Handles multiple adjacent
* citations like `[1] [2]` or `[1][2]`.
*/
function splitWithCitations(
export function splitWithCitations(
s: string,
sources?: ProvenanceItem[],
hideCitations = false,
): React.ReactNode[] {
const pattern = /\[(\d{1,2})\]/g;
const out: React.ReactNode[] = [];
): ReactNode[] {
const pattern = createCitationMarkerPattern();
const out: ReactNode[] = [];
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
Expand All @@ -212,9 +217,12 @@ function splitWithCitations(
out.push(...renderNumbers(s.slice(last, match.index), `n${last}`));
}
if (!hideCitations) {
out.push(
<CitationChip key={`c-${i++}`} n={Number(match[1])} sources={sources} />,
);
parseCitationNumbers(match[1]).forEach((n, position) => {
if (position > 0) {
out.push(" ");
}
out.push(<CitationChip key={`c-${i++}`} n={n} sources={sources} />);
});
}
last = match.index + match[0].length;
}
Expand Down
Loading
Loading