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
88 changes: 49 additions & 39 deletions src/components/document-viewer/source-panels.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,8 +32,6 @@ import {
cn,
codeText,
eyebrowText,
fieldControl,
fieldLabel,
floatingControl,
LoadingPanel,
panel,
Expand DownExpand Up@@ -737,36 +735,53 @@ export function PinnedSourceEvidence({
<p className="mt-2 text-sm font-semibold text-[color:var(--text)]">{chunk.section_heading}</p>
)}
<blockquote
id={chunk.id ? `cited-passage-${chunk.id}` : undefined}
className={cn(
"mt-2 rounded-lg bg-[color:var(--surface-inset)] px-3 py-2.5 text-[color:var(--text)]",
showingPreview ? "line-clamp-3 whitespace-normal" : "whitespace-pre-line",
"mt-2 rounded-lg bg-[color:var(--surface-inset)] px-3 py-2 text-[color:var(--text)]",
showingPreview ? "line-clamp-2 whitespace-normal" : "whitespace-pre-line",
)}
>
{visibleContent || "No displayable clinical text was available for this indexed passage."}
</blockquote>
<div className="mt-3 flex flex-wrap gap-2">
<a href="#pdf-preview-section" className={cn(primaryButton, "sm:min-h-9 px-3 text-xs")}>
<div className="mt-3 grid grid-cols-3 gap-1.5 sm:gap-2">
<a
href="#pdf-preview-section"
className={cn(
primaryButton,
"min-w-0 justify-center px-1.5 text-center text-2xs sm:min-h-9 sm:px-3 sm:text-xs",
)}
>
<ExternalLink aria-hidden="true" className="h-4 w-4" />
View in PDF
<span>View PDF</span>
</a>
{compact && isLong ? (
<button
type="button"
onClick={() => setExpandedChunkId((current) => (current === chunk.id ? null : chunk.id))}
className={cn(secondaryButton, "sm:min-h-9 px-3 text-xs")}
className={cn(
secondaryButton,
"min-w-0 justify-center px-1.5 text-center text-2xs sm:min-h-9 sm:px-3 sm:text-xs",
)}
data-testid="toggle-full-passage"
aria-expanded={expanded}
aria-controls={chunk.id ? `cited-passage-${chunk.id}` : undefined}
>
{expanded ? "Show passage preview" : "Show full passage"}
{expanded ? "Collapse" : "Full passage"}
</button>
) : null}
) : (
<span aria-hidden="true" />
)}
{onInspectIndexedText ? (
<button
type="button"
onClick={onInspectIndexedText}
className={cn(secondaryButton, "sm:min-h-9 px-3 text-xs")}
className={cn(
secondaryButton,
"min-w-0 justify-center px-1.5 text-center text-2xs sm:min-h-9 sm:px-3 sm:text-xs",
)}
data-testid="inspect-indexed-text"
>
Inspect indexed text
Indexed text
</button>
) : null}
</div>
Expand DownExpand Up@@ -910,7 +925,7 @@ function HighlightedSearchText({ text, terms }: { text: string; terms: string[]
// Memoised: both the mobile <details> and desktop copies stay mounted and are
// CSS-toggled, so without this every unrelated parent re-render (e.g. composer
// typing) re-rendered both instances. All props are referentially stable across
// those renders (onSearchChange is a stable setState), so memo actually elides them.
// those renders, so memo actually elides them.
export const IndexedTextPanel = memo(function IndexedTextPanel({
loading,
selectedPage,
Expand All@@ -922,7 +937,6 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
idPrefix,
sectionId,
selectedChunkId,
onSearchChange,
compact = false,
revealRequest = false,
}: {
Expand All@@ -936,7 +950,8 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
idPrefix: string;
sectionId?: "source-text";
selectedChunkId?: string;
onSearchChange: (value: string) => void;
/** Retained for call-site compatibility; search input is owned by the document composer. */
onSearchChange?: (value: string) => void;
compact?: boolean;
/**
* Explicit user intent to open the panel (e.g. "Inspect indexed text").
Expand DownExpand Up@@ -1002,19 +1017,26 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
// lost across re-renders and left deep-linked hits collapsed in Production UI.
const [manualClosedDriver, setManualClosedDriver] = useState<string | null>(null);
const [compactOpen, setCompactOpen] = useState(false);
const previousSearchRef = useRef("");
// In-document search and an explicit "Inspect indexed text" action keep the
// panel revealed through exclusive-accordion closes. Citation deep-links alone
// must not force-open — that stole the first viewport from the PDF.
const forceReveal = Boolean(normalizedSearch) || revealRequest;
const [prevForceReveal, setPrevForceReveal] = useState(forceReveal);
if (forceReveal !== prevForceReveal) {
setPrevForceReveal(forceReveal);
const previousForceRevealRef = useRef(forceReveal);
useEffect(() => {
if (forceReveal === previousForceRevealRef.current) return;
previousForceRevealRef.current = forceReveal;
// Rising edge: latch open so exclusive-accordion closes cannot collapse an
Comment thread
BigSimmo marked this conversation as resolved.
// active inspect/search reveal. Falling edge: drop the latch so jumping to
// PDF/overview (or clearing revealRequest on the same citation) restores
// PDF-first instead of leaving the dump controlled-open.
setCompactOpen(forceReveal);
}
}, [forceReveal]);
useEffect(() => {
if (previousSearchRef.current === normalizedSearch) return;
previousSearchRef.current = normalizedSearch;
setActiveHitIndex(0);
}, [normalizedSearch]);
if (previousAutoOpenDriverRef.current !== autoOpenDriver) {
previousAutoOpenDriverRef.current = autoOpenDriver;
if (manualClosedDriver !== null) setManualClosedDriver(null);
Expand DownExpand Up@@ -1097,31 +1119,19 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
: `Extracted text for page ${selectedPage?.page_number ?? "n/a"} with searchable source passages.`
}
/>
<div className={cn(clinicalDivider, "p-5 pt-4")}>
<label className="block">
<span className={fieldLabel}>Search within indexed source text</span>
<input
value={search}
onChange={(event) => {
setActiveHitIndex(0);
onSearchChange(event.target.value);
}}
placeholder="Find a term, warning, or monitoring item"
className={fieldControl}
/>
</label>
<div className={cn(clinicalDivider, "px-4 pb-4 pt-3 sm:p-5 sm:pt-4")}>
Comment thread
BigSimmo marked this conversation as resolved.
{loading ? (
<LoadingPanel label="Loading indexed source text" />
) : (
<div className="mt-4 grid gap-3">
<div className="grid gap-2.5">
<details
data-source-nested-disclosure
data-testid="indexed-page-text-disclosure"
className={cn(sourceCard, "group/source-row overflow-hidden p-0 source-print")}
>
<summary
onClick={handleNestedSummaryClick}
className="flex min-h-tap cursor-pointer list-none items-center justify-between gap-3 px-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9"
className="flex min-h-tap cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9"
>
<span>
<span className="block text-sm font-semibold text-[color:var(--text)]">Full extracted page text</span>
Comment thread
BigSimmo marked this conversation as resolved.
Expand DownExpand Up@@ -1150,7 +1160,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
</details>
</div>
)}
<div className={cn("mt-4 pt-4", clinicalDivider)}>
<div className={cn("mt-3 pt-3", clinicalDivider)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[color:var(--text)]">Source passages</p>
{searchEligible ? (
Expand DownExpand Up@@ -1198,7 +1208,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
{documentSearchError}
</p>
) : null}
<div className="mt-3 grid gap-3">
<div className="mt-2.5 grid gap-2.5">
{normalizedSearch.length === 1 ? (
<p className={cn("text-base-minus leading-6", textMuted)}>
Enter at least 2 characters to search all indexed passages.
Expand DownExpand Up@@ -1239,7 +1249,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
>
<summary
onClick={handleNestedSummaryClick}
className="flex min-h-tap cursor-pointer list-none items-start justify-between gap-3 px-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9"
className="flex min-h-tap cursor-pointer list-none items-start justify-between gap-2 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9"
>
<span className="min-w-0">
<span
Expand All@@ -1254,7 +1264,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
>
{status}
</span>
<span className={cn("mt-2 block", eyebrowText)}>
<span className={cn("mt-1.5 block", eyebrowText)}>
Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index}
{chunk.serverRanked ? " · full-document search" : ""}
</span>
Expand All@@ -1263,7 +1273,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
{chunk.section_heading}
</span>
) : null}
<span className={cn("mt-1 line-clamp-2 block text-sm leading-5", textMuted)}>
<span className={cn("mt-1 line-clamp-1 block text-xs leading-5", textMuted)}>
{teaser || "No displayable clinical text was available for this indexed passage."}
</span>
</span>
Expand Down
59 changes: 59 additions & 0 deletions tests/document-section-summary.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -154,6 +154,65 @@ describe("IndexedTextPanel citation landing", () => {
expect(screen.getByText("Hit 1 of 1")).toBeVisible();
});

it("resets the active hit index when the search query changes", async () => {
const props = {
loading: false,
selectedPage: basePage,
chunks: [
baseChunk,
{
...baseChunk,
id: "chunk-2",
chunk_index: 1,
content: "Lithium levels are checked 5 to 7 days after initiation",
},
],
search: "vom",
documentSearchResults: [
{
id: "chunk-1",
page_number: 1,
chunk_index: 0,
section_heading: "Monitoring",
snippet: "Escalate review when there is vomiting",
matched_terms: ["vom"],
image_ids: [],
score: 1,
},
{
id: "chunk-2",
page_number: 1,
chunk_index: 1,
section_heading: "Monitoring",
snippet: "Lithium levels are checked 5 to 7 days after initiation",
matched_terms: ["vom"],
image_ids: [],
score: 1,
},
],
searchingDocument: false,
documentSearchError: null,
idPrefix: "source-chunk",
sectionId: "source-text" as const,
onSearchChange: vi.fn(),
compact: true,
};
const { rerender } = render(<IndexedTextPanel {...props} />);

expect(screen.getByText("Hit 1 of 2")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Next document search hit" }));
expect(screen.getByText("Hit 2 of 2")).toBeVisible();

rerender(
<IndexedTextPanel
{...props}
search="head"
documentSearchResults={[props.documentSearchResults[1], props.documentSearchResults[0]]}
/>,
Comment thread
BigSimmo marked this conversation as resolved.
);
expect(screen.getByText("Hit 1 of 2")).toBeVisible();
});

it("keeps the deep-linked nested chunk disclosure open under an inspect reveal", async () => {
const props = {
loading: false,
Expand Down
16 changes: 13 additions & 3 deletions tests/ui-smoke.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4234,7 +4234,10 @@ test.describe("Clinical KB UI smoke coverage", () => {
page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"),
).toHaveJSProperty("open", true);

const sourceSearch = page.getByLabel("Search within indexed source text").last();
// The fixed document composer is the single search owner; the indexed-text
// disclosure must not duplicate a large search field inside its content.
const sourceSearch = page.getByRole("textbox", { name: "Search within this document" });
await expect(page.getByLabel("Search within indexed source text")).toHaveCount(0);
await waitForReactEventHandler(sourceSearch, "onChange");
await sourceSearch.fill("safety plan include");
const desktopTextPanel = page.getByTestId("source-chunk-indexed-text-panel");
Expand DownExpand Up@@ -4416,14 +4419,21 @@ test.describe("Clinical KB UI smoke coverage", () => {
expect(indexedTextBox!.y).toBeLessThan(imagesBox!.y);

const passageToggle = page.getByTestId("toggle-full-passage").first();
await expect(passageToggle).toHaveText("Show full passage");
await expect(passageToggle).toHaveText("Full passage");
await expect(passageToggle).toHaveAttribute("aria-expanded", "false");
// Keyboard activation is intentional here: pdf.js can resize the canvas
// while Firefox is calculating pointer coordinates, but a focused native
// button must keep its expand/collapse behavior through that layout shift.
await activateFocusedControl(page, passageToggle);
await expect(passageToggle).toHaveText("Show passage preview");
await expect(passageToggle).toHaveText("Collapse");
await expect(passageToggle).toHaveAttribute("aria-expanded", "true");
const expandedEvidenceBox = await evidence.boundingBox();
expect(expandedEvidenceBox?.height ?? 0).toBeGreaterThan(evidenceBox!.height);
await activateFocusedControl(page, passageToggle);
await expect(passageToggle).toHaveText("Full passage");
await expect(passageToggle).toHaveAttribute("aria-expanded", "false");
const collapsedEvidenceBox = await evidence.boundingBox();
expect(collapsedEvidenceBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThan(expandedEvidenceBox!.height);
await openSection(/PDF preview/);
await expect(preview).toBeInViewport();
await openSection(/Indexed source text/);
Expand Down
Loading