diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index a725fff38..97e714c5e 100644 --- a/src/components/document-viewer/source-panels.tsx +++ b/src/components/document-viewer/source-panels.tsx @@ -32,8 +32,6 @@ import { cn, codeText, eyebrowText, - fieldControl, - fieldLabel, floatingControl, LoadingPanel, panel, @@ -737,36 +735,53 @@ export function PinnedSourceEvidence({

{chunk.section_heading}

)}
{visibleContent || "No displayable clinical text was available for this indexed passage."}
-
- +
+ {compact && isLong ? ( - ) : null} + ) : ( +
@@ -910,7 +925,7 @@ function HighlightedSearchText({ text, terms }: { text: string; terms: string[] // Memoised: both the mobile
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, @@ -922,7 +937,6 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ idPrefix, sectionId, selectedChunkId, - onSearchChange, compact = false, revealRequest = false, }: { @@ -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"). @@ -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(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 // 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); @@ -1097,23 +1119,11 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ : `Extracted text for page ${selectedPage?.page_number ?? "n/a"} with searchable source passages.` } /> -
- +
{loading ? ( ) : ( -
+
Full extracted page text @@ -1150,7 +1160,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({
)} -
+

Source passages

{searchEligible ? ( @@ -1198,7 +1208,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ {documentSearchError}

) : null} -
+
{normalizedSearch.length === 1 ? (

Enter at least 2 characters to search all indexed passages. @@ -1239,7 +1249,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ >

{status} - + Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index} {chunk.serverRanked ? " · full-document search" : ""} @@ -1263,7 +1273,7 @@ export const IndexedTextPanel = memo(function IndexedTextPanel({ {chunk.section_heading} ) : null} - + {teaser || "No displayable clinical text was available for this indexed passage."} diff --git a/tests/document-section-summary.dom.test.tsx b/tests/document-section-summary.dom.test.tsx index a68b58955..421f1a9d1 100644 --- a/tests/document-section-summary.dom.test.tsx +++ b/tests/document-section-summary.dom.test.tsx @@ -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(); + + 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( + , + ); + 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, diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 747d903b5..e44922a91 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -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"); @@ -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/);