From 5d7c87a1abe8d0f0ef8751cbabd5d8eeae617965 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:28:30 +0800 Subject: [PATCH 1/6] feat: preserve contextual back navigation and recent answers --- docs/design-system/adoption-manifest.json | 6 +- src/app/privacy/page.tsx | 6 +- src/components/ClinicalDashboard.tsx | 79 +++--- src/components/DocumentViewer.tsx | 8 +- .../use-persisted-answer-thread.ts | 60 +++++ src/components/contextual-back-link.tsx | 82 ++++++ .../differential-compare-queue-page.tsx | 7 +- ...ifferential-presentation-workflow-page.tsx | 13 +- .../differential-stream-workspace.tsx | 7 +- .../in-page-nav/in-page-nav-header.tsx | 8 +- src/components/information-page-shell.tsx | 6 +- src/components/navigation-back-button.tsx | 7 +- .../therapy-compass/screens/detail-screen.tsx | 8 +- src/components/ui/page-header.tsx | 13 +- src/lib/answer-thread-storage.ts | 196 ++++++++++++--- tests/answer-thread-storage.test.ts | 238 +++++++++++++++--- ...ontextual-back-navigation-contract.test.ts | 48 ++++ tests/factsheet-detail-header.dom.test.tsx | 7 +- tests/factsheet-save.dom.test.tsx | 5 + tests/form-confirm-callout.dom.test.tsx | 5 + tests/form-priority-facts.dom.test.tsx | 5 + tests/forms-back-navigation.dom.test.tsx | 5 + .../forms-information-disclosure.dom.test.tsx | 5 + tests/in-page-nav-header.dom.test.tsx | 5 +- tests/information-page-shell.dom.test.tsx | 6 +- tests/medication-record-page.dom.test.tsx | 4 + tests/navigation-back-button.dom.test.tsx | 118 +++++++-- .../patient-safety-plan-privacy.dom.test.tsx | 24 +- tests/privacy-ui.test.ts | 5 +- tests/ui-smoke.spec.ts | 75 +++++- 30 files changed, 875 insertions(+), 186 deletions(-) create mode 100644 src/components/clinical-dashboard/use-persisted-answer-thread.ts create mode 100644 src/components/contextual-back-link.tsx create mode 100644 tests/contextual-back-navigation-contract.test.ts diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 6cf447807..445f9a3b8 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -249,7 +249,11 @@ "preview": ".design-sync/previews/Breadcrumb.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/information-page-shell.dom.test.tsx"], + "testFiles": [ + "tests/design-sync-visual-exports.test.ts", + "tests/information-page-shell.dom.test.tsx", + "tests/ui-smoke.spec.ts" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index bbd2c26b6..235fe1236 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -43,7 +43,9 @@ const SECTIONS: Section[] = [ <> Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text is also omitted from durable query logs by default. A short-lived response cache can contain the answer while - its read TTL is valid. + its read TTL is valid. To make recent answers reappear quickly, completed answer threads may also remain in this + browser tab for up to 12 hours. That tab-only copy is not shared across tabs or devices and is never available + offline. ), }, @@ -64,7 +66,7 @@ const SECTIONS: Section[] = [ }, { heading: "Retention", - body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.", + body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Completed answer threads in the current browser tab expire no later than 12 hours after the most recent answer and are also cleared by New chat, sign-out, or an account change. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.", }, { heading: "Your responsibilities", diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a6d1fd98e..68a30eb47 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -157,7 +157,7 @@ import { UploadPanel, } from "@/components/clinical-dashboard/clinical-dashboard-lazy"; -import { clearLegacyRecentQueries, demoRecentQueryOwnerId, recentQueryStorageKey } from "@/lib/recent-query-storage"; +import { clearLegacyRecentQueries, recentQueryStorageKey } from "@/lib/recent-query-storage"; import type { SearchFacets } from "@/components/clinical-dashboard/document-search-results"; import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { @@ -212,10 +212,15 @@ import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-d import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { clearPersistedAnswerThread, + createAnswerThreadSnapshotMetadata, loadPersistedAnswerThread, maxStoredAnswerTurns, - savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; +import { + resolveDashboardAnswerThreadOwnerId, + usePersistedAnswerThread, + type AnswerThreadSnapshotMetadata, +} from "@/components/clinical-dashboard/use-persisted-answer-thread"; import { buildAnswerClipboardText } from "@/components/clinical-dashboard/answer-copy-payload"; import { buildAnswerRenderModel, isAnswerSourceBacked } from "@/lib/answer-render-policy"; import { @@ -342,6 +347,7 @@ export function ClinicalDashboard({ const threadRestoreScrolledRef = useRef(false); const restoredThreadFromStorageRef = useRef(false); const latestAnswerTurnRef = useRef | null>(null); + const latestAnswerSnapshotMetadataRef = useRef(null); const answerTurnSeqRef = useRef(0); const [documentMatches, setDocumentMatches] = useState([]); const [searchRelevance, setSearchRelevance] = useState(null); @@ -401,6 +407,7 @@ export function ClinicalDashboard({ setLatestAnswerQuery(null); setCollapsedTurnIds(new Set()); setShowEarlierTurns(false); + latestAnswerSnapshotMetadataRef.current = null; const ownerId = activeAnswerThreadOwnerIdRef.current; if (ownerId) clearPersistedAnswerThread(ownerId); }, []); @@ -656,13 +663,15 @@ export function ClinicalDashboard({ openAccountProfile, setSettingsOpen: settingsState.setSettingsOpen, }); - const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? demoRecentQueryOwnerId : null); + const answerThreadOwnerId = resolveDashboardAnswerThreadOwnerId(auth.session?.user.id, clientDemoMode, authStatus); const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); useEffect(() => { const previousOwnerId = previousAnswerThreadOwnerIdRef.current; previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId; activeAnswerThreadOwnerIdRef.current = answerThreadOwnerId; if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return; + clearPersistedAnswerThread(previousOwnerId); + if (answerThreadOwnerId) clearPersistedAnswerThread(answerThreadOwnerId); answerThreadBootstrappedRef.current = false; queueMicrotask(() => { setPriorAnswerTurns([]); @@ -671,25 +680,34 @@ export function ClinicalDashboard({ setAnswer(null); setSources([]); latestAnswerTurnRef.current = null; + latestAnswerSnapshotMetadataRef.current = null; setAnswerThreadBootstrapped(false); }); }, [answerThreadOwnerId]); useEffect(() => { if (authStatus === "loading" || answerThreadBootstrappedRef.current) return; queueMicrotask(() => { - const persisted = answerThreadOwnerId ? loadPersistedAnswerThread(answerThreadOwnerId) : null; + const expectedSubmissionSignature = + searchMode === "answer" && submittedUrlQuery + ? searchSubmissionSignature(searchMode, submittedUrlQuery, routedSearchContext) + : undefined; + const persisted = + answerThreadOwnerId && searchMode === "answer" + ? loadPersistedAnswerThread(answerThreadOwnerId, { expectedSubmissionSignature }) + : null; if (persisted) { restoredThreadFromStorageRef.current = true; setPriorAnswerTurns(persisted.priorTurns); setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + setShowEarlierTurns(persisted.showEarlierTurns); + latestAnswerSnapshotMetadataRef.current = persisted; if (persisted.latestTurn) { latestAnswerTurnRef.current = persisted.latestTurn; setAnswer(persisted.latestTurn.answer); setSources(persisted.latestTurn.sources); setModeSearchSubmitted(true); setQuery(""); - const restoredQuery = persisted.latestTurn.query.trim(); - if (restoredQuery) autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; + autoRunSearchSignatureRef.current = persisted.latestSubmissionSignature; } answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { const match = /^answer-turn-(\d+)$/.exec(turn.id); @@ -706,7 +724,7 @@ export function ClinicalDashboard({ answerThreadBootstrappedRef.current = true; setAnswerThreadBootstrapped(true); }); - }, [answerThreadOwnerId, authStatus]); + }, [answerThreadOwnerId, authStatus, routedSearchContext, searchMode, submittedUrlQuery]); // Local no-auth can still exercise public-read APIs, but administration is always // derived separately from the immutable account role claim. const uploadReadOnlyMode = resolveUploadReadOnlyMode({ @@ -839,29 +857,16 @@ export function ClinicalDashboard({ [answerThreadOwnerId], ); - useEffect(() => { - if (!answerThreadBootstrapped) return; - if (searchMode !== "answer") return; - if (!answer && priorAnswerTurns.length === 0) { - if (answerThreadOwnerId) clearPersistedAnswerThread(answerThreadOwnerId); - return; - } - if (!answerThreadOwnerId) return; - savePersistedAnswerThread(answerThreadOwnerId, { - version: 1, - priorTurns: priorAnswerTurns, - latestTurn: latestAnswerTurnRef.current, - collapsedTurnIds: [...collapsedTurnIds], - }); - }, [ - searchMode, + usePersistedAnswerThread({ + ownerId: answerThreadOwnerId, + enabled: answerThreadBootstrapped && searchMode === "answer", answer, - priorAnswerTurns, + priorTurns: priorAnswerTurns, + latestTurn: latestAnswerTurnRef.current, collapsedTurnIds, - latestAnswerQuery, - answerThreadBootstrapped, - answerThreadOwnerId, - ]); + showEarlierTurns, + metadata: latestAnswerSnapshotMetadataRef.current, + }); useEffect(() => { documentsRef.current = documents; @@ -1820,7 +1825,7 @@ export function ClinicalDashboard({ const priorTurn = archivePreviousAnswer ? latestAnswerTurnRef.current : null; if (priorTurn) { const turnId = `answer-turn-${++answerTurnSeqRef.current}`; - setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }].slice(-maxStoredAnswerTurns)); + setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }].slice(-(maxStoredAnswerTurns - 1))); setCollapsedTurnIds((current) => new Set(current).add(turnId)); } const committedQuery = displayQuery ?? payload.query; @@ -2100,6 +2105,15 @@ export function ClinicalDashboard({ // M10: discard a stale response — a newer search owns the UI state. if (requestIsCurrent()) { + if (successfulPayload.kind === "answer") { + latestAnswerSnapshotMetadataRef.current = createAnswerThreadSnapshotMetadata( + searchSubmissionSignature(targetMode, trimmedQuery, { + queryMode: targetQueryMode, + scopeFilters: filtersOverride, + scopeRef: privateScopeRef, + }), + ); + } applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer); if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery); if (successfulPayload.kind === "answer") { @@ -2108,11 +2122,8 @@ export function ClinicalDashboard({ // effect. Seed their completed context so a later in-place route to // the same query with different intent/scope is recognized as a // replacement search instead of leaving the old answer on screen. - autoRunSearchSignatureRef.current = searchSubmissionSignature(targetMode, trimmedQuery, { - queryMode: targetQueryMode, - scopeFilters: filtersOverride, - scopeRef: privateScopeRef, - }); + autoRunSearchSignatureRef.current = + latestAnswerSnapshotMetadataRef.current?.latestSubmissionSignature ?? null; // The composer is a draft box in a conversation: clear it so the // user can type the next follow-up immediately. setQuery(""); diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index a0aca346d..f37d1f1d3 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { CircleAlert, @@ -17,6 +16,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal"; @@ -1100,14 +1100,14 @@ export function DocumentViewer({ className="edge-glass-header relative z-30 border-b border-[color:var(--border)] py-2 shadow-[var(--shadow-tight)] backdrop-blur-xl max-sm:pt-2 sm:sticky sm:top-0 sm:pt-[max(0.5rem,env(safe-area-inset-top))]" >
-
- Search - + diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index de2df7b2b..6de1ddbce 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -21,6 +21,7 @@ import { import { DiagnosisTermChip, DiagnosisTermInlineList } from "@/components/differentials/diagnosis-term-link"; import { CopyAfterReviewButton } from "@/components/differentials/differential-presentation-actions"; import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { cn } from "@/components/ui-primitives"; import { isClinicalHingeLabel, resolveDiagnosisTermSegments } from "@/lib/differential-diagnosis-links"; import { @@ -741,25 +742,25 @@ export function DifferentialPresentationWorkflowPage({
- Back - +
- Back to differentials - +
diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index a9bdee9a0..ed2619d6b 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -6,6 +6,7 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea import { createPortal } from "react-dom"; import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, FileText, GitCompareArrows, ShieldAlert } from "lucide-react"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { appModeHomeHref } from "@/lib/app-modes"; import { normalizeSearchText } from "@/lib/catalog-search"; import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; @@ -685,13 +686,13 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }:

- Back to differential home - + - {showBackLabel ? {back.label} : null} - + {documentSections.length > 0 ? ( // The title is the section-list disclosure. Line two names where // you are, which the track can place but never label. diff --git a/src/components/information-page-shell.tsx b/src/components/information-page-shell.tsx index abff11c71..c728d3243 100644 --- a/src/components/information-page-shell.tsx +++ b/src/components/information-page-shell.tsx @@ -69,8 +69,8 @@ export type InformationPageCrumb = { }; /** - * Back-link + optional trail. Prefer this over mode-local `router.push` icon buttons - * so history-less deep links still land on the mode home. + * Contextual back-link + optional trail. History-less deep links still land on + * the mode home through the crumb's fallback href. * * Now a projection onto the DS `Breadcrumb` rather than a second implementation * of one. The semantics it already shipped are the ones that survive: a crumb @@ -91,7 +91,7 @@ export function InformationPageBreadcrumbs({ className?: string; }) { const items: Crumb[] = [ - { label: home.label, href: home.href, icon: ArrowLeft }, + { label: home.label, href: home.href, icon: ArrowLeft, behavior: "history-back" }, ...crumbs, ...(current ? [{ label: current }] : []), ]; diff --git a/src/components/navigation-back-button.tsx b/src/components/navigation-back-button.tsx index 251bdd67c..74593af3e 100644 --- a/src/components/navigation-back-button.tsx +++ b/src/components/navigation-back-button.tsx @@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react"; import { useRouter } from "next/navigation"; +import { navigateContextuallyBack } from "@/components/contextual-back-link"; import { cn, floatingControl, IconButton } from "@/components/ui-primitives"; type NavigationBackButtonProps = { @@ -17,9 +18,7 @@ type NavigationBackButtonProps = { }; /** - * Deterministic in-app back control. Always navigates to `fallbackHref` rather - * than `history.back()`, so deep links / external referrers cannot eject the - * user out of Clinical KB (same contract as form detail pages). + * Browser-history back control with a deterministic fallback for a fresh tab. */ export function NavigationBackButton({ label = "Go back", @@ -35,7 +34,7 @@ export function NavigationBackButton({ icon={ArrowLeft} onClick={() => { if (onBeforeNavigate && !onBeforeNavigate()) return; - router.push(fallbackHref); + navigateContextuallyBack(router, fallbackHref); }} className={cn(floatingControl, "rounded-full text-[color:var(--text-muted)]", className)} iconClassName="h-5 w-5" diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index 930e5cce0..d18c0e0ee 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { pageContainer } from "@/components/ui-primitives"; import { useTcBindings } from "../bindings"; @@ -34,14 +35,13 @@ export function DetailScreen() { return (
- +
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 570251028..1332b87f9 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -15,12 +15,15 @@ import { ChevronRight, type LucideIcon } from "lucide-react"; import Link from "next/link"; import type { ReactNode } from "react"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { cn, eyebrowText, iconTilePremium, textMuted } from "@/components/ui-primitives"; export type Crumb = { label: string; /** Omit on the final crumb — the current page is not a link to itself. */ href?: string; + /** History-aware page back; `href` remains the deep-link fallback. */ + behavior?: "link" | "history-back"; /** * Leading glyph. Exists so the first crumb can keep the back-arrow that the * information pages already use to get a history-less deep link home; that @@ -64,7 +67,15 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) { className="size-icon-xs shrink-0 text-[color:var(--decoration-soft)]" /> ) : null} - {item.href ? ( + {item.href && item.behavior === "history-back" ? ( + + {Icon ? + ) : item.href ? ( | null; collapsedTurnIds: string[]; + showEarlierTurns: boolean; + latestSubmissionSignature: string; + expiresAt: number; }; -// Stored envelope = the thread plus a write timestamp. savedAt stays internal to -// this module: loads strip it after the TTL check so callers round-trip the -// plain PersistedAnswerThread shape. Payloads written before the TTL existed -// have no savedAt and are accepted once; the next save stamps them. -type PersistedAnswerThreadEnvelope = PersistedAnswerThread & { savedAt?: number }; +type LegacyPersistedAnswerThread = { + version: 1; + priorTurns?: unknown; + latestTurn?: unknown; + collapsedTurnIds?: unknown; + savedAt?: unknown; +}; + +export type AnswerThreadRestoreOptions = { + /** Exact canonical signature for a submitted answer URL. Omit on the unsubmitted answer home. */ + expectedSubmissionSignature?: string; +}; const maxStorageBytes = 4_500_000; @@ -42,43 +76,81 @@ function isStoredAnswerTurn(value: unknown): value is StoredAnswerTurn { ); } -function normalizePersistedAnswerThread(value: unknown): PersistedAnswerThread | null { +function normalizeLatestTurn(value: unknown): Omit | null { if (!value || typeof value !== "object") return null; - const record = value as Partial; - if (record.version !== 1) return null; + const turn = value as Partial>; if ( - typeof record.savedAt === "number" && - Number.isFinite(record.savedAt) && - Date.now() - record.savedAt > answerThreadTtlMs + typeof turn.query !== "string" || + !turn.query.trim() || + !turn.answer || + typeof turn.answer !== "object" || + typeof turn.answer.answer !== "string" || + !Array.isArray(turn.sources) ) { return null; } - const priorTurns = Array.isArray(record.priorTurns) - ? record.priorTurns.filter(isStoredAnswerTurn).slice(-maxStoredAnswerTurns) - : []; - const latestTurn = - record.latestTurn && - typeof record.latestTurn === "object" && - typeof record.latestTurn.query === "string" && - Boolean(record.latestTurn.query.trim()) && - record.latestTurn.answer && - typeof record.latestTurn.answer.answer === "string" && - Array.isArray(record.latestTurn.sources) - ? { - query: record.latestTurn.query, - answer: record.latestTurn.answer, - sources: record.latestTurn.sources, - } - : null; - const collapsedTurnIds = Array.isArray(record.collapsedTurnIds) - ? record.collapsedTurnIds.filter((id): id is string => typeof id === "string") - : priorTurns.map((turn) => turn.id); + return { query: turn.query, answer: turn.answer, sources: turn.sources }; +} + +function normalizeTurns(value: unknown) { + return Array.isArray(value) ? value.filter(isStoredAnswerTurn).slice(-(maxStoredAnswerTurns - 1)) : []; +} + +function normalizeCollapsedTurnIds(value: unknown, priorTurns: StoredAnswerTurn[]) { + if (!Array.isArray(value)) return priorTurns.map((turn) => turn.id); + const availableIds = new Set(priorTurns.map((turn) => turn.id)); + return Array.from(new Set(value.filter((id): id is string => typeof id === "string" && availableIds.has(id)))); +} + +function normalizeV2(value: Record): PersistedAnswerThread | null { + const now = Date.now(); + const priorTurns = normalizeTurns(value.priorTurns); + const latestTurn = normalizeLatestTurn(value.latestTurn); if (!priorTurns.length && !latestTurn) return null; + if ( + typeof value.latestSubmissionSignature !== "string" || + !value.latestSubmissionSignature || + typeof value.expiresAt !== "number" || + !Number.isFinite(value.expiresAt) || + value.expiresAt <= now || + value.expiresAt > now + answerThreadTtlMs + ) { + return null; + } + return { + version: 2, + priorTurns, + latestTurn, + collapsedTurnIds: normalizeCollapsedTurnIds(value.collapsedTurnIds, priorTurns), + showEarlierTurns: value.showEarlierTurns === true, + latestSubmissionSignature: value.latestSubmissionSignature, + expiresAt: value.expiresAt, + }; +} + +function migrateV1( + value: LegacyPersistedAnswerThread, + expectedSubmissionSignature: string | undefined, +): PersistedAnswerThread | null { + const priorTurns = normalizeTurns(value.priorTurns); + const latestTurn = normalizeLatestTurn(value.latestTurn); + if (!latestTurn || !expectedSubmissionSignature) return null; + // V1 had no query-mode/scope signature. It is safe to accept only for an + // unscoped answer URL whose exact query matches the latest completed turn. + const unscopedLegacySignature = `answer:${latestTurn.query.trim()}:`; + if (expectedSubmissionSignature !== unscopedLegacySignature) return null; + const now = Date.now(); + const savedAt = typeof value.savedAt === "number" && Number.isFinite(value.savedAt) ? value.savedAt : now; + const expiresAt = savedAt + answerThreadTtlMs; + if (savedAt > now || expiresAt <= now) return null; return { - version: 1, + version: 2, priorTurns, latestTurn, - collapsedTurnIds, + collapsedTurnIds: normalizeCollapsedTurnIds(value.collapsedTurnIds, priorTurns), + showEarlierTurns: false, + latestSubmissionSignature: expectedSubmissionSignature, + expiresAt, }; } @@ -86,13 +158,45 @@ function scopedStorageKey(ownerId: string) { return `${answerThreadStorageKey}:${ownerId}`; } -export function loadPersistedAnswerThread(ownerId: string): PersistedAnswerThread | null { +function removeStoredThread(ownerId: string) { + window.sessionStorage.removeItem(scopedStorageKey(ownerId)); +} + +export function loadPersistedAnswerThread( + ownerId: string, + options: AnswerThreadRestoreOptions = {}, +): PersistedAnswerThread | null { if (typeof window === "undefined" || !ownerId) return null; try { const raw = window.sessionStorage.getItem(scopedStorageKey(ownerId)); if (!raw) return null; - return normalizePersistedAnswerThread(JSON.parse(raw)); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") { + removeStoredThread(ownerId); + return null; + } + const record = parsed as Record; + const thread = + record.version === 2 + ? normalizeV2(record) + : record.version === 1 + ? migrateV1(record as LegacyPersistedAnswerThread, options.expectedSubmissionSignature) + : null; + if ( + !thread || + (options.expectedSubmissionSignature && thread.latestSubmissionSignature !== options.expectedSubmissionSignature) + ) { + removeStoredThread(ownerId); + return null; + } + if (record.version === 1) savePersistedAnswerThread(ownerId, thread); + return thread; } catch { + try { + removeStoredThread(ownerId); + } catch { + // Thread persistence is a convenience only. + } return null; } } @@ -100,15 +204,25 @@ export function loadPersistedAnswerThread(ownerId: string): PersistedAnswerThrea export function savePersistedAnswerThread(ownerId: string, thread: PersistedAnswerThread): boolean { if (typeof window === "undefined" || !ownerId) return false; try { - const payload: PersistedAnswerThreadEnvelope = { - version: 1, - priorTurns: thread.priorTurns.slice(-maxStoredAnswerTurns), + const now = Date.now(); + if (thread.expiresAt <= now || thread.expiresAt > now + answerThreadTtlMs) { + removeStoredThread(ownerId); + return false; + } + const payload: PersistedAnswerThread = { + version: 2, + priorTurns: thread.priorTurns.slice(-(maxStoredAnswerTurns - 1)), latestTurn: thread.latestTurn, collapsedTurnIds: thread.collapsedTurnIds, - savedAt: Date.now(), + showEarlierTurns: thread.showEarlierTurns, + latestSubmissionSignature: thread.latestSubmissionSignature, + expiresAt: thread.expiresAt, }; const serialized = JSON.stringify(payload); - if (serialized.length > maxStorageBytes) return false; + if (new TextEncoder().encode(serialized).byteLength > maxStorageBytes) { + removeStoredThread(ownerId); + return false; + } window.sessionStorage.setItem(scopedStorageKey(ownerId), serialized); return true; } catch { diff --git a/tests/answer-thread-storage.test.ts b/tests/answer-thread-storage.test.ts index ba884ccb9..9951d513a 100644 --- a/tests/answer-thread-storage.test.ts +++ b/tests/answer-thread-storage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RagAnswer } from "@/lib/types"; import { @@ -6,6 +6,8 @@ import { answerThreadTtlMs, clearPersistedAnswerThread, loadPersistedAnswerThread, + maxStoredAnswerTurns, + resolveAnswerThreadOwnerId, savePersistedAnswerThread, type PersistedAnswerThread, } from "@/lib/answer-thread-storage"; @@ -18,23 +20,29 @@ const sampleAnswer = { sources: [], } satisfies RagAnswer; -const sampleThread: PersistedAnswerThread = { - version: 1, - priorTurns: [ - { - id: "answer-turn-1", - query: "lithium dosing", +function createSampleThread(overrides: Partial = {}): PersistedAnswerThread { + return { + version: 2, + priorTurns: [ + { + id: "answer-turn-1", + query: "lithium dosing", + answer: sampleAnswer, + sources: [], + }, + ], + latestTurn: { + query: "what about renal impairment?", answer: sampleAnswer, sources: [], }, - ], - latestTurn: { - query: "what about renal impairment?", - answer: sampleAnswer, - sources: [], - }, - collapsedTurnIds: ["answer-turn-1"], -}; + collapsedTurnIds: ["answer-turn-1"], + showEarlierTurns: false, + latestSubmissionSignature: "answer:what about renal impairment?:", + expiresAt: Date.now() + answerThreadTtlMs, + ...overrides, + }; +} describe("answer thread storage", () => { let storage: Map; @@ -77,20 +85,74 @@ describe("answer thread storage", () => { vi.unstubAllGlobals(); }); - it("round-trips a stored thread", () => { + it("round-trips an exact-match completed thread without extending its expiry", () => { + const sampleThread = createSampleThread({ showEarlierTurns: true }); expect(savePersistedAnswerThread("user-a", sampleThread)).toBe(true); - expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); + expect( + loadPersistedAnswerThread("user-a", { + expectedSubmissionSignature: sampleThread.latestSubmissionSignature, + }), + ).toEqual(sampleThread); expect(loadPersistedAnswerThread("user-b")).toBeNull(); + + const raw = JSON.parse(storage.get(`${answerThreadStorageKey}:user-a`) ?? "{}"); + expect(raw.expiresAt).toBe(sampleThread.expiresAt); + expect(raw).not.toHaveProperty("savedAt"); }); - it("clears stored thread state", () => { + it("scopes snapshots to accounts or the resolved current-tab guest", () => { + expect( + resolveAnswerThreadOwnerId({ + userId: "user-a", + demoMode: false, + demoOwnerId: "demo-owner", + authStatus: "authenticated", + }), + ).toBe("user-a"); + expect( + resolveAnswerThreadOwnerId({ + demoMode: true, + demoOwnerId: "demo-owner", + authStatus: "unconfigured", + }), + ).toBe("demo-owner"); + for (const authStatus of ["unconfigured", "signed_out", "expired", "error"] as const) { + expect(resolveAnswerThreadOwnerId({ demoMode: false, demoOwnerId: "demo-owner", authStatus })).toBe( + "guest-tab-session", + ); + } + expect( + resolveAnswerThreadOwnerId({ demoMode: false, demoOwnerId: "demo-owner", authStatus: "loading" }), + ).toBeNull(); + }); + + it("purges a valid thread when the returned URL signature does not match", () => { + const sampleThread = createSampleThread(); savePersistedAnswerThread("user-a", sampleThread); + + expect( + loadPersistedAnswerThread("user-a", { + expectedSubmissionSignature: "answer:what about renal impairment?:queryMode=compare_guidance", + }), + ).toBeNull(); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); + }); + + it("restores the latest thread on the unsubmitted answer home", () => { + const sampleThread = createSampleThread(); + savePersistedAnswerThread("guest-tab-session", sampleThread); + expect(loadPersistedAnswerThread("guest-tab-session")).toEqual(sampleThread); + }); + + it("clears stored thread state", () => { + savePersistedAnswerThread("user-a", createSampleThread()); clearPersistedAnswerThread(); expect([...storage.keys()].some((key) => key.startsWith(answerThreadStorageKey))).toBe(false); expect(loadPersistedAnswerThread("user-a")).toBeNull(); }); it("clears only the active owner's thread when an owner is provided", () => { + const sampleThread = createSampleThread(); savePersistedAnswerThread("user-a", sampleThread); savePersistedAnswerThread("user-b", sampleThread); storage.set(answerThreadStorageKey, JSON.stringify(sampleThread)); @@ -102,29 +164,137 @@ describe("answer thread storage", () => { expect(loadPersistedAnswerThread("user-b")).toEqual(sampleThread); }); - it("rejects invalid persisted payloads", () => { - storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify({ version: 2 })); + it("purges invalid and corrupt persisted payloads", () => { + storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify({ version: 3 })); expect(loadPersistedAnswerThread("user-a")).toBeNull(); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); + + storage.set(`${answerThreadStorageKey}:user-a`, "{not-json"); + expect(loadPersistedAnswerThread("user-a")).toBeNull(); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); }); - it("expires persisted threads once the TTL has elapsed", () => { - storage.set( - `${answerThreadStorageKey}:user-a`, - JSON.stringify({ ...sampleThread, savedAt: Date.now() - answerThreadTtlMs - 1 }), - ); + it("expires and purges threads once the completed-answer TTL has elapsed", () => { + const expiredThread = createSampleThread({ expiresAt: Date.now() - 1 }); + storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify(expiredThread)); expect(loadPersistedAnswerThread("user-a")).toBeNull(); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); + expect(savePersistedAnswerThread("user-a", expiredThread)).toBe(false); }); - it("keeps fresh threads and stamps savedAt on save", () => { - savePersistedAnswerThread("user-a", sampleThread); - const raw = JSON.parse(storage.get(`${answerThreadStorageKey}:user-a`) ?? "{}"); - expect(typeof raw.savedAt).toBe("number"); - expect(Date.now() - raw.savedAt).toBeLessThan(answerThreadTtlMs); - expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); + it("rejects an expiry beyond the 12-hour privacy boundary", () => { + const overlongThread = createSampleThread({ expiresAt: Date.now() + answerThreadTtlMs + 60_000 }); + storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify(overlongThread)); + expect(loadPersistedAnswerThread("user-a")).toBeNull(); + expect(savePersistedAnswerThread("user-a", overlongThread)).toBe(false); + }); + + it("drops an oversized snapshot instead of leaving an older answer behind", () => { + savePersistedAnswerThread("user-a", createSampleThread()); + const oversizedAnswer = { ...sampleAnswer, answer: "ü".repeat(2_300_000) }; + const oversizedThread = createSampleThread({ + latestTurn: { query: "large answer", answer: oversizedAnswer, sources: [] }, + latestSubmissionSignature: "answer:large answer:", + }); + + expect(savePersistedAnswerThread("user-a", oversizedThread)).toBe(false); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); + }); + + it("migrates a fresh v1 thread only for its exact unscoped answer URL", () => { + const savedAt = Date.now() - 1_000; + const legacyThread = { + version: 1, + priorTurns: createSampleThread().priorTurns, + latestTurn: createSampleThread().latestTurn, + collapsedTurnIds: ["answer-turn-1"], + savedAt, + }; + storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify(legacyThread)); + + const migrated = loadPersistedAnswerThread("user-a", { + expectedSubmissionSignature: "answer:what about renal impairment?:", + }); + expect(migrated).toMatchObject({ + version: 2, + latestSubmissionSignature: "answer:what about renal impairment?:", + expiresAt: savedAt + answerThreadTtlMs, + }); + expect(JSON.parse(storage.get(`${answerThreadStorageKey}:user-a`) ?? "{}").version).toBe(2); + }); + + it("rejects v1 snapshots on answer home, query mismatch, scoped URL, or expiry", () => { + const legacyThread = { + version: 1, + priorTurns: [], + latestTurn: createSampleThread().latestTurn, + collapsedTurnIds: [], + savedAt: Date.now(), + }; + const key = `${answerThreadStorageKey}:user-a`; + + for (const expectedSubmissionSignature of [ + undefined, + "answer:different question?:", + "answer:what about renal impairment?:scope.medications=lithium", + ]) { + storage.set(key, JSON.stringify(legacyThread)); + expect(loadPersistedAnswerThread("user-a", { expectedSubmissionSignature })).toBeNull(); + expect(storage.has(key)).toBe(false); + } + + storage.set(key, JSON.stringify({ ...legacyThread, savedAt: Date.now() - answerThreadTtlMs - 1 })); + expect( + loadPersistedAnswerThread("user-a", { + expectedSubmissionSignature: "answer:what about renal impairment?:", + }), + ).toBeNull(); + + storage.set(key, JSON.stringify({ ...legacyThread, savedAt: Date.now() + 60_000 })); + expect( + loadPersistedAnswerThread("user-a", { + expectedSubmissionSignature: "answer:what about renal impairment?:", + }), + ).toBeNull(); + }); + + it("keeps no more than 12 completed turns including the latest", () => { + const priorTurns = Array.from({ length: 20 }, (_, index) => ({ + id: `answer-turn-${index + 1}`, + query: `question ${index + 1}`, + answer: sampleAnswer, + sources: [], + })); + savePersistedAnswerThread("user-a", createSampleThread({ priorTurns })); + const restored = loadPersistedAnswerThread("user-a"); + expect(restored?.priorTurns).toHaveLength(maxStoredAnswerTurns - 1); + expect(restored?.priorTurns[0]?.id).toBe("answer-turn-10"); }); - it("accepts legacy payloads without a savedAt stamp", () => { - storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify(sampleThread)); - expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); + it("keeps blocked storage non-blocking", () => { + vi.stubGlobal("window", { + localStorage: { + removeItem() { + throw new Error("blocked"); + }, + }, + sessionStorage: { + get length() { + throw new Error("blocked"); + }, + getItem() { + throw new Error("blocked"); + }, + setItem() { + throw new Error("blocked"); + }, + removeItem() { + throw new Error("blocked"); + }, + }, + }); + expect(loadPersistedAnswerThread("user-a")).toBeNull(); + expect(savePersistedAnswerThread("user-a", createSampleThread())).toBe(false); + expect(() => clearPersistedAnswerThread()).not.toThrow(); }); }); diff --git a/tests/contextual-back-navigation-contract.test.ts b/tests/contextual-back-navigation-contract.test.ts new file mode 100644 index 000000000..ab5c6ecd8 --- /dev/null +++ b/tests/contextual-back-navigation-contract.test.ts @@ -0,0 +1,48 @@ +import { globSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const sourceRoot = resolve(process.cwd(), "src"); +const internalArrowControls = new Set([ + // Multi-step calculator flow, not browser-route navigation. + "components/calculators/guided-flow.tsx", + // Returns from a calculator record to the calculator search panel in-place. + "components/calculators/search-detail.tsx", + // Returns from a settings subpanel to the settings root in the same dialog. + "components/clinical-dashboard/settings-dialog.tsx", + // Previous step in the formulation builder workflow. + "components/formulation/formulation-builder-page.tsx", +]); + +function productionArrowFiles() { + return globSync("{app,components}/**/*.tsx", { cwd: sourceRoot }) + .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.toLowerCase().includes("mockup")) + .filter((file) => { + const source = readFileSync(resolve(sourceRoot, file), "utf8"); + return ( + /)/.test(source) || + /)/.test(source) || + /icon=\{ArrowLeft\}/.test(source) || + /icon:\s*ArrowLeft\b/.test(source) + ); + }); +} + +describe("page-level back-arrow contract", () => { + it("routes every production page-level left arrow through contextual browser history", () => { + const violations = productionArrowFiles().filter((file) => { + if (internalArrowControls.has(file)) return false; + const source = readFileSync(resolve(sourceRoot, file), "utf8"); + return !/ContextualBackLink|navigateContextuallyBack|behavior:\s*["']history-back["']/.test(source); + }); + + expect(violations).toEqual([]); + }); + + it("keeps the internal-control exclusion list exact and reviewable", () => { + const arrowFiles = new Set(productionArrowFiles()); + expect([...internalArrowControls].filter((file) => !arrowFiles.has(file))).toEqual([]); + }); +}); diff --git a/tests/factsheet-detail-header.dom.test.tsx b/tests/factsheet-detail-header.dom.test.tsx index fd7724cf7..620aae9e4 100644 --- a/tests/factsheet-detail-header.dom.test.tsx +++ b/tests/factsheet-detail-header.dom.test.tsx @@ -1,10 +1,15 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { FactsheetDetailPage } from "@/components/factsheets/factsheet-detail-page"; import { findFactsheet } from "@/components/factsheets/factsheets-data"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/factsheets/sertraline", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + function renderFactsheet(slug: string) { const factsheet = findFactsheet(slug); if (!factsheet) throw new Error(`Expected the ${slug} factsheet fixture`); diff --git a/tests/factsheet-save.dom.test.tsx b/tests/factsheet-save.dom.test.tsx index 13979cb49..3d4d95e1f 100644 --- a/tests/factsheet-save.dom.test.tsx +++ b/tests/factsheet-save.dom.test.tsx @@ -2,6 +2,11 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/factsheets/sertraline", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + import { FactsheetDetailPage } from "@/components/factsheets/factsheet-detail-page"; import { findFactsheet } from "@/components/factsheets/factsheets-data"; import { savedFactsheetsStorageKey } from "@/lib/saved-registry-storage"; diff --git a/tests/form-confirm-callout.dom.test.tsx b/tests/form-confirm-callout.dom.test.tsx index 6cd414da4..f5ee79bde 100644 --- a/tests/form-confirm-callout.dom.test.tsx +++ b/tests/form-confirm-callout.dom.test.tsx @@ -1,6 +1,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/forms/test-form", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + import { FormDetailPage } from "@/components/forms/form-detail-page"; import { getFormRecord } from "@/lib/forms"; diff --git a/tests/form-priority-facts.dom.test.tsx b/tests/form-priority-facts.dom.test.tsx index bcd6144d6..e9384c4ea 100644 --- a/tests/form-priority-facts.dom.test.tsx +++ b/tests/form-priority-facts.dom.test.tsx @@ -2,6 +2,11 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/forms/test-form", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + import { FormDetailPage } from "@/components/forms/form-detail-page"; import { getFormRecord } from "@/lib/forms"; diff --git a/tests/forms-back-navigation.dom.test.tsx b/tests/forms-back-navigation.dom.test.tsx index 136cfbc64..7e9d7eefd 100644 --- a/tests/forms-back-navigation.dom.test.tsx +++ b/tests/forms-back-navigation.dom.test.tsx @@ -4,6 +4,11 @@ import { describe, expect, it, vi } from "vitest"; import { FormDetailPage } from "@/components/forms/form-detail-page"; import { formRecords } from "@/lib/forms"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/forms/transport-crisis-form", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + vi.mock("@/components/account-data-provider", () => ({ useAccountData: () => ({ isSaved: () => false, diff --git a/tests/forms-information-disclosure.dom.test.tsx b/tests/forms-information-disclosure.dom.test.tsx index a0cb9473e..1deeed057 100644 --- a/tests/forms-information-disclosure.dom.test.tsx +++ b/tests/forms-information-disclosure.dom.test.tsx @@ -2,6 +2,11 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/forms/test-form", + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + import { FormDetailPage } from "@/components/forms/form-detail-page"; import { getFormRecord } from "@/lib/forms"; diff --git a/tests/in-page-nav-header.dom.test.tsx b/tests/in-page-nav-header.dom.test.tsx index 3d0763106..43120a282 100644 --- a/tests/in-page-nav-header.dom.test.tsx +++ b/tests/in-page-nav-header.dom.test.tsx @@ -10,7 +10,10 @@ import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer"; /** Reassigned per case so a rerender can simulate a route change. */ let pathname: string | null = null; -vi.mock("next/navigation", () => ({ usePathname: () => pathname })); +vi.mock("next/navigation", () => ({ + usePathname: () => pathname, + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); // Module state, so a case that navigates would otherwise hand its final // pathname to whichever case runs next. diff --git a/tests/information-page-shell.dom.test.tsx b/tests/information-page-shell.dom.test.tsx index c3c029b24..f947c55c4 100644 --- a/tests/information-page-shell.dom.test.tsx +++ b/tests/information-page-shell.dom.test.tsx @@ -1,9 +1,13 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell"; import { isInformationPage } from "@/lib/information-pages"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + describe("isInformationPage", () => { it("recognises catalogue detail routes per mode", () => { expect(isInformationPage("/services/acuity")).toBe(true); diff --git a/tests/medication-record-page.dom.test.tsx b/tests/medication-record-page.dom.test.tsx index 1e19bf91d..7138cc114 100644 --- a/tests/medication-record-page.dom.test.tsx +++ b/tests/medication-record-page.dom.test.tsx @@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { MedicationRecordPage } from "@/components/clinical-dashboard/medication-record-page"; import type { MedicationRecord } from "@/lib/medications"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), +})); + // Controllable data-hook mock so each test drives one content-first state. const { useMedicationDetail } = vi.hoisted(() => ({ useMedicationDetail: vi.fn() })); vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ useMedicationDetail })); diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx index 59cfd1c8a..e1a371d29 100644 --- a/tests/navigation-back-button.dom.test.tsx +++ b/tests/navigation-back-button.dom.test.tsx @@ -1,14 +1,16 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import ColourCodingReferencePage from "@/app/reference/colour-coding/page"; +import { ContextualBackLink } from "@/components/contextual-back-link"; import { NavigationBackButton } from "@/components/navigation-back-button"; import { PrivacyPageBackButton } from "@/components/privacy-page-back-button"; -import ColourCodingReferencePage from "@/app/reference/colour-coding/page"; import { appModeHomeHref } from "@/lib/app-modes"; const router = vi.hoisted(() => ({ back: vi.fn(), - push: vi.fn(), + replace: vi.fn(), })); const currentSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() })); @@ -17,61 +19,125 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => currentSearchParams.value, })); +function setCanGoBack(value: boolean | undefined) { + Object.defineProperty(window, "navigation", { + configurable: true, + value: value === undefined ? undefined : { canGoBack: value }, + }); +} + beforeEach(() => { router.back.mockReset(); - router.push.mockReset(); + router.replace.mockReset(); currentSearchParams.value = new URLSearchParams(); + setCanGoBack(false); }); -describe("NavigationBackButton", () => { - it("uses the explicit in-app fallback even when browser history has prior entries", () => { - window.history.pushState({}, "", "/unrelated-route"); - window.history.pushState({}, "", "/privacy"); - expect(window.history.length).toBeGreaterThan(1); - +describe("contextual back navigation", () => { + it("returns to the immediately preceding browser-history entry", async () => { + const user = userEvent.setup(); + setCanGoBack(true); render(); - fireEvent.click(screen.getByRole("button", { name: "Go back" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith("/"); + await user.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.back).toHaveBeenCalledOnce(); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("uses replace for a direct-entry fallback so it cannot create a back loop", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.replace).toHaveBeenCalledOnce(); + expect(router.replace).toHaveBeenCalledWith("/forms"); expect(router.back).not.toHaveBeenCalled(); }); - it("cancels navigation when onBeforeNavigate returns false", () => { - const onBeforeNavigate = vi.fn(() => false); + it("falls back to history.length when the Navigation API is unavailable", async () => { + const user = userEvent.setup(); + setCanGoBack(undefined); + window.history.pushState({}, "", "/answer"); + window.history.pushState({}, "", "/medications/lithium"); + render(); + + await user.click(screen.getByRole("button", { name: "Go back" })); + expect(router.back).toHaveBeenCalledOnce(); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("cancels button navigation when onBeforeNavigate returns false", async () => { + const user = userEvent.setup(); + const onBeforeNavigate = vi.fn(() => false); + setCanGoBack(true); render(); - fireEvent.click(screen.getByRole("button", { name: "Go back" })); + await user.click(screen.getByRole("button", { name: "Go back" })); + + expect(onBeforeNavigate).toHaveBeenCalledOnce(); + expect(router.back).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("supports keyboard activation on the fallback link", async () => { + const user = userEvent.setup(); + setCanGoBack(true); + render(Back to services); + + await user.tab(); + await user.keyboard("{Enter}"); + + expect(router.back).toHaveBeenCalledOnce(); + }); + + it("preserves fallback-link semantics for modified clicks", () => { + render(Back to services); + const link = screen.getByRole("link", { name: "Back to services" }); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true, button: 0 }); + window.addEventListener("click", (clickEvent) => clickEvent.preventDefault(), { once: true }); + + link.dispatchEvent(event); + expect(link).toHaveAttribute("href", "/services"); + expect(router.back).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("runs the dirty-state gate before a modified-click fallback", () => { + const onBeforeNavigate = vi.fn(() => false); + render( + + Back to services + , + ); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true, button: 0 }); + + expect(screen.getByRole("link").dispatchEvent(event)).toBe(false); expect(onBeforeNavigate).toHaveBeenCalledOnce(); - expect(router.push).not.toHaveBeenCalled(); }); - it("returns the colour-coding reference to the canonical Tools home", () => { + it("returns the colour-coding reference to the canonical Tools home on direct entry", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Go back" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + expect(router.replace).toHaveBeenCalledWith(appModeHomeHref("tools")); }); - it("returns privacy readers to their allowlisted source mode", () => { + it("returns privacy readers to their allowlisted source mode on direct entry", () => { currentSearchParams.value = new URLSearchParams("from=documents"); - render(); fireEvent.click(screen.getByRole("button", { name: "Go back" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith(appModeHomeHref("documents")); + expect(router.replace).toHaveBeenCalledWith(appModeHomeHref("documents")); }); it("fails closed to the default home for an invalid privacy source", () => { currentSearchParams.value = new URLSearchParams("from=https://example.com"); - render(); fireEvent.click(screen.getByRole("button", { name: "Go back" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith("/"); + expect(router.replace).toHaveBeenCalledWith("/"); }); }); diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index be021e784..151697782 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -6,7 +6,7 @@ import { appModeHomeHref } from "@/lib/app-modes"; const router = vi.hoisted(() => ({ back: vi.fn(), - push: vi.fn(), + replace: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -19,7 +19,11 @@ describe("PatientSafetyPlan privacy contract", () => { beforeEach(() => { vi.clearAllMocks(); router.back.mockReset(); - router.push.mockReset(); + router.replace.mockReset(); + Object.defineProperty(window, "navigation", { + configurable: true, + value: { canGoBack: false }, + }); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, @@ -78,13 +82,13 @@ describe("PatientSafetyPlan privacy contract", () => { expect(confirmSpy).toHaveBeenCalledOnce(); expect(confirmSpy).toHaveBeenCalledWith(expect.stringMatching(/Leave this safety plan\?.*will be lost/i)); - expect(router.push).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); confirmSpy.mockReturnValue(true); fireEvent.click(screen.getByRole("button", { name: "Go back" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + expect(router.replace).toHaveBeenCalledOnce(); + expect(router.replace).toHaveBeenCalledWith(appModeHomeHref("tools")); }); it("confirms before leaving unadded safety-plan step text", () => { @@ -97,7 +101,7 @@ describe("PatientSafetyPlan privacy contract", () => { fireEvent.click(screen.getByRole("button", { name: "Go back" })); expect(confirmSpy).toHaveBeenCalledOnce(); - expect(router.push).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); }); it("confirms before leaving unadded contact detail text", () => { @@ -109,7 +113,7 @@ describe("PatientSafetyPlan privacy contract", () => { fireEvent.click(screen.getByRole("button", { name: "Go back" })); expect(confirmSpy).toHaveBeenCalledOnce(); - expect(router.push).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); }); it("keeps unadded draft text dirty after loading and clearing the example", () => { @@ -124,7 +128,7 @@ describe("PatientSafetyPlan privacy contract", () => { fireEvent.click(screen.getByRole("button", { name: "Go back" })); expect(confirmSpy).toHaveBeenCalledOnce(); - expect(router.push).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); }); it("navigates back without confirmation when the safety plan is empty", () => { @@ -134,7 +138,7 @@ describe("PatientSafetyPlan privacy contract", () => { fireEvent.click(screen.getByRole("button", { name: "Go back" })); expect(confirmSpy).not.toHaveBeenCalled(); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + expect(router.replace).toHaveBeenCalledOnce(); + expect(router.replace).toHaveBeenCalledWith(appModeHomeHref("tools")); }); }); diff --git a/tests/privacy-ui.test.ts b/tests/privacy-ui.test.ts index 364d3c26c..56731f412 100644 --- a/tests/privacy-ui.test.ts +++ b/tests/privacy-ui.test.ts @@ -9,7 +9,7 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), useRouter: () => ({ back: vi.fn(), - push: vi.fn(), + replace: vi.fn(), }), })); @@ -49,6 +49,9 @@ describe("privacy UI", () => { expect(markup).toContain("Safety-plan working content has no Clinical KB retention"); expect(markup).toContain("Clipboard, print, and PDF copies are outside the app"); expect(markup).toContain("Generated answer text is also omitted from durable query logs by default"); + expect(markup).toContain("completed answer threads may also remain in this browser tab for up to 12 hours"); + expect(markup).toContain("is never available offline"); + expect(markup).toContain("cleared by New chat, sign-out, or an account change"); expect(markup).toContain("application service in Singapore"); expect(markup).toContain("Railway in Singapore"); expect(markup).toContain("retrieval embedding"); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2297d8085..477b851f8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2607,7 +2607,8 @@ test.describe("Clinical KB UI smoke coverage", () => { test("answer results surface cross-mode quick links", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await mockDemoApi(page); + const answerRequests: string[] = []; + await mockDemoApi(page, { onAnswerRequest: (query) => answerRequests.push(query) }); const question = "What is the maximum dose of clozapine?"; await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`, { waitUntil: "domcontentloaded", @@ -2643,6 +2644,25 @@ test.describe("Clinical KB UI smoke coverage", () => { await waitForReactEventHandler(medicationLink, "onClick"); await medicationLink.click(); await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 45_000 }); + const medicationPage = page.getByTestId("medication-page-clozapine"); + await expect(medicationPage).toBeVisible(); + await medicationPage + .getByRole("navigation", { name: "Breadcrumb" }) + .getByRole("link", { name: "Medications", exact: true }) + .click(); + await expect(page).toHaveURL( + (url) => + url.pathname === "/" && + url.searchParams.get("mode") === "answer" && + url.searchParams.get("q") === question && + url.searchParams.get("run") === "1", + { timeout: 45_000 }, + ); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + expect(answerRequests).toEqual([question]); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + expect(answerRequests).toEqual([question]); await expectNoPageHorizontalOverflow(page); }); @@ -3152,6 +3172,49 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(visibleByTestId(page, "differentials-search-results")).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await expect(page.getByTestId("differentials-home")).toHaveCount(0); + + const origin = new URL(page.url()); + await visibleByTestId(page, "differentials-search-results") + .getByRole("link", { name: "Open page" }) + .first() + .click(); + await expect(page).toHaveURL(/\/differentials\/(diagnoses|presentations)\//, { timeout: 30_000 }); + await page + .getByRole("link", { name: /^Back(?: to (?:diagnoses|differentials))?$/i }) + .filter({ visible: true }) + .first() + .click(); + await expect(page).toHaveURL( + (url) => + url.pathname === origin.pathname && + url.searchParams.get("q") === origin.searchParams.get("q") && + url.searchParams.get("run") === origin.searchParams.get("run"), + { timeout: 30_000 }, + ); + await expect(visibleByTestId(page, "differentials-search-results")).toBeVisible({ timeout: 30_000 }); + }); + + test("document detail back arrow restores its originating search", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + await gotoApp(page, "/documents/search?mode=documents&q=lithium+monitoring&run=1"); + + const workspace = page.getByTestId("document-search-workspace"); + const firstResult = workspace.getByTestId("document-result-card").first(); + await expect(firstResult).toBeVisible({ timeout: 30_000 }); + const origin = new URL(page.url()); + await firstResult.getByRole("link", { name: /^Open / }).click(); + await expect(page).toHaveURL(/\/documents\/[0-9a-f-]+\?/, { timeout: 30_000 }); + + await page.getByRole("link", { name: "Back to documents" }).click(); + await expect(page).toHaveURL( + (url) => + url.pathname === origin.pathname && + url.searchParams.get("q") === origin.searchParams.get("q") && + url.searchParams.get("run") === origin.searchParams.get("run"), + { timeout: 30_000 }, + ); + await expect(workspace).toBeVisible({ timeout: 30_000 }); }); test("newer routed differential context wins over an older response", async ({ page }) => { @@ -3436,13 +3499,21 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(actionOverflow.overflows).toBe(false); expect(actionOverflow.textOverflow).not.toBe("ellipsis"); + const origin = new URL(page.url()); await acamprosateCard.click(); await expect(page).toHaveURL(/\/medications\/acamprosate$/, { timeout: 30_000 }); const backLink = page.getByRole("link", { name: "Medications", exact: true }); await expect(backLink).toBeVisible(); await expectMinTouchTarget(backLink); await backLink.click(); - await expect(page).toHaveURL(/\/medications$/); + await expect(page).toHaveURL( + (url) => + url.pathname === origin.pathname && + url.searchParams.get("mode") === origin.searchParams.get("mode") && + url.searchParams.get("q") === origin.searchParams.get("q") && + url.searchParams.get("run") === origin.searchParams.get("run"), + ); + await expect(page.getByTestId("medication-result-acamprosate-phone")).toBeVisible(); }); test("tablet document chrome keeps one new-chat action and readable Sources rows", async ({ page }) => { From b04ff7683451749ced7b083cb7bb2a7b4492250b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 10:16:42 +0000 Subject: [PATCH 2/6] fix: harden answer-thread restore and post-merge nav mocks Keep signature-mismatched snapshots for forward restore, store only metadata on restore, preserve guest threads across initial signed-out auth, correct privacy copy and tap targets, and mock usePathname after the main in-page-nav merge. --- docs/branch-review-ledger.md | 1 + src/app/privacy/page.tsx | 4 ++-- src/components/ClinicalDashboard.tsx | 5 ++++- .../clinical-dashboard/use-persisted-answer-thread.ts | 3 ++- .../differential-presentation-workflow-page.tsx | 4 ++-- .../therapy-compass/screens/detail-screen.tsx | 2 +- src/lib/answer-thread-storage.ts | 11 ++++++++--- src/lib/supabase/client.tsx | 5 ++++- tests/answer-thread-storage.test.ts | 5 +++-- tests/medication-record-page.dom.test.tsx | 1 + tests/navigation-back-button.dom.test.tsx | 11 ++++++++++- tests/privacy-ui.test.ts | 2 +- 12 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 171e63bcd..ca773d240 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -855,3 +855,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | PR #1782 / cursor/fix-document-open-scroll-e5bf | 5709f2cc7a954197e02107c96d7896d8d13445c3 | document-viewer open-at-top | ship: remove chunk mount scrollIntoView so document opens stay at overview top | document-viewer-shell.dom 7 pass; document-section-summary.dom 8 pass; verify:pr-local dry-run | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 98029875db7d640d3e699829249bb33892296bff | PR #1782 unblock | before: static-pr+coverage failed on stale adoption-manifest (document-viewer-shell testFiles drift), merge-tree clean 0 behind, auto-merge armed, 1 advisory CodeRabbit waitFor thread; after: regenerated adoption-manifest, hardened scroll negative assertion, pre-commit+handoff adoption sync to prevent recurrence; CodeRabbit dispositioned as fixed by sync assert | check:design-system-adoption PASS; vitest design-system-adoption+document-viewer-shell+docs-inventory 63/63 PASS; format; no provider-backed checks | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 86698228533ebe10452c10c1bd7a3e1610d891ae | PR #1782 unblock | merged origin/main (behind-but-clean); fixed static-pr TS2322 on document-viewer-shell chunk fixture; fixed Production UI DSM compare remove stall via location.assign + DOM proof; prior adoption-manifest drift already fixed | tsc clean for changed files; vitest document-viewer-shell+dsm-compare-remove+design-system-adoption 59/59 PASS; check:design-system-adoption PASS; format; no provider-backed checks | +| 2026-08-10 | PR #1788 | cc64d8356ca964ef2e550f4493f17778965ada79 | Run PR sweep | merged origin/main (resolved in-page-nav ContextualBackLink+rail); fixed restore metadata/signature/guest clear, privacy copy, tap targets, medication pathname mock; threads dispositioned; tip follows | merge-tree clean; vitest answer-thread/privacy/nav/medication/in-page-nav pass; CI pending after push | diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 235fe1236..31c5c864e 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -44,8 +44,8 @@ const SECTIONS: Section[] = [ Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text is also omitted from durable query logs by default. A short-lived response cache can contain the answer while its read TTL is valid. To make recent answers reappear quickly, completed answer threads may also remain in this - browser tab for up to 12 hours. That tab-only copy is not shared across tabs or devices and is never available - offline. + browser tab for up to 12 hours. That tab-only copy stays in this tab, is not shared across tabs or devices, and + is never sent to the application service. ), }, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 82e0c1f96..15fb12e97 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -700,7 +700,10 @@ export function ClinicalDashboard({ setPriorAnswerTurns(persisted.priorTurns); setLatestAnswerQuery(persisted.latestTurn?.query ?? null); setShowEarlierTurns(persisted.showEarlierTurns); - latestAnswerSnapshotMetadataRef.current = persisted; + latestAnswerSnapshotMetadataRef.current = { + latestSubmissionSignature: persisted.latestSubmissionSignature, + expiresAt: persisted.expiresAt, + }; if (persisted.latestTurn) { latestAnswerTurnRef.current = persisted.latestTurn; setAnswer(persisted.latestTurn.answer); diff --git a/src/components/clinical-dashboard/use-persisted-answer-thread.ts b/src/components/clinical-dashboard/use-persisted-answer-thread.ts index f9ba4c5fe..c37073543 100644 --- a/src/components/clinical-dashboard/use-persisted-answer-thread.ts +++ b/src/components/clinical-dashboard/use-persisted-answer-thread.ts @@ -54,7 +54,8 @@ export function usePersistedAnswerThread({ latestTurn, collapsedTurnIds: [...collapsedTurnIds], showEarlierTurns, - ...metadata, + latestSubmissionSignature: metadata.latestSubmissionSignature, + expiresAt: metadata.expiresAt, }); }, [answer, collapsedTurnIds, enabled, latestTurn, metadata, ownerId, priorTurns, showEarlierTurns]); } diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index 6de1ddbce..4c175d7e2 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -744,7 +744,7 @@ export function DifferentialPresentationWorkflowPage({
Back @@ -756,7 +756,7 @@ export function DifferentialPresentationWorkflowPage({
Back to differentials diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index d18c0e0ee..805bf74c6 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -37,7 +37,7 @@ export function DetailScreen() {
Back to results diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index 4ce908755..5d8a9e366 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -182,11 +182,16 @@ export function loadPersistedAnswerThread( : record.version === 1 ? migrateV1(record as LegacyPersistedAnswerThread, options.expectedSubmissionSignature) : null; + if (!thread) { + removeStoredThread(ownerId); + return null; + } + // A URL/signature mismatch means this navigation does not own the stored + // thread — leave it intact for a later forward restore or home restore. if ( - !thread || - (options.expectedSubmissionSignature && thread.latestSubmissionSignature !== options.expectedSubmissionSignature) + options.expectedSubmissionSignature && + thread.latestSubmissionSignature !== options.expectedSubmissionSignature ) { - removeStoredThread(ownerId); return null; } if (record.version === 1) savePersistedAnswerThread(ownerId, thread); diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index b5d646786..7da3ce705 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -220,7 +220,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setError(null); setNotice(null); } else { - clearPersistedAnswerThread(); + // Initial signed-out must not wipe the guest answer-thread snapshot: + // auth boots as `loading`, then resolves here before the dashboard + // adopts `guest-tab-session` and restores. Account transitions still + // clear via sign-out / user-id change / expiry handlers below. clearRecentQueries(); clearSignedUrlCache(); if (callbackError) { diff --git a/tests/answer-thread-storage.test.ts b/tests/answer-thread-storage.test.ts index 9951d513a..d799e8d63 100644 --- a/tests/answer-thread-storage.test.ts +++ b/tests/answer-thread-storage.test.ts @@ -126,7 +126,7 @@ describe("answer thread storage", () => { ).toBeNull(); }); - it("purges a valid thread when the returned URL signature does not match", () => { + it("leaves a valid thread in place when the returned URL signature does not match", () => { const sampleThread = createSampleThread(); savePersistedAnswerThread("user-a", sampleThread); @@ -135,7 +135,8 @@ describe("answer thread storage", () => { expectedSubmissionSignature: "answer:what about renal impairment?:queryMode=compare_guidance", }), ).toBeNull(); - expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(false); + expect(storage.has(`${answerThreadStorageKey}:user-a`)).toBe(true); + expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); }); it("restores the latest thread on the unsubmitted answer home", () => { diff --git a/tests/medication-record-page.dom.test.tsx b/tests/medication-record-page.dom.test.tsx index 06fc01fef..27f7f2ab7 100644 --- a/tests/medication-record-page.dom.test.tsx +++ b/tests/medication-record-page.dom.test.tsx @@ -6,6 +6,7 @@ import { MedicationRecordPage } from "@/components/clinical-dashboard/medication import type { MedicationRecord } from "@/lib/medications"; vi.mock("next/navigation", () => ({ + usePathname: () => "/medications/test-med", useRouter: () => ({ back: vi.fn(), replace: vi.fn() }), })); diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx index e1a371d29..7a5c0fb96 100644 --- a/tests/navigation-back-button.dom.test.tsx +++ b/tests/navigation-back-button.dom.test.tsx @@ -97,9 +97,18 @@ describe("contextual back navigation", () => { render(Back to services); const link = screen.getByRole("link", { name: "Back to services" }); const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true, button: 0 }); - window.addEventListener("click", (clickEvent) => clickEvent.preventDefault(), { once: true }); + let preventedByComponent: boolean | undefined; + window.addEventListener( + "click", + (clickEvent) => { + preventedByComponent = clickEvent.defaultPrevented; + clickEvent.preventDefault(); + }, + { once: true }, + ); link.dispatchEvent(event); + expect(preventedByComponent).toBe(false); expect(link).toHaveAttribute("href", "/services"); expect(router.back).not.toHaveBeenCalled(); expect(router.replace).not.toHaveBeenCalled(); diff --git a/tests/privacy-ui.test.ts b/tests/privacy-ui.test.ts index 56731f412..e1e498ad3 100644 --- a/tests/privacy-ui.test.ts +++ b/tests/privacy-ui.test.ts @@ -50,7 +50,7 @@ describe("privacy UI", () => { expect(markup).toContain("Clipboard, print, and PDF copies are outside the app"); expect(markup).toContain("Generated answer text is also omitted from durable query logs by default"); expect(markup).toContain("completed answer threads may also remain in this browser tab for up to 12 hours"); - expect(markup).toContain("is never available offline"); + expect(markup).toContain("is never sent to the application service"); expect(markup).toContain("cleared by New chat, sign-out, or an account change"); expect(markup).toContain("application service in Singapore"); expect(markup).toContain("Railway in Singapore"); From 365cd2596c63f23e07c691c89be254b0eb89a42e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 10:25:13 +0000 Subject: [PATCH 3/6] fix: extract answer-thread bootstrap under maintainability budget ClinicalDashboard exceeded the 4140-line no-growth gate after the contextual-back/answer-cache work. Move owner/bootstrap restore into useAnswerThreadBootstrap and merge latest main. --- docs/branch-review-ledger.md | 1 + src/components/ClinicalDashboard.tsx | 92 +++------- .../use-answer-thread-bootstrap.ts | 168 ++++++++++++++++++ tests/use-answer-thread-bootstrap.test.ts | 52 ++++++ 4 files changed, 248 insertions(+), 65 deletions(-) create mode 100644 src/components/clinical-dashboard/use-answer-thread-bootstrap.ts create mode 100644 tests/use-answer-thread-bootstrap.test.ts diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ca773d240..9b1780f56 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -856,3 +856,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 98029875db7d640d3e699829249bb33892296bff | PR #1782 unblock | before: static-pr+coverage failed on stale adoption-manifest (document-viewer-shell testFiles drift), merge-tree clean 0 behind, auto-merge armed, 1 advisory CodeRabbit waitFor thread; after: regenerated adoption-manifest, hardened scroll negative assertion, pre-commit+handoff adoption sync to prevent recurrence; CodeRabbit dispositioned as fixed by sync assert | check:design-system-adoption PASS; vitest design-system-adoption+document-viewer-shell+docs-inventory 63/63 PASS; format; no provider-backed checks | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 86698228533ebe10452c10c1bd7a3e1610d891ae | PR #1782 unblock | merged origin/main (behind-but-clean); fixed static-pr TS2322 on document-viewer-shell chunk fixture; fixed Production UI DSM compare remove stall via location.assign + DOM proof; prior adoption-manifest drift already fixed | tsc clean for changed files; vitest document-viewer-shell+dsm-compare-remove+design-system-adoption 59/59 PASS; check:design-system-adoption PASS; format; no provider-backed checks | | 2026-08-10 | PR #1788 | cc64d8356ca964ef2e550f4493f17778965ada79 | Run PR sweep | merged origin/main (resolved in-page-nav ContextualBackLink+rail); fixed restore metadata/signature/guest clear, privacy copy, tap targets, medication pathname mock; threads dispositioned; tip follows | merge-tree clean; vitest answer-thread/privacy/nav/medication/in-page-nav pass; CI pending after push | +| 2026-08-10 | PR #1788 | aaeb54630fde2c05efe9a90eda13fbb92cc933c0 | Run PR sweep | Static PR maintainability: extracted useAnswerThreadBootstrap (ClinicalDashboard 4144→4106); merged origin/main (#1794/#1795) | check:maintainability-budgets pass; vitest bootstrap+storage 17; tsc clean; CI pending after push | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 15fb12e97..4b1e30536 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -213,9 +213,9 @@ import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/ import { clearPersistedAnswerThread, createAnswerThreadSnapshotMetadata, - loadPersistedAnswerThread, maxStoredAnswerTurns, } from "@/lib/answer-thread-storage"; +import { useAnswerThreadBootstrap } from "@/components/clinical-dashboard/use-answer-thread-bootstrap"; import { resolveDashboardAnswerThreadOwnerId, usePersistedAnswerThread, @@ -664,70 +664,32 @@ export function ClinicalDashboard({ setSettingsOpen: settingsState.setSettingsOpen, }); const answerThreadOwnerId = resolveDashboardAnswerThreadOwnerId(auth.session?.user.id, clientDemoMode, authStatus); - const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); - useEffect(() => { - const previousOwnerId = previousAnswerThreadOwnerIdRef.current; - previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId; - activeAnswerThreadOwnerIdRef.current = answerThreadOwnerId; - if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return; - clearPersistedAnswerThread(previousOwnerId); - if (answerThreadOwnerId) clearPersistedAnswerThread(answerThreadOwnerId); - answerThreadBootstrappedRef.current = false; - queueMicrotask(() => { - setPriorAnswerTurns([]); - setLatestAnswerQuery(null); - setCollapsedTurnIds(new Set()); - setAnswer(null); - setSources([]); - latestAnswerTurnRef.current = null; - latestAnswerSnapshotMetadataRef.current = null; - setAnswerThreadBootstrapped(false); - }); - }, [answerThreadOwnerId]); - useEffect(() => { - if (authStatus === "loading" || answerThreadBootstrappedRef.current) return; - queueMicrotask(() => { - const expectedSubmissionSignature = - searchMode === "answer" && submittedUrlQuery - ? searchSubmissionSignature(searchMode, submittedUrlQuery, routedSearchContext) - : undefined; - const persisted = - answerThreadOwnerId && searchMode === "answer" - ? loadPersistedAnswerThread(answerThreadOwnerId, { expectedSubmissionSignature }) - : null; - if (persisted) { - restoredThreadFromStorageRef.current = true; - setPriorAnswerTurns(persisted.priorTurns); - setLatestAnswerQuery(persisted.latestTurn?.query ?? null); - setShowEarlierTurns(persisted.showEarlierTurns); - latestAnswerSnapshotMetadataRef.current = { - latestSubmissionSignature: persisted.latestSubmissionSignature, - expiresAt: persisted.expiresAt, - }; - if (persisted.latestTurn) { - latestAnswerTurnRef.current = persisted.latestTurn; - setAnswer(persisted.latestTurn.answer); - setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); - setQuery(""); - autoRunSearchSignatureRef.current = persisted.latestSubmissionSignature; - } - answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { - const match = /^answer-turn-(\d+)$/.exec(turn.id); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); - setCollapsedTurnIds( - persisted.collapsedTurnIds.length - ? new Set(persisted.collapsedTurnIds) - : new Set(persisted.priorTurns.map((turn) => turn.id)), - ); - } else if (!answerThreadOwnerId) { - clearPersistedAnswerThread(); - } - answerThreadBootstrappedRef.current = true; - setAnswerThreadBootstrapped(true); - }); - }, [answerThreadOwnerId, authStatus, routedSearchContext, searchMode, submittedUrlQuery]); + useAnswerThreadBootstrap({ + answerThreadOwnerId, + authStatus, + searchMode, + submittedUrlQuery, + expectedSubmissionSignature: + searchMode === "answer" && submittedUrlQuery + ? searchSubmissionSignature(searchMode, submittedUrlQuery, routedSearchContext) + : undefined, + activeAnswerThreadOwnerIdRef, + answerThreadBootstrappedRef, + restoredThreadFromStorageRef, + latestAnswerTurnRef, + latestAnswerSnapshotMetadataRef, + answerTurnSeqRef, + autoRunSearchSignatureRef, + setPriorAnswerTurns, + setLatestAnswerQuery, + setCollapsedTurnIds, + setShowEarlierTurns, + setAnswer, + setSources, + setModeSearchSubmitted, + setQuery, + setAnswerThreadBootstrapped, + }); // Local no-auth can still exercise public-read APIs, but administration is always // derived separately from the immutable account role claim. const uploadReadOnlyMode = resolveUploadReadOnlyMode({ diff --git a/src/components/clinical-dashboard/use-answer-thread-bootstrap.ts b/src/components/clinical-dashboard/use-answer-thread-bootstrap.ts new file mode 100644 index 000000000..3e97b996a --- /dev/null +++ b/src/components/clinical-dashboard/use-answer-thread-bootstrap.ts @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; + +import { + clearPersistedAnswerThread, + loadPersistedAnswerThread, + type PersistedAnswerThread, + type StoredAnswerTurn, +} from "@/lib/answer-thread-storage"; +import type { RagAnswer, SearchResult } from "@/lib/types"; + +import type { AnswerThreadSnapshotMetadata } from "@/components/clinical-dashboard/use-persisted-answer-thread"; + +export function snapshotMetadataFromPersisted(persisted: PersistedAnswerThread): AnswerThreadSnapshotMetadata { + return { + latestSubmissionSignature: persisted.latestSubmissionSignature, + expiresAt: persisted.expiresAt, + }; +} + +export function nextAnswerTurnSeqFromPriorTurns(priorTurns: StoredAnswerTurn[]): number { + return priorTurns.reduce((max, turn) => { + const match = /^answer-turn-(\d+)$/.exec(turn.id); + return match ? Math.max(max, Number(match[1])) : max; + }, 0); +} + +export function collapsedTurnIdsFromPersisted(persisted: PersistedAnswerThread): Set { + return persisted.collapsedTurnIds.length + ? new Set(persisted.collapsedTurnIds) + : new Set(persisted.priorTurns.map((turn) => turn.id)); +} + +/** + * Owns answer-thread owner transitions and the one-shot sessionStorage restore + * that seeds React state before auto-run / persistence effects may fire. + */ +export function useAnswerThreadBootstrap({ + answerThreadOwnerId, + authStatus, + searchMode, + submittedUrlQuery, + expectedSubmissionSignature, + activeAnswerThreadOwnerIdRef, + answerThreadBootstrappedRef, + restoredThreadFromStorageRef, + latestAnswerTurnRef, + latestAnswerSnapshotMetadataRef, + answerTurnSeqRef, + autoRunSearchSignatureRef, + setPriorAnswerTurns, + setLatestAnswerQuery, + setCollapsedTurnIds, + setShowEarlierTurns, + setAnswer, + setSources, + setModeSearchSubmitted, + setQuery, + setAnswerThreadBootstrapped, +}: { + answerThreadOwnerId: string | null; + authStatus: string; + searchMode: string; + submittedUrlQuery: string; + expectedSubmissionSignature: string | undefined; + activeAnswerThreadOwnerIdRef: MutableRefObject; + answerThreadBootstrappedRef: MutableRefObject; + restoredThreadFromStorageRef: MutableRefObject; + latestAnswerTurnRef: MutableRefObject | null>; + latestAnswerSnapshotMetadataRef: MutableRefObject; + answerTurnSeqRef: MutableRefObject; + autoRunSearchSignatureRef: MutableRefObject; + setPriorAnswerTurns: Dispatch>; + setLatestAnswerQuery: Dispatch>; + setCollapsedTurnIds: Dispatch>>; + setShowEarlierTurns: Dispatch>; + setAnswer: Dispatch>; + setSources: Dispatch>; + setModeSearchSubmitted: Dispatch>; + setQuery: Dispatch>; + setAnswerThreadBootstrapped: Dispatch>; +}) { + const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); + useEffect(() => { + const previousOwnerId = previousAnswerThreadOwnerIdRef.current; + previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId; + activeAnswerThreadOwnerIdRef.current = answerThreadOwnerId; + if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return; + clearPersistedAnswerThread(previousOwnerId); + if (answerThreadOwnerId) clearPersistedAnswerThread(answerThreadOwnerId); + answerThreadBootstrappedRef.current = false; + queueMicrotask(() => { + setPriorAnswerTurns([]); + setLatestAnswerQuery(null); + setCollapsedTurnIds(new Set()); + setAnswer(null); + setSources([]); + latestAnswerTurnRef.current = null; + latestAnswerSnapshotMetadataRef.current = null; + setAnswerThreadBootstrapped(false); + }); + }, [ + activeAnswerThreadOwnerIdRef, + answerThreadBootstrappedRef, + answerThreadOwnerId, + latestAnswerSnapshotMetadataRef, + latestAnswerTurnRef, + setAnswer, + setAnswerThreadBootstrapped, + setCollapsedTurnIds, + setLatestAnswerQuery, + setPriorAnswerTurns, + setSources, + ]); + + useEffect(() => { + if (authStatus === "loading" || answerThreadBootstrappedRef.current) return; + queueMicrotask(() => { + const persisted = + answerThreadOwnerId && searchMode === "answer" + ? loadPersistedAnswerThread(answerThreadOwnerId, { expectedSubmissionSignature }) + : null; + if (persisted) { + restoredThreadFromStorageRef.current = true; + setPriorAnswerTurns(persisted.priorTurns); + setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + setShowEarlierTurns(persisted.showEarlierTurns); + latestAnswerSnapshotMetadataRef.current = snapshotMetadataFromPersisted(persisted); + if (persisted.latestTurn) { + latestAnswerTurnRef.current = persisted.latestTurn; + setAnswer(persisted.latestTurn.answer); + setSources(persisted.latestTurn.sources); + setModeSearchSubmitted(true); + setQuery(""); + autoRunSearchSignatureRef.current = persisted.latestSubmissionSignature; + } + answerTurnSeqRef.current = nextAnswerTurnSeqFromPriorTurns(persisted.priorTurns); + setCollapsedTurnIds(collapsedTurnIdsFromPersisted(persisted)); + } else if (!answerThreadOwnerId) { + clearPersistedAnswerThread(); + } + answerThreadBootstrappedRef.current = true; + setAnswerThreadBootstrapped(true); + }); + }, [ + answerThreadBootstrappedRef, + answerThreadOwnerId, + answerTurnSeqRef, + authStatus, + autoRunSearchSignatureRef, + expectedSubmissionSignature, + latestAnswerSnapshotMetadataRef, + latestAnswerTurnRef, + restoredThreadFromStorageRef, + searchMode, + setAnswer, + setAnswerThreadBootstrapped, + setCollapsedTurnIds, + setLatestAnswerQuery, + setModeSearchSubmitted, + setPriorAnswerTurns, + setQuery, + setShowEarlierTurns, + setSources, + submittedUrlQuery, + ]); +} diff --git a/tests/use-answer-thread-bootstrap.test.ts b/tests/use-answer-thread-bootstrap.test.ts new file mode 100644 index 000000000..5504bea12 --- /dev/null +++ b/tests/use-answer-thread-bootstrap.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + collapsedTurnIdsFromPersisted, + nextAnswerTurnSeqFromPriorTurns, + snapshotMetadataFromPersisted, +} from "@/components/clinical-dashboard/use-answer-thread-bootstrap"; +import type { PersistedAnswerThread } from "@/lib/answer-thread-storage"; +import type { RagAnswer } from "@/lib/types"; + +const sampleAnswer = { + answer: "sample", + confidence: "medium", + citations: [], + warnings: [], +} as unknown as RagAnswer; + +function sampleThread(overrides: Partial = {}): PersistedAnswerThread { + return { + version: 2, + priorTurns: [ + { id: "answer-turn-3", query: "earlier", answer: sampleAnswer, sources: [] }, + { id: "answer-turn-7", query: "later", answer: sampleAnswer, sources: [] }, + ], + latestTurn: { query: "latest", answer: sampleAnswer, sources: [] }, + collapsedTurnIds: ["answer-turn-3"], + showEarlierTurns: true, + latestSubmissionSignature: "answer:latest:", + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +describe("use-answer-thread-bootstrap helpers", () => { + it("copies only snapshot metadata from a persisted thread", () => { + const persisted = sampleThread(); + expect(snapshotMetadataFromPersisted(persisted)).toEqual({ + latestSubmissionSignature: persisted.latestSubmissionSignature, + expiresAt: persisted.expiresAt, + }); + }); + + it("derives the next turn sequence from prior turn ids", () => { + expect(nextAnswerTurnSeqFromPriorTurns(sampleThread().priorTurns)).toBe(7); + expect(nextAnswerTurnSeqFromPriorTurns([{ id: "other", query: "q", answer: sampleAnswer, sources: [] }])).toBe(0); + }); + + it("falls back to collapsing every prior turn when none were stored", () => { + const persisted = sampleThread({ collapsedTurnIds: [] }); + expect([...collapsedTurnIdsFromPersisted(persisted)]).toEqual(["answer-turn-3", "answer-turn-7"]); + }); +}); From 4150153ac3bfcfdfe70f34c6fce2b94e55d866aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 11:25:16 +0000 Subject: [PATCH 4/6] fix(ci): use Back to medications aria-label in quick-links smoke Production UI (2) timed out waiting for Breadcrumb/Medications exact inside medication-page-*; MedicationNavHeader exposes aria-label "Back to medications" outside that test id. Align with the in-page-nav Playwright contract from #1781 and guard the quick-links block against the old selector. --- docs/branch-review-ledger.md | 1 + docs/design-system/adoption-manifest.json | 4 ++-- tests/in-page-nav-playwright-contract.test.ts | 15 +++++++++++++++ tests/ui-smoke.spec.ts | 14 ++++++++------ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c95ac5086..3b1e792fc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -859,3 +859,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-10 | PR #1788 | aaeb54630fde2c05efe9a90eda13fbb92cc933c0 | Run PR sweep | Static PR maintainability: extracted useAnswerThreadBootstrap (ClinicalDashboard 4144→4106); merged origin/main (#1794/#1795) | check:maintainability-budgets pass; vitest bootstrap+storage 17; tsc clean; CI pending after push | | 2026-08-10 | PR #1797 / claude/codex-m4a-retire-dead-type-8wq9ta | 6bf3c7b2a0600021290e165302fd07d721af6592 | retire the dead --text-2xl-compact type step (ledger #297): globals.css @theme, twMerge config, two test lists, the design-system-contract exemption, TOKENS.md/GATES.md | Executed the recorded next action on outstanding-issues #297. The step had zero class-utility and zero var(--text-*) consumers, so the deletion renders identically; UNUSED_TYPE_STEP_EXEMPTIONS is now empty and the declared-but-unconsumed gate holds the line with no carve-out. One test fixture using the token as a synthetic var() consumer was repointed at --text-2xl-minus. GATES.md corrected to eight non-standard steps; the 705-consumer total is unchanged because this step contributed 0. No clinical, RAG-ranking or operational risk paths touched (classifyPullRequestFiles: all false). | check:design-system-contract PASS (705 production files); check:type-scale --strict PASS; lint exit 0; typecheck exit 0; npm run build after rm -rf .next exit 0 (Compiled successfully in 63s); check:outstanding-issues PASS; verify:pr-local completed through typecheck then failed at test on a PRE-EXISTING root-permission failure in tests/pr-handoff-stop.test.ts that reproduces on clean d812c76 (5993 passed, 1 failed); build and check:rag:fixtures run/assessed separately. No UI gate: no rendered output can change. No provider-backed check run. | | 2026-08-10 | codex/visual-baseline-advisory-pr | 6bc57714c36bc6d027561bb8f5f8b00bb92524b2 | PR #1791 babysit unblock | fixed Production UI formulation Clear→Draft flake settle; classified visual drift vs non-drift failures | test:ci-workflows 263; classify-visual-baseline-outcome+ci-cache-safety 40 | +| 2026-08-10 | PR #1788 / codex/chat-contextual-back-answer-cache-05ea-1 | 98dd877ab4bd41e169310004c3b91aa4780d3772 | Run PR sweep | before: Production UI (2) failed on Breadcrumb/Medications selector; after: use Back to medications aria-label + contract guard; disposition Codex/Sentry/CodeRabbit threads; merged origin/main | vitest in-page-nav-contract+answer-thread-storage 19p; format; merge-tree clean | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index fcb6d1ce1..f0a28bf62 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -252,8 +252,8 @@ }, "testFiles": [ "tests/design-sync-visual-exports.test.ts", - "tests/information-page-shell.dom.test.tsx", - "tests/ui-smoke.spec.ts" + "tests/in-page-nav-playwright-contract.test.ts", + "tests/information-page-shell.dom.test.tsx" ], "baseline": { "targetLayer": "v2", diff --git a/tests/in-page-nav-playwright-contract.test.ts b/tests/in-page-nav-playwright-contract.test.ts index 4ff3b6597..d2c0e0619 100644 --- a/tests/in-page-nav-playwright-contract.test.ts +++ b/tests/in-page-nav-playwright-contract.test.ts @@ -31,6 +31,21 @@ describe("in-page-nav Playwright contract", () => { expect(prescribingBlock.match(/Back to medications/g)?.length ?? 0).toBeGreaterThanOrEqual(2); }); + it("cross-mode quick-links smoke uses the aria-label back name, not Breadcrumb/Medications", () => { + // Production UI (2) on PR #1788 timed out waiting for Breadcrumb > + // Medications exact inside medication-page-*; the back control lives in + // MedicationNavHeader with aria-label "Back to medications". + const source = readFileSync(UI_SMOKE, "utf8"); + const quickLinksBlock = source.slice( + source.indexOf('test("answer results surface cross-mode quick links"'), + source.indexOf('test("answer mode keeps prior turns visible for follow-up questions"'), + ); + + expect(quickLinksBlock).toContain('name: "Back to medications"'); + expect(quickLinksBlock).not.toMatch(/name:\s*["']Breadcrumb["']/); + expect(quickLinksBlock).not.toMatch(/getByRole\(\s*["']link["']\s*,\s*\{\s*name:\s*["']Medications["']/); + }); + it("forms section-nav scopes form-detail-header through visibleByTestId", () => { const source = readFileSync(UI_FORMS_SECTION_NAV, "utf8"); expect(source).toMatch(/import\s*\{[^}]*\bvisibleByTestId\b[^}]*\}\s*from\s*["']\.\/playwright-settlement["']/); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0b5f8b7ef..3429da392 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2644,12 +2644,14 @@ test.describe("Clinical KB UI smoke coverage", () => { await waitForReactEventHandler(medicationLink, "onClick"); await medicationLink.click(); await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 45_000 }); - const medicationPage = page.getByTestId("medication-page-clozapine"); - await expect(medicationPage).toBeVisible(); - await medicationPage - .getByRole("navigation", { name: "Breadcrumb" }) - .getByRole("link", { name: "Medications", exact: true }) - .click(); + // MedicationNavHeader portals above `medication-page-*`; InPageNavHeader's + // back control is always named via aria-label (`Back to ${label}`), which is + // the only stable accessible name across desktop (visible text) and phone + // (label hidden). See tests/in-page-nav-playwright-contract.test.ts. + await expect(page.getByTestId("medication-page-clozapine")).toBeVisible(); + const medicationsBack = page.getByRole("link", { name: "Back to medications" }).filter({ visible: true }); + await expect(medicationsBack).toBeVisible(); + await medicationsBack.click(); await expect(page).toHaveURL( (url) => url.pathname === "/" && From d8ceec43b0287f4ac3002d29f23e01dd21380e26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 12:20:54 +0000 Subject: [PATCH 5/6] ci: retrigger Production UI after formulation flake on shard 1 From 24a18d37d605c9eaeb03a4cd818da45ebcbcc9a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 12:25:02 +0000 Subject: [PATCH 6/6] fix(formulation): make framework radio selection reliable under UI shards Controlled sr-only template radios could miss native label activation after scroll-into-view on Production UI shard 1. Select from label click as well, and force-check the radio in the Playwright journey. --- .../formulation/formulation-builder-page.tsx | 14 ++++++++++---- tests/ui-formulation.spec.ts | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx index cca503de3..8e3e164a9 100644 --- a/src/components/formulation/formulation-builder-page.tsx +++ b/src/components/formulation/formulation-builder-page.tsx @@ -452,6 +452,11 @@ export function FormulationBuilderPage({ > {formulationTemplates.map((template) => { const active = template.id === templateId; + const selectTemplate = () => { + if (template.id === templateId) return; + setTemplateId(template.id); + setEditedDraft(null); + }; return (