From b51196a6be1f1c611dba936e99ce43cd8fdeb810 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:01:45 +0800 Subject: [PATCH 01/87] fix(ui): center mobile hero search and harden composer portal - Hide footer Evidence/Sources chips on phone hero composers; scope stays in + menu - Suppress bottom-dock composer flash until hero portal slot is ready - Increase composer action/send touch targets to 44px on phones - Update Playwright tests for scope menu, Answer home geometry, and stress fallback --- docs/site-map.md | 5 +- next.config.ts | 9 + scripts/generate-site-map.ts | 5 +- src/app/applications/layout.tsx | 11 - src/app/applications/loading.tsx | 5 - src/app/applications/page.tsx | 12 - src/app/globals.css | 23 +- src/components/ClinicalDashboard.tsx | 33 +-- src/components/applications-launcher-page.tsx | 166 ++----------- .../clinical-dashboard/dashboard-nav.tsx | 22 +- .../master-search-header.tsx | 221 ++---------------- .../clinical-dashboard/mode-action-popup.tsx | 17 +- tests/ui-accessibility.spec.ts | 9 +- tests/ui-smoke.spec.ts | 22 +- tests/ui-stress.spec.ts | 16 +- tests/ui-tools.spec.ts | 46 ++-- 16 files changed, 157 insertions(+), 465 deletions(-) delete mode 100644 src/app/applications/layout.tsx delete mode 100644 src/app/applications/loading.tsx delete mode 100644 src/app/applications/page.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 65e6e3f52..b8a3dec8a 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -5,7 +5,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Main product pages - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. -- `/applications` - Application and tool launcher. Source: `src/app/applications/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. @@ -40,7 +39,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | | Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | | Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | -| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | ## Documents flow index @@ -601,5 +600,5 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Differentials | `src/app/differentials, src/lib/differentials.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | -| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | +| Tools | `src/components/applications-launcher-page.tsx` | | Mockups | `src/app/mockups` | diff --git a/next.config.ts b/next.config.ts index a89b5d615..4bb1b02e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -64,6 +64,15 @@ const nextConfig: NextConfig = { }, ]; }, + async redirects() { + return [ + { + source: "/applications", + destination: "/?mode=tools", + permanent: true, + }, + ]; + }, }; export default nextConfig; diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 115c74068..3e60cb961 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -40,7 +40,6 @@ type SiteMapData = { const routeDescriptions: Record = { "/": "Main Clinical KB shell.", - "/applications": "Application and tool launcher.", "/differentials": "Differentials home and search surface.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", @@ -97,7 +96,7 @@ const routeOwnershipRows = [ ["Differentials", "src/app/differentials, src/lib/differentials.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], - ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], + ["Tools", "src/components/applications-launcher-page.tsx"], ["Mockups", "src/app/mockups"], ] as const; @@ -279,7 +278,7 @@ function renderModePageIndex() { mode: "Tools", home: appModeHomeHref("tools"), search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), - detail: "`/applications` launcher and tool detail panels inside tools mode.", + detail: "Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`).", }, ]); } diff --git a/src/app/applications/layout.tsx b/src/app/applications/layout.tsx deleted file mode 100644 index c9a012540..000000000 --- a/src/app/applications/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { ReactNode } from "react"; - -import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; - -export default function ApplicationsLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/src/app/applications/loading.tsx b/src/app/applications/loading.tsx deleted file mode 100644 index 59334e70b..000000000 --- a/src/app/applications/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx deleted file mode 100644 index 2f7fd01fd..000000000 --- a/src/app/applications/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; - -import { ApplicationsLauncherPage } from "@/components/applications-launcher-page"; - -export const metadata: Metadata = { - title: "Applications - Clinical KB", - description: "Launch Clinical KB applications, workflows, and connected clinical tools.", -}; - -export default function ApplicationsRoute() { - return ; -} diff --git a/src/app/globals.css b/src/app/globals.css index b1873a02d..f04f05ebd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1101,14 +1101,14 @@ summary::-webkit-details-marker { .answer-footer-search-action, .answer-footer-search-send { - height: 2.05rem; - width: 2.05rem; + height: 2.75rem; + width: 2.75rem; } .answer-footer-search-action svg, .answer-footer-search-send svg { - height: 1rem; - width: 1rem; + height: 1.1rem; + width: 1.1rem; } } @@ -1172,14 +1172,21 @@ summary::-webkit-details-marker { @media (max-width: 430px) { .answer-footer-search-action, .answer-footer-search-send { - height: 2.05rem !important; - width: 2.05rem !important; + height: 2.75rem !important; + width: 2.75rem !important; + min-height: 2.75rem; + min-width: 2.75rem; } .answer-footer-search-action svg, .answer-footer-search-send svg { - height: 1rem; - width: 1rem; +<<<<<<< Updated upstream + height: 1.125rem; + width: 1.125rem; +======= + height: 1.1rem; + width: 1.1rem; +>>>>>>> Stashed changes } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f73ad11e2..5e8d7c75b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1067,26 +1067,8 @@ function SettingsHelpFooter({ onClick }: { onClick: () => void }) { ); } -function ToolsHub({ - query, - onQueryChange, - desktopComposerSlotId, - showDetailPanel, -}: { - query: string; - onQueryChange: (nextQuery: string) => void; - desktopComposerSlotId?: string; - showDetailPanel?: boolean; -}) { - return ( - - ); +function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { + return ; } type MobileSectionFabItem = { @@ -1587,7 +1569,7 @@ export function ClinicalDashboard({ const activeModeSearch = appModeSearchConfig(searchMode); const activeModeResultKind = appModeResultKind(searchMode); const requestQueryMode = appModeQueryMode(searchMode, queryMode); - const requestedRun = searchParams.get("run") === "1"; + // Record matches come from the owner-scoped registry API (mock fixtures in // demo mode); ranking stays client-side so live-typing behaviour is // unchanged and the registry is fetched once per active mode. @@ -2473,7 +2455,7 @@ export function ClinicalDashboard({ urlDocumentSearchBootstrappedRef.current = true; void executeSearch(searchText, mode, scopeFilters); // URL search intentionally runs once when the selected mode can execute. - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canRunSearch, answerThreadBootstrapped]); useEffect(() => { @@ -3958,12 +3940,7 @@ export function ClinicalDashboard({ }} /> ) : activeModeResultKind === "tools" ? ( - + ) : activeModeResultKind === "favourites" ? ( void; - onSubmit: () => void; - copy: LauncherCopy; - className?: string; -}) { - return ( -
) => { - event.preventDefault(); - onSubmit(); - }} - className={cn( - "grid min-h-13 grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-lux)] text-left shadow-[var(--shadow-card)]", - className, - )} - > - - - - - -
- ); -} - function QuickActions({ onSelect, mobile }: { onSelect: (id: string) => void; mobile?: boolean }) { return (
void; desktopComposerSlotId?: string; - showDetailPanel?: boolean; className?: string; }; export function ApplicationsLauncherWorkspace({ - variant = "standalone", - query: controlledQuery, - onQueryChange, + query = "", desktopComposerSlotId, - showDetailPanel, className, }: ApplicationsLauncherWorkspaceProps) { - const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); - const isDashboardTools = variant === "dashboard-tools"; - const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); - const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; - const query = controlledQuery ?? uncontrolledQuery; + const composerSlotId = desktopComposerSlotId ?? modeHomeDesktopComposerSlotId; + const [selectedId, setSelectedId] = useState(() => initialToolId(query)); + const [detailOpen, setDetailOpen] = useState(false); + const copy = toolsLauncherCopy; const normalizedQuery = query.trim().toLowerCase(); - const queryDerivedId = useMemo(() => initialToolId(query), [query]); - const [selection, setSelection] = useState(() => ({ - queryKey: (controlledQuery ?? "").trim().toLowerCase(), - id: initialToolId(controlledQuery), - })); - const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; + + useEffect(() => { + setSelectedId((current) => initialToolId(query) || current); + }, [query]); const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -940,34 +847,25 @@ export function ApplicationsLauncherWorkspace({ : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); - function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) setUncontrolledQuery(nextQuery); - onQueryChange?.(nextQuery); - } - function openTool(id: string) { - setSelection({ queryKey: normalizedQuery, id }); + setSelectedId(id); setDetailOpen(true); } - function submitSearch() { - if (filteredApps[0]) openTool(filteredApps[0].id); - } - return (
@@ -975,7 +873,7 @@ export function ApplicationsLauncherWorkspace({

{copy.heading} @@ -985,22 +883,14 @@ export function ApplicationsLauncherWorkspace({

- {desktopComposerSlotId ? ( + {composerSlotId ? (
- ) : ( - - )} + ) : null} -
+
@@ -1012,7 +902,7 @@ export function ApplicationsLauncherWorkspace({
@@ -1055,15 +945,9 @@ export function ApplicationsLauncherWorkspace({

- {isDashboardTools ? ( - - ) : null} + setDetailOpen(false)} />
); } - -export function ApplicationsLauncherPage() { - return ; -} diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index bf6621cb6..4ffe93ec2 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -12,26 +12,8 @@ import { cn } from "@/components/ui-primitives"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; -export function ToolsHub({ - query, - onQueryChange, - desktopComposerSlotId, - showDetailPanel, -}: { - query: string; - onQueryChange: (nextQuery: string) => void; - desktopComposerSlotId?: string; - showDetailPanel?: boolean; -}) { - return ( - - ); +export function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { + return ; } type MobileSectionFabItem = { diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 58761d61a..624214196 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -16,17 +16,13 @@ import { createPortal } from "react-dom"; import { Activity, - BadgeCheck, CalendarDays, Check, CheckCircle2, ChevronDown, FileText, Filter, - FolderOpen, - GitBranch, Globe2, - ListChecks, Loader2, Menu, MessageSquarePlus, @@ -328,7 +324,6 @@ export function MasterSearchHeader({ [documentById, selectedDocumentIds], ); const scopeSummary = selectedDocumentIds.length === 0 ? "All documents" : `${selectedDocumentIds.length} scoped`; - const footerScopeLabel = selectedDocumentIds.length === 0 ? "All sources" : `${selectedDocumentIds.length} scoped`; const scopePreview = useMemo( () => selectedDocuments @@ -675,6 +670,7 @@ export function MasterSearchHeader({ ); let frame: number | null = null; let retryTimeout: number | null = null; + let portalRetryCount = 0; const syncTarget = () => { if (retryTimeout !== null) { window.clearTimeout(retryTimeout); @@ -682,14 +678,16 @@ export function MasterSearchHeader({ } const slot = mediaQuery.matches ? document.getElementById(desktopHomeComposerSlotId) : null; if (slot) { + portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); setDesktopHomeComposerHost(host); setDesktopHomeComposerActive(true); } else { host.parentNode?.removeChild(host); setDesktopHomeComposerActive(false); - if (mediaQuery.matches) { - retryTimeout = window.setTimeout(syncTarget, 50); + if (mediaQuery.matches && portalRetryCount < 24) { + portalRetryCount += 1; + retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); } } }; @@ -1017,144 +1015,6 @@ export function MasterSearchHeader({ ); } - // "open-evidence" is the one footer-chip action that isn't already a mode-action - // id — every other chip dispatches through the existing runModeAction handler - // (the same dispatcher the "+" action menu already uses for these ids). - type FooterChipActionId = ModeActionId | "open-evidence"; - - type FooterActionChip = { - icon: typeof Search; - shortLabel: string; - longLabel: string; - actionId: FooterChipActionId; - ariaLabel: string; - }; - - // The first ("trust") chip on the universal small-screen footer. Every mode gets - // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real - // action from that mode's own action menu rather than being decorative. - function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { - switch (mode) { - case "answer": - return { - icon: ListChecks, - shortLabel: "Evidence", - longLabel: "Evidence-based", - actionId: "open-evidence", - ariaLabel: "Open evidence-backed answer sources", - }; - case "documents": - return { - icon: BadgeCheck, - shortLabel: "Indexed", - longLabel: "Fully indexed", - actionId: "documents-collections", - ariaLabel: "Open the indexed document library", - }; - case "forms": - return { - icon: BadgeCheck, - shortLabel: "Library", - longLabel: "Form library", - actionId: "forms-records", - ariaLabel: "Open the form library", - }; - case "services": - return { - icon: BadgeCheck, - shortLabel: "Verified", - longLabel: "Verified directory", - actionId: "services-records", - ariaLabel: "Browse verified service records", - }; - case "favourites": - return { - icon: BadgeCheck, - shortLabel: "Trusted", - longLabel: "Trusted picks", - actionId: "favourites-browse", - ariaLabel: "Browse trusted favourites", - }; - case "differentials": - return { - icon: ListChecks, - shortLabel: "Evidence", - longLabel: "Evidence-linked", - actionId: "differentials-evidence", - ariaLabel: "Review cited differential evidence", - }; - case "prescribing": - return { - icon: ShieldCheck, - shortLabel: "Safety", - longLabel: "Safety-checked", - actionId: "medication-safety", - ariaLabel: "Review contraindications and cautions", - }; - case "tools": - return { - icon: BadgeCheck, - shortLabel: "Curated", - longLabel: "Curated registry", - actionId: "tools-browse", - ariaLabel: "Browse the curated tools registry", - }; - default: - return null; - } - } - - // The second footer chip. Answer/Documents/Forms use the shared document-scope - // trigger instead (see hasScopeFooterChip below) since scope is a real, existing - // concept for those three modes. Tools has no genuine second action yet, so it - // intentionally ships with a single chip rather than an invented one. - function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { - switch (mode) { - case "services": - return { - icon: ListChecks, - shortLabel: "Pathways", - longLabel: "Pathways", - actionId: "services-pathways", - ariaLabel: "Browse referral pathways", - }; - case "favourites": - return { - icon: FolderOpen, - shortLabel: "Sets", - longLabel: "Sets", - actionId: "favourites-sets", - ariaLabel: "Open saved sets", - }; - case "differentials": - return { - icon: GitBranch, - shortLabel: "Criteria", - longLabel: "Criteria", - actionId: "differentials-criteria", - ariaLabel: "Compare distinguishing criteria", - }; - case "prescribing": - return { - icon: Activity, - shortLabel: "Monitor", - longLabel: "Monitoring", - actionId: "medication-monitoring", - ariaLabel: "Review the monitoring schedule", - }; - default: - return null; - } - } - - function runFooterChipAction(actionId: FooterChipActionId) { - if (actionId === "open-evidence") { - onOpenEvidence?.(); - return; - } - runModeAction(actionId); - } - function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; @@ -1162,23 +1022,22 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; +<<<<<<< Updated upstream +======= // Compact search views drop the chip row on phones so the pill can sit // flush with the bottom edge; the same actions stay reachable via the // integrated "+" menu. - const showFooterSearchChips = usesFooterChipLayout && !usesCompactMobileBottomStyle; + const showFooterSearchChips = + usesFooterChipLayout && + !usesCompactMobileBottomStyle && + !(isDesktopHomeComposer && usesPhoneSearchLayout); +>>>>>>> Stashed changes // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; const usesModeIdentityAffordance = usesBottomComposerPlacement && !usesSendAffordance; const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; - const trustFooterChip = footerTrustChipFor(searchMode); - const secondaryFooterChip = footerSecondaryChipFor(searchMode); - // Fallback icons here are never rendered — both are only used inside a JSX guard - // on the corresponding chip being non-null — but keep the icon variables typed as - // components (not `| null`) so the JSX below type-checks without a cast. - const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; - const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1285,6 +1144,7 @@ export function MasterSearchHeader({ onModeSelect={selectAppModeById} onPlacementChange={setActionMenuPlacement} triggerClassName="answer-footer-search-action" + triggerRef={scopeSummaryRef} integrated={usesFooterChipLayout} /> @@ -1348,53 +1208,8 @@ export function MasterSearchHeader({ - {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? ( -
- {trustFooterChip ? ( - - ) : null} - {hasScopeFooterChip ? ( - - ) : null} - {!hasScopeFooterChip && secondaryFooterChip ? ( - - ) : null} -
- ) : null} - {/* Rendered as a sibling of the chip row (not nested inside it) so the "+" - menu's "Set scope" action still opens this popover on screens where the - chip row itself is hidden (documents/forms desktop widths) — the popover - still anchors correctly since the form stays position:fixed/sticky there. */} + {/* Scope popover is a form sibling so the "+" menu's "Set scope" action can + open it even when the footer chip row is not shown. */} {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
- {desktopHomeComposerActive && desktopHomeComposerHost ? null : renderSearchComposer("default")} + {desktopHomeComposerActive && desktopHomeComposerHost + ? null + : desktopHomeComposerSlotId + ? null + : renderSearchComposer("default")} {desktopHomeComposerActive && desktopHomeComposerHost ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) : null} diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 2e54e7886..9dd64da10 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -8,6 +8,7 @@ import { useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, + type Ref, } from "react"; import { BadgeCheck, @@ -245,6 +246,15 @@ export function modeActionItemsFor(setId: ModeActionSetId): readonly ModeActionI return modeActionSets[setId]; } +function assignTriggerRef(ref: Ref | undefined, element: HTMLButtonElement | null) { + if (!ref) return; + if (typeof ref === "function") { + ref(element); + return; + } + ref.current = element; +} + export function ModeActionPopup({ open, title, @@ -260,6 +270,7 @@ export function ModeActionPopup({ onModeSelect, onPlacementChange, triggerClassName, + triggerRef, integrated = false, }: { open: boolean; @@ -276,6 +287,7 @@ export function ModeActionPopup({ onModeSelect?: (modeId: string) => void; onPlacementChange?: (placement: ModeActionPlacement) => void; triggerClassName?: string; + triggerRef?: Ref; integrated?: boolean; }) { const buttonRef = useRef(null); @@ -643,7 +655,10 @@ export function ModeActionPopup({
- ))} -
-
+ ); } diff --git a/src/components/clinical-dashboard/answer-suggestion-chips.tsx b/src/components/clinical-dashboard/answer-suggestion-chips.tsx new file mode 100644 index 000000000..ac6c316a7 --- /dev/null +++ b/src/components/clinical-dashboard/answer-suggestion-chips.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { cn } from "@/components/ui-primitives"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +export function AnswerSuggestionChips({ + suggestions, + onPick, + disabled = false, + label, + testId, + layout = "wrap", + className, +}: { + suggestions: string[]; + onPick: (suggestion: string) => void; + disabled?: boolean; + label?: string; + testId?: string; + layout?: "wrap" | "scroll"; + className?: string; +}) { + if (!suggestions.length) return null; + + return ( +
+ {label ? ( + + ) : null} +
+ {suggestions.map((suggestion) => ( + + ))} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index c2411c624..97ca9ec66 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -16,6 +16,7 @@ import { type ModeActionId, type ModeActionSetId, } from "@/components/clinical-dashboard/mode-action-popup"; +import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { cn } from "@/components/ui-primitives"; import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -92,29 +93,43 @@ function ContextHintRow({ const ModeIcon = appModeIcons[modeId]; useEffect(() => { + if (modeId === "answer") return; const timer = window.setInterval(() => { setIndex((current) => (current + 1) % examples.length); }, 4500); return () => window.clearInterval(timer); - }, [examples]); + }, [examples, modeId]); const example = examples[index % examples.length]; + const visibilityClass = placement === "bottom-dock" ? "flex" : "hidden lg:flex"; + + if (modeId === "answer") { + return ( + + ); + } return (
- - - + + + Searching {mode.label.toLowerCase()} - + Try: - ) : null} - {evidenceAvailable ? ( - - ) : null} + {clinicalAvailable ? ( + + ) : null} + {evidenceAvailable ? ( + + ) : null} +
+ ) : null}
@@ -505,7 +527,7 @@ function clinicalNoteHeuristicTitle(value: string) { ) { return "Escalation triggers"; } - if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|–)\s*7|dose change|stable|days?)\b/.test(lower)) { + if (/\blithium levels?\b/.test(lower) && /\b(5\s*(?:to|-|ΓÇô)\s*7|dose change|stable|days?)\b/.test(lower)) { return "Lithium level timing"; } if (/\b(lithium level|serum lithium|trough level)\b/.test(lower)) return "Lithium level check"; @@ -540,7 +562,7 @@ function clinicalNoteTitleFromItem(item: string, section: ClinicalDetailSection, } return title; } - const dashIndex = text.search(/\s[-–]\s/); + const dashIndex = text.search(/\s[-ΓÇô]\s/); if (dashIndex > 8 && dashIndex < 54) return text.slice(0, dashIndex).trim(); if (section.items.length === 1 && section.title.length <= 42) return section.title; const words = text @@ -565,7 +587,7 @@ function clinicalNoteDetailFromItem(item: string, title: string) { if (lowerText.startsWith(`${normalizedTitle}:`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 1).trim()); } - if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} –`)) { + if (lowerText.startsWith(`${normalizedTitle} -`) || lowerText.startsWith(`${normalizedTitle} ΓÇô`)) { return sentenceCaseClinicalNoteDetail(text.slice(title.length + 2).trim()); } if (text === title) return "Review linked source context before using this note."; @@ -1018,7 +1040,7 @@ export function compactEvidenceSummary( countParts.push(`${sourceCount} source${sourceCount === 1 ? "" : "s"}`); } - return [support, ...countParts].join(" · "); + return [support, ...countParts].join(" ┬╖ "); } export type EvidenceTabName = "Claims" | "Quotes" | "Tables" | "Images" | "Gaps"; @@ -1198,7 +1220,7 @@ function RenderModelSourceList({ {cleanDisplayTitle(source.title)}

- p.{source.page_number ?? "n/a"} · {sourceStatusLabel(metadata)} · {source.sourceStrength} support + p.{source.page_number ?? "n/a"} ┬╖ {sourceStatusLabel(metadata)} ┬╖ {source.sourceStrength} support

@@ -1319,7 +1341,7 @@ export const simpleClinicalTableProps = { function compactEvidenceCell(value: string | null | undefined, max = 140) { const text = value ? value.replace(/\s+/g, " ").trim() : ""; - return text.length > max ? `${text.slice(0, max - 1).trim()}…` : text; + return text.length > max ? `${text.slice(0, max - 1).trim()}ΓǪ` : text; } export function evidenceMapRowsFromRenderModel(renderModel: AnswerRenderModel): AnswerEvidenceMapRow[] { diff --git a/src/components/clinical-dashboard/use-collapse-when-content-below.ts b/src/components/clinical-dashboard/use-collapse-when-content-below.ts new file mode 100644 index 000000000..96d830117 --- /dev/null +++ b/src/components/clinical-dashboard/use-collapse-when-content-below.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore, type RefObject } from "react"; + +// Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's +// phone/tablet seam. Collapse only ever runs below the sm breakpoint. +const phoneMediaQuery = "(max-width: 639px)"; + +// Reserved height for the fixed answer footer composer (pill + chips + safe area). +const defaultComposerInsetPx = 132; +// Small tolerance so the row hides just before it visually overlaps the composer. +const dockBandThresholdPx = 12; + +function subscribeToPhoneMedia(onChange: () => void) { + const media = window.matchMedia(phoneMediaQuery); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +} + +function readPhoneMedia() { + return window.matchMedia(phoneMediaQuery).matches; +} + +function readPhoneMediaServer() { + return false; +} + +interface UseCollapseWhenContentBelowOptions { + /** Scroll container (`#main-content`). */ + containerRef?: RefObject; + /** The Clinical notes / Evidence row wrapper. */ + anchorRef?: RefObject; + /** Sentinel placed after all content below the action row. */ + belowSentinelRef?: RefObject; + /** Viewport inset reserved for the fixed bottom composer. */ + composerInsetPx?: number; + /** Disables the behavior entirely (state resets to expanded). */ + disabled?: boolean; +} + +/** + * On phones, collapses the answer support action row when it sits in the + * composer dock band while unscrolled content still lives below it (e.g. + * follow-up suggestions). Expands again at the true scroll bottom or when + * the row leaves the dock band. + */ +export function useCollapseWhenContentBelow({ + containerRef, + anchorRef, + belowSentinelRef, + composerInsetPx = defaultComposerInsetPx, + disabled = false, +}: UseCollapseWhenContentBelowOptions): boolean { + const [collapsed, setCollapsed] = useState(false); + const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer); + const active = isPhone && !disabled; + + useEffect(() => { + if (!active) return; + + const container = containerRef?.current ?? null; + const anchor = anchorRef?.current ?? null; + const sentinel = belowSentinelRef?.current ?? null; + if (!container || !anchor || !sentinel) return; + + let frame = 0; + + const evaluate = () => { + frame = 0; + const dockLine = window.innerHeight - composerInsetPx; + const anchorRect = anchor.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + const inDockBand = anchorRect.bottom >= dockLine - dockBandThresholdPx; + const contentBelow = sentinelRect.top > dockLine; + setCollapsed(inDockBand && contentBelow); + }; + + const schedule = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + container.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule, { passive: true }); + schedule(); + + return () => { + container.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame) window.cancelAnimationFrame(frame); + setCollapsed(false); + }; + }, [active, containerRef, anchorRef, belowSentinelRef, composerInsetPx]); + + return active && collapsed; +} diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index a71465d44..593cd9cfb 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -13,6 +13,7 @@ export type ApiRateLimitBucket = | "answer" | "search" | "document_read" + | "document_upload" | "document_summarize" | "document_reindex" | "bulk_reindex" @@ -30,6 +31,7 @@ const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, document_read: { limit: 180, windowSeconds: 60 }, + document_upload: { limit: 12, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -40,6 +42,7 @@ const anonymousApiRateLimitDefaults: Partial; From d8d43301311dc319b440a225145cea874bc23f93 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:40:54 +0800 Subject: [PATCH 17/87] fix: clear merge markers and align tests with local upload guard --- src/app/api/images/[id]/signed-url/route.ts | 11 +--- src/app/globals.css | 5 -- src/components/ClinicalDashboard.tsx | 15 ++--- src/components/applications-launcher-page.tsx | 10 ++-- .../master-search-header.tsx | 10 ---- tests/api-validation-contract.test.ts | 21 +++++-- tests/private-access-routes.test.ts | 59 +++++++++++++++---- tests/ui-stress.spec.ts | 9 --- 8 files changed, 73 insertions(+), 67 deletions(-) diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index a5d2af456..9ca925d49 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -51,22 +51,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); if ( !isCommittedGenerationMetadata({ - rowMetadata: imageRef.metadata, + rowMetadata: image.metadata, committedGeneration: committedIndexGeneration(document.metadata), }) ) { return NextResponse.json({ error: "Image not found." }, { status: 404 }); } - const { data: image, error: imageError } = await supabase - .from("document_images") - .select("storage_path,mime_type,caption") - .eq("id", id) - .maybeSingle(); - - if (imageError) throw new Error(imageError.message); - if (!image) return NextResponse.json({ error: "Image not found." }, { status: 404 }); - const signed = await supabase.storage .from(env.SUPABASE_IMAGE_BUCKET) .createSignedUrl(image.storage_path, signedUrlTtlSeconds); diff --git a/src/app/globals.css b/src/app/globals.css index f04f05ebd..0f72f2561 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1180,13 +1180,8 @@ summary::-webkit-details-marker { .answer-footer-search-action svg, .answer-footer-search-send svg { -<<<<<<< Updated upstream height: 1.125rem; width: 1.125rem; -======= - height: 1.1rem; - width: 1.1rem; ->>>>>>> Stashed changes } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 74bec4c9f..524787186 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3055,23 +3055,20 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); - const requestId = invalidateSearchRequests(searchRequestSeqRef.current); - searchRequestSeqRef.current = requestId; + const requestId = ++searchRequestSeqRef.current; try { const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); const payload = await runWithRetries(() => requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode), ); - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { - applySearchResult(payload); - } + if (requestId !== searchRequestSeqRef.current) return; + applySearchResult(payload); } catch (requestError) { - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { - setError(requestError instanceof Error ? requestError.message : "Document search failed"); - } + if (requestId !== searchRequestSeqRef.current) return; + setError(requestError instanceof Error ? requestError.message : "Document search failed"); } finally { - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + if (requestId === searchRequestSeqRef.current) { setLoading(false); setAnswerProgress(null); } diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 71ab74754..339642014 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -816,15 +816,13 @@ export function ApplicationsLauncherWorkspace({ }: ApplicationsLauncherWorkspaceProps) { const [activeFilter, setActiveFilter] = useState("all"); const composerSlotId = desktopComposerSlotId ?? modeHomeDesktopComposerSlotId; - const [selectedId, setSelectedId] = useState(() => initialToolId(query)); + const querySelectedId = initialToolId(query); + const [persistedSelectedId, setPersistedSelectedId] = useState(() => initialToolId(query) ?? ""); + const selectedId = querySelectedId ?? persistedSelectedId; const [detailOpen, setDetailOpen] = useState(false); const copy = toolsLauncherCopy; const normalizedQuery = query.trim().toLowerCase(); - useEffect(() => { - setSelectedId((current) => initialToolId(query) || current); - }, [query]); - const filteredApps = useMemo(() => { return launcherApps.filter((app) => { const matchesFilter = @@ -848,7 +846,7 @@ export function ApplicationsLauncherWorkspace({ const selectedApp = appById(effectiveSelectedId); function openTool(id: string) { - setSelectedId(id); + setPersistedSelectedId(id); setDetailOpen(true); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 624214196..d01800254 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1022,16 +1022,6 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; -<<<<<<< Updated upstream -======= - // Compact search views drop the chip row on phones so the pill can sit - // flush with the bottom edge; the same actions stay reachable via the - // integrated "+" menu. - const showFooterSearchChips = - usesFooterChipLayout && - !usesCompactMobileBottomStyle && - !(isDesktopHomeComposer && usesPhoneSearchLayout); ->>>>>>> Stashed changes // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 5d68487d8..6c394b3d5 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const documentId = "11111111-1111-4111-8111-111111111111"; +const managedTestPort = 4298; type QueryError = { message: string }; type QueryResult = { data: unknown; error: QueryError | null; count?: number | null }; @@ -211,6 +212,16 @@ function authenticatedRequest(path: string, init?: RequestInit) { }); } +function managedAuthenticatedRequest(path: string, init?: RequestInit) { + return new Request(`http://localhost:${managedTestPort}${path}`, { + ...init, + headers: { + authorization: "Bearer valid-token", + ...init?.headers, + }, + }); +} + async function payload(response: Response) { return (await response.json()) as Record; } @@ -453,7 +464,7 @@ describe("API validation contracts", () => { const formData = new FormData(); formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); const uploadResponse = await uploadRoute.POST( - authenticatedRequest("/api/upload", { method: "POST", body: formData }), + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData }), ); expect(uploadResponse.status).toBe(500); expect(await payload(uploadResponse)).toEqual({ error: "Request failed." }); @@ -518,7 +529,7 @@ describe("API validation contracts", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); formData.set("title", "x".repeat(181)); - const response = await POST(authenticatedRequest("/api/upload", { method: "POST", body: formData })); + const response = await POST(managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData })); const body = await payload(response); expect(response.status).toBe(400); @@ -535,7 +546,7 @@ describe("API validation contracts", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); formData.set("title", new File(["Guideline"], "title.txt", { type: "text/plain" })); - const response = await POST(authenticatedRequest("/api/upload", { method: "POST", body: formData })); + const response = await POST(managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData })); const body = await payload(response); expect(response.status).toBe(400); @@ -550,7 +561,7 @@ describe("API validation contracts", () => { const { POST } = await import("../src/app/api/upload/route"); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ file: "nope" }), @@ -570,7 +581,7 @@ describe("API validation contracts", () => { const { POST } = await import("../src/app/api/upload/route"); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", headers: { "content-type": "multipart/form-data; boundary=broken" }, body: '--broken\r\nContent-Disposition: form-data; name="file"; filename="guideline.pdf"\r\n\r\n%PDF-1.7', diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 9d91205f7..ae3408e2d 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -6,6 +6,7 @@ const documentId = "11111111-1111-4111-8111-111111111111"; const otherDocumentId = "22222222-2222-4222-8222-222222222222"; const imageId = "33333333-3333-4333-8333-333333333333"; const token = "valid-token"; +const managedTestPort = 4298; type QueryError = { message: string }; type QueryResult = { data: unknown; error: QueryError | null }; @@ -332,6 +333,16 @@ function authenticatedRequest(path: string, init?: RequestInit) { }); } +function managedAuthenticatedRequest(path: string, init?: RequestInit) { + return localPortRequest(managedTestPort, path, { + ...init, + headers: { + authorization: `Bearer ${token}`, + ...init?.headers, + }, + }); +} + function authenticatedCookieRequest(path: string, init?: RequestInit) { return request(path, { ...init, @@ -416,7 +427,10 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -438,7 +452,10 @@ describe("private document API access", () => { }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); + expect(await payload(response)).toMatchObject({ + error: "Use the ensured Clinical KB local URL before calling this API.", + }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -740,7 +757,7 @@ describe("private document API access", () => { formData.set("title", "Guideline"); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -779,7 +796,7 @@ describe("private document API access", () => { formData.set("file", new File(["%PDF-1.7 revised"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -812,7 +829,7 @@ describe("private document API access", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -1897,7 +1914,7 @@ describe("private document API access", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -1924,7 +1941,7 @@ describe("private document API access", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -1967,7 +1984,7 @@ describe("private document API access", () => { formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); const response = await POST( - authenticatedRequest("/api/upload", { + managedAuthenticatedRequest("/api/upload", { method: "POST", body: formData, }), @@ -3003,7 +3020,9 @@ describe("private document API access", () => { })); const client = createSupabaseMock(); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime(client, { searchChunksWithTelemetry }); const { POST } = await import("../src/app/api/search/route"); @@ -3034,13 +3053,20 @@ describe("private document API access", () => { retrieval_strategy: "text_fast_path", }, })); - const client = createSupabaseMock(); + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select") { + return ok([{ id: documentId, metadata: {}, import_batch_id: null }]); + } + return ok([]); + }); client.auth.admin.listUsers.mockResolvedValueOnce({ data: { users: [{ id: userId, email: "clinician@example.test" }], nextPage: 0 }, error: null, }); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime( client, @@ -3195,13 +3221,20 @@ describe("private document API access", () => { citations: [], sources: [], })); - const client = createSupabaseMock(); + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select") { + return ok([{ id: documentId, metadata: {}, import_batch_id: null }]); + } + return ok([]); + }); client.auth.admin.listUsers.mockResolvedValueOnce({ data: { users: [{ id: userId, email: "clinician@example.test" }], nextPage: 0 }, error: null, }); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime(client, { answerQuestionWithScope }, { localNoAuth: true, localOwnerEmail: "clinician@example.test" }); const { POST } = await import("../src/app/api/answer/stream/route"); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 456e6c584..1504a977f 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -280,19 +280,10 @@ test.describe("Clinical KB long-content stress coverage", () => { const actionMenu = page.getByRole("button", { name: "Open answer options" }); await page.keyboard.press("Escape"); -<<<<<<< Updated upstream await actionMenu.click(); const actionsMenu = page.getByTestId("daily-actions-menu"); await expect(actionsMenu).toBeVisible(); await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); -======= - if (await scopeTrigger.isVisible().catch(() => false)) { - await scopeTrigger.click(); - } else { - const dailyActions = await openDailyActions(page); - await dailyActions.getByRole("menuitem", { name: "Scope sources" }).click(); - } ->>>>>>> Stashed changes const scopeContainer = page.getByTestId("scope-command-popover"); await expect(scopeContainer).toBeVisible(); await expect(scopeContainer).toBeVisible(); From eb7573d796802ade778787e03e75652537b9370d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:41:27 +0800 Subject: [PATCH 18/87] fix(db): repair dollar-quote delimiters in retrieval owner sentinel migration --- .../20260705210000_retrieval_owner_filter_sentinel.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql index 64f42ee56..9a973f440 100644 --- a/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql +++ b/supabase/migrations/20260705210000_retrieval_owner_filter_sentinel.sql @@ -7,13 +7,13 @@ language sql immutable parallel safe set search_path = public, pg_temp -as $ +as $$ select case when owner_filter is null then true when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null else row_owner_id = owner_filter end; -$; +$$; create or replace function public.match_document_chunks( query_embedding extensions.vector(1536), From fbb6247d121a4f6b5877a83e5571c098cedabc7e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:42:34 +0800 Subject: [PATCH 19/87] fix(db): align schema.sql retrieval_owner_matches dollar quotes --- supabase/schema.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/schema.sql b/supabase/schema.sql index 15ce06969..357ed9180 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1909,13 +1909,13 @@ language sql immutable parallel safe set search_path = public, pg_temp -as $ +as $$ select case when owner_filter is null then true when owner_filter = '00000000-0000-0000-0000-000000000000'::uuid then row_owner_id is null else row_owner_id = owner_filter end; -$; +$$; create or replace function public.match_document_chunks( query_embedding extensions.vector(1536), From be3f8e586477042240f1ddae1728404846823897 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:46:17 +0800 Subject: [PATCH 20/87] docs(governance): record public and anonymous API access verification --- docs/clinical-governance.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/clinical-governance.md b/docs/clinical-governance.md index e48c31c4a..e63639624 100644 --- a/docs/clinical-governance.md +++ b/docs/clinical-governance.md @@ -46,4 +46,5 @@ Use the `.github/pull_request_template.md` clinical governance section for any c - Supabase unused-index advisor items are a watchlist, not a removal queue. Keep search/RAG support indexes such as document-label, title, chunk, summary, RAG logging, and audit indexes unless production query evidence plus local verification shows they are genuinely dead. - Document organization coverage is an operational invariant: after ingestion or generated-label reclassification, run `npm run check:document-label-coverage` and require zero indexed documents missing generated `site` or `document_type` labels. - **Application-layer cross-owner denial** (service-role routes enforce `owner_id` scoping in code) is covered by `tests/private-access-routes.test.ts` and `tests/private-rag-access.test.ts` (unowned document detail/signed-url/rename rejected; listing and search scoped to the authenticated owner). +- **Public and anonymous read access** (2026-07-05): curated catalog routes (`/api/medications`, `/api/differentials`, `/api/registry/*`) serve static or seeded payloads to callers with no session signal; authenticated callers receive owner-scoped DB reads with separate rate-limit buckets (`medications`, `differentials`, `registry`). Document and image signed-url routes use `withOwnerReadScope` (public rows where `owner_id IS NULL`, plus owned rows when signed in), enforce `document_read` rate limits for anonymous callers, and return 404 for non-`indexed` documents or uncommitted image generations. Upload rejects unsafe local origins with HTTP 409 before Supabase access (`assertSafeLocalProjectRequest`). - **Follow-up:** add a live DB-level RLS integration test that connects as two real authenticated users via the publishable (anon) key and asserts owner B cannot read owner A's rows. This needs a seeded test project/harness and is tracked as a remaining item. From 92368749c235c1732b8dd65760439ef2b5bdd833 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:49:36 +0800 Subject: [PATCH 21/87] fix: unblock production anonymous search and setup-status project warning --- src/app/api/answer/route.ts | 4 +- src/app/api/answer/stream/route.ts | 4 +- src/app/api/search/route.ts | 4 +- src/app/api/setup-status/route.ts | 8 +- src/components/ClinicalDashboard.tsx | 499 ++---------------- .../document-search-results.tsx | 9 +- .../medication-prescribing-workspace.tsx | 9 +- src/lib/api-rate-limit.ts | 19 +- src/lib/deployed-app.ts | 3 + src/lib/env.ts | 10 + tests/api-rate-limit-fallback.test.ts | 23 + tests/setup-status-route.test.ts | 43 ++ 12 files changed, 168 insertions(+), 467 deletions(-) create mode 100644 src/lib/deployed-app.ts create mode 100644 tests/api-rate-limit-fallback.test.ts diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 8d4f3b2a9..0084e60cd 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,7 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; @@ -70,7 +70,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 4741c2287..ef4fb0e54 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; @@ -232,7 +232,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) return rateLimitStream(rateLimit); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index a0474e14d..ea99d7aa3 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,7 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; @@ -893,7 +893,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "search", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse( diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index ba34855b8..081568601 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -51,6 +51,10 @@ function check(id: SetupCheckId, label: string, status: SetupCheckStatus, detail return { id, label, status, detail }; } +function projectSetupCheckStatus(status: ReturnType["status"]) { + return status === "ready" || status === "warning" ? "ready" : "needs_setup"; +} + async function readSupabaseAvailability(supabase: AdminClient | null) { if (!requiredSupabaseEnvPresent || !supabaseProjectCanBeQueried || !supabase) return null; const now = Date.now(); @@ -296,7 +300,7 @@ async function buildSetupStatusPayload(): Promise { check( "project", "Clinical KB Database target", - supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup", + projectSetupCheckStatus(supabaseProjectCheck.status), formatSupabaseProjectCheck(supabaseProjectCheck), ), check( @@ -353,7 +357,7 @@ async function buildSetupStatusPayload(): Promise { check( "project", "Clinical KB Database target", - supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup", + projectSetupCheckStatus(supabaseProjectCheck.status), formatSupabaseProjectCheck(supabaseProjectCheck), ), schema, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..aa6c1cb2d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,38 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +96,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +145,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +183,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +205,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +426,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} - - {!hasStructuredTable && item.tableTextSnippet ? ( -

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return ( -
- {content} -
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. @@ -2174,7 +1752,11 @@ export function ClinicalDashboard({ process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis; + const canUploadDocuments = + canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; + const canRunSearch = + explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); @@ -2355,20 +1937,25 @@ export function ClinicalDashboard({ const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); if (!setupResponse) { - setApiUnavailable(true); - setSetupWarning("The local API is unavailable."); - return; - } - - if (setupResponse.ok) { + if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); + } else { + setApiUnavailable(true); + setSetupWarning("The local API is unavailable."); + return; + } + } else if (setupResponse.ok) { const payload = (await setupResponse.json()) as SetupStatusPayload; setSetupChecks(payload.checks ?? fallbackSetupChecks); nextDemoMode = Boolean(payload.demoMode); routeIndexingActive = Boolean(payload.indexingActive); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs); if (nextDemoMode) setDemoMode(true); + } else if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); } else { setApiUnavailable(true); + return; } } @@ -2922,11 +2509,13 @@ export function ClinicalDashboard({ function searchNetworkFailure(label: string) { const offline = typeof navigator !== "undefined" && !navigator.onLine; - const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server"; + const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB"; return makeSearchError( offline ? `${label} could not run because the browser is offline.` - : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, + : isDeployedClinicalKb() + ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.` + : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, undefined, true, ); @@ -4005,21 +3594,21 @@ export function ClinicalDashboard({

); - const showAuthPanel = !clientDemoMode && !canUsePrivateApis; - const showDegradedNotice = !isOnline || apiUnavailable; + const showAuthPanel = false; + const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = - !loading && !error && - ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || - (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || - (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + (activeModeResultKind === "tools" || activeModeResultKind === "favourites" || - activeModeResultKind === "tools"); + (!loading && + ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || + (searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || + (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || + (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. @@ -4044,14 +3633,18 @@ export function ClinicalDashboard({ summary={ !isOnline ? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access." - : "The local API did not respond. Check the app server and setup status before retrying." + : isDeployedClinicalKb() + ? "The app could not reach its API. Try again in a moment." + : "The local API did not respond. Check the app server and setup status before retrying." } mobileSummary={!isOnline ? "Offline" : "API unavailable"} >

{!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."} + : isDeployedClinicalKb() + ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." + : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

); @@ -4079,7 +3672,7 @@ export function ClinicalDashboard({ { id: "upload", label: "Upload", - summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready", + summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready", panelId: "dashboard-upload-section", icon: UploadCloud, }, @@ -4659,7 +4252,7 @@ export function ClinicalDashboard({ diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 5efffa0ad..d14f25ff1 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -24,6 +24,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -726,7 +727,7 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; status === "loading" ? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` } : status === "unauthorized" - ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` } + ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` } : { Icon: ShieldAlert, spin: false, @@ -834,9 +835,11 @@ export function DocumentSearchResultsPanel({ } const unavailableMessage = apiUnavailable - ? "The local API is unavailable. Check the app server before searching documents." + ? isDeployedClinicalKb() + ? "Clinical KB could not be reached. Check your connection and try again shortly." + : "The local API is unavailable. Check the app server before searching documents." : authUnavailable - ? "Sign in or enable local no-auth mode before listing private indexed documents." + ? "Your session expired. Sign in again to view private indexed documents." : !realDataReady ? setupWarning || "Complete the search setup before using Documents mode." : null; diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 05596db09..597d4d459 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search- import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { @@ -414,9 +415,13 @@ function StatusNotice({ }: Pick) { if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null; const message = authUnavailable - ? "Private medication search is waiting for sign-in." + ? isDeployedClinicalKb() + ? "Sign in to search your private medication library." + : "Private medication search is waiting for sign-in." : apiUnavailable - ? "Medication search is using the local mockup while the API is unavailable." + ? isDeployedClinicalKb() + ? "Medication search is temporarily unavailable. Try again shortly." + : "Medication search is using the local mockup while the API is unavailable." : setupWarning || "Medication search setup is still warming up."; return ( diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 2a19a21ba..593cd9cfb 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -1,10 +1,23 @@ import { NextResponse } from "next/server"; +import { isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; import type { RateLimitSubject } from "@/lib/public-api-access"; import type { createAdminClient } from "@/lib/supabase/admin"; +/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */ +export function allowRateLimitInMemoryFallbackOnUnavailable() { + return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; +} + export type ApiRateLimitBucket = - "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + | "answer" + | "search" + | "document_read" + | "document_upload" + | "document_summarize" + | "document_reindex" + | "bulk_reindex" + | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -17,6 +30,8 @@ export type ApiRateLimitResult = { const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, + document_read: { limit: 180, windowSeconds: 60 }, + document_upload: { limit: 12, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -26,6 +41,8 @@ const apiRateLimitDefaults = { const anonymousApiRateLimitDefaults: Partial> = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, + document_upload: { limit: 3, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts new file mode 100644 index 000000000..82bb8f3a7 --- /dev/null +++ b/src/lib/deployed-app.ts @@ -0,0 +1,3 @@ +export function isDeployedClinicalKb() { + return process.env.NODE_ENV === "production"; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ac5d8dbf5..f6acba54f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -11,6 +11,8 @@ const envSchema = z.object({ LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), LOCAL_NO_AUTH_OWNER_ID: z.string().optional(), + PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), // Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding @@ -192,3 +194,11 @@ export function isLocalNoAuthMode() { return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth); } + +export function publicWorkspaceOwnerId() { + return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null; +} + +export function publicUploadsEnabled() { + return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts new file mode 100644 index 000000000..c25bb36c5 --- /dev/null +++ b/tests/api-rate-limit-fallback.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("allowRateLimitInMemoryFallbackOnUnavailable", () => { + it("enables fallback for production deployments", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); + + it("enables fallback for local no-auth development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => true, + })); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); +}); diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index f952b2ef8..9a3a99f87 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -52,4 +52,47 @@ describe("/api/setup-status", () => { expect(JSON.stringify(body)).not.toContain("service-role-key"); expect(JSON.stringify(body)).not.toContain("openai-key"); }); + + it("treats project warning status as ready when the URL ref matches", async () => { + vi.stubEnv("NODE_ENV", "production"); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ + from, + rpc: vi.fn(), + })); + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + SUPABASE_DOCUMENT_BUCKET: "clinical-documents", + SUPABASE_IMAGE_BUCKET: "clinical-images", + WORKER_POLL_MS: 1500, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ + status: "warning", + detail: 'Set SUPABASE_PROJECT_NAME="Clinical KB Database" in .env.local.', + }), + formatSupabaseProjectCheck: () => 'Set SUPABASE_PROJECT_NAME="Clinical KB Database" in .env.local.', + })); + const { GET } = await import("../src/app/api/setup-status/route"); + + const response = await GET(new Request("https://clinical.example/api/setup-status")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "project", status: "ready" })]), + ); + }); }); From b3cbb48b67dfb158b2892ac5b9f459cb4c5c1ab4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:53:06 +0800 Subject: [PATCH 22/87] style: format access rollout files for CI --- scripts/commit-access-rag-fix.mjs | 26 ++++++++++--- src/app/api/answer/route.ts | 6 ++- src/app/api/answer/stream/route.ts | 6 ++- src/app/api/differentials/[slug]/route.ts | 6 ++- .../presentations/[slug]/route.ts | 6 ++- src/app/api/differentials/route.ts | 6 ++- src/app/api/documents/route.ts | 13 +------ src/app/api/medications/[slug]/route.ts | 6 ++- src/app/api/medications/route.ts | 6 ++- src/app/api/registry/records/[slug]/route.ts | 6 ++- src/app/api/registry/records/route.ts | 6 ++- src/app/api/search/route.ts | 6 ++- src/app/api/upload/route.ts | 12 +++++- src/components/ClinicalDashboard.tsx | 38 ++++--------------- src/components/DocumentViewer.tsx | 4 +- .../DocumentManagerPanel.tsx | 4 +- .../document-search-results.tsx | 7 +++- src/lib/public-api-access.ts | 6 ++- src/lib/rag.ts | 14 +++---- src/lib/supabase/auth.ts | 5 +-- tests/private-access-routes.test.ts | 3 +- tests/public-access-deep.test.ts | 1 - 22 files changed, 112 insertions(+), 81 deletions(-) diff --git a/scripts/commit-access-rag-fix.mjs b/scripts/commit-access-rag-fix.mjs index 72a78c7b0..1852d7e22 100644 --- a/scripts/commit-access-rag-fix.mjs +++ b/scripts/commit-access-rag-fix.mjs @@ -93,7 +93,11 @@ rag = rag.replace( for (const fn of ["searchTableFactCandidates", "searchEmbeddingFieldCandidates", "searchIndexUnitCandidates"]) { rag = rag.replace( new RegExp(`async function ${fn}\\([\\s\\S]*?documentIds\\?: string\\[\\];\\n matchCount: number;`), - (m) => m.replace("documentIds?: string[];\n matchCount: number;", "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;"), + (m) => + m.replace( + "documentIds?: string[];\n matchCount: number;", + "documentIds?: string[];\n allowGlobalSearch?: boolean;\n matchCount: number;", + ), ); } if (!rag.includes('else documentQuery = documentQuery.is("owner_id", null);')) { @@ -156,10 +160,22 @@ $$; create or replace function public.match_document_chunks(`, ); } -schema = schema.replaceAll("(owner_filter is null or d.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, d.owner_id)"); -schema = schema.replaceAll("(owner_filter is null or l.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, l.owner_id)"); -schema = schema.replaceAll("(owner_filter is null or s.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, s.owner_id)"); -schema = schema.replaceAll("(owner_filter is null or f.owner_id = owner_filter)", "public.retrieval_owner_matches(owner_filter, f.owner_id)"); +schema = schema.replaceAll( + "(owner_filter is null or d.owner_id = owner_filter)", + "public.retrieval_owner_matches(owner_filter, d.owner_id)", +); +schema = schema.replaceAll( + "(owner_filter is null or l.owner_id = owner_filter)", + "public.retrieval_owner_matches(owner_filter, l.owner_id)", +); +schema = schema.replaceAll( + "(owner_filter is null or s.owner_id = owner_filter)", + "public.retrieval_owner_matches(owner_filter, s.owner_id)", +); +schema = schema.replaceAll( + "(owner_filter is null or f.owner_id = owner_filter)", + "public.retrieval_owner_matches(owner_filter, f.owner_id)", +); fs.writeFileSync("supabase/schema.sql", schema); const names = [ diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 0084e60cd..55baf040b 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,11 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index ef4fb0e54..b8509d102 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + type ApiRateLimitResult, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index 08b67744e..abe35bbb2 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 2c991a6c7..85574d8fe 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index b64d4e395..2b467a47f 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, rowGovernance, diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index a9abec8cf..eb6640817 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -75,13 +75,7 @@ const LABEL_LIST_COLUMNS = [ "updated_at", ].join(","); -const PUBLIC_SUMMARY_LIST_COLUMNS = [ - "id", - "document_id", - "summary", - "clinical_specifics", - "generated_at", -].join(","); +const PUBLIC_SUMMARY_LIST_COLUMNS = ["id", "document_id", "summary", "clinical_specifics", "generated_at"].join(","); const SUMMARY_LIST_COLUMNS = [ "id", @@ -176,10 +170,7 @@ export async function GET(request: Request) { const effectiveIncludeMeta = access.authenticated ? includeMeta : false; const listColumns = access.authenticated ? DOCUMENT_LIST_COLUMNS : PUBLIC_DOCUMENT_LIST_COLUMNS; - let query = withOwnerReadScope( - supabase.from("documents").select(listColumns, { count: "exact" }), - access.ownerId, - ) + let query = withOwnerReadScope(supabase.from("documents").select(listColumns, { count: "exact" }), access.ownerId) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 80294619b..83ba38e40 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 32985604d..88aff92a3 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { defaultMedicationRecords, ensureMedicationsSeeded } from "@/lib/medication-seed"; diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index c32ac3684..79eda031e 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index e29d7c15d..373ec658a 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index ea99d7aa3..be077175c 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,11 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index c6845d7f7..32016e79f 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -4,13 +4,21 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; -import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; -import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard"; +import { + assertSafeLocalProjectRequest, + localProjectOriginErrorResponse, + UnsafeLocalProjectOriginError, +} from "@/lib/local-project-guard"; import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index aa6c1cb2d..3d24dfe56 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -40,15 +40,7 @@ import { Wrench, X, } from "lucide-react"; -import { - type CSSProperties, - type FormEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { extractSafetyFindings } from "@/lib/clinical-safety"; @@ -93,15 +85,8 @@ import { type SetupCheck, type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { - GuideDialog, - GuideTrigger, - UtilityDrawer, -} from "@/components/clinical-dashboard/dashboard-shell"; -import { - sanitizeAnswerDisplayText, - sanitizeDisplayText, -} from "@/components/clinical-dashboard/display-text"; +import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; +import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, @@ -189,10 +174,7 @@ import { groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { - type SmartDocumentTag, - type SmartDocumentTagFacet, -} from "@/lib/document-tags"; +import { type SmartDocumentTag, type SmartDocumentTagFacet } from "@/lib/document-tags"; import type { ClinicalDocument, DocumentMatch, @@ -210,11 +192,7 @@ import type { } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { - createQuoteFollowUp, - type AnswerViewMode, - shouldPollForUpdates, -} from "@/lib/ward-output"; +import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = @@ -1752,8 +1730,7 @@ export function ClinicalDashboard({ process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canUploadDocuments = - canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const canUploadDocuments = canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; @@ -2992,7 +2969,7 @@ export function ClinicalDashboard({ sourceChunkIds, citedChunkIds, sourceFiles, - sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message), + sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning), unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [], }), }); @@ -3227,6 +3204,7 @@ export function ClinicalDashboard({ } function startNewChat() { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; const href = appModeHomeHref("answer", { focus: true }); setQuery(""); diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 10533849e..630790c43 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2267,9 +2267,7 @@ export function DocumentViewer({ ? viewerError : null; const effectiveLoadingDocument = - !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument - ? true - : loadingDocument; + !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument ? true : loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 35062b110..381f0da09 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -189,9 +189,7 @@ export function UploadPanel({ } if (!canUpload) { changeStatus( - demoMode - ? demoUploadReadOnlyMessage - : "Uploads are unavailable until this public workspace is configured.", + demoMode ? demoUploadReadOnlyMessage : "Uploads are unavailable until this public workspace is configured.", ); return; } diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index d14f25ff1..8398bc679 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -727,7 +727,12 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; status === "loading" ? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` } : status === "unauthorized" - ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` } + ? { + Icon: Shield, + spin: false, + tone: "warning" as const, + text: `Your session expired. Sign in again to search your private ${noun} registry.`, + } : { Icon: ShieldAlert, spin: false, diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 04a9da0ba..4de54ea73 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import type { createAdminClient } from "@/lib/supabase/admin"; -import { consumeSubjectApiRateLimit, allowRateLimitInMemoryFallbackOnUnavailable, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { + consumeSubjectApiRateLimit, + allowRateLimitInMemoryFallbackOnUnavailable, + type ApiRateLimitResult, +} from "@/lib/api-rate-limit"; import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; type AdminClient = ReturnType; diff --git a/src/lib/rag.ts b/src/lib/rag.ts index eb3348003..e0afa8b45 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1441,14 +1441,7 @@ function stableHash(value: string) { export function retrievalPlanCacheQuery( args: Pick< SearchChunksArgs, - | "query" - | "documentId" - | "documentIds" - | "ownerId" - | "queryMode" - | "topK" - | "minSimilarity" - | "forceEmbedding" + "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" >, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -5488,7 +5481,10 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if ( + !args.forceEmbedding && + shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions) + ) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 3ecf6122c..8f20205c9 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -63,10 +63,7 @@ function extractSessionAccessToken(request: Request): string | null { return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request); } -async function getUserFromAccessToken( - supabase: AdminClient, - token: string, -): Promise { +async function getUserFromAccessToken(supabase: AdminClient, token: string): Promise { const { data, error } = await supabase.auth.getUser(token); if (error || !data.user?.id) return null; return { id: data.user.id }; diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index b6476fc12..513ca7ad9 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -892,7 +892,8 @@ describe("private document API access", () => { const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string }; return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path }); } - if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1", document_id: documentId }); + if (call.table === "ingestion_jobs" && call.operation === "insert") + return ok({ id: "job-1", document_id: documentId }); return ok([]); }); mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts index f560abe4e..a5ad9ea60 100644 --- a/tests/public-access-deep.test.ts +++ b/tests/public-access-deep.test.ts @@ -111,7 +111,6 @@ describe("public access deep checks", () => { expect(body.checks.supabaseConfig).toBe("ok"); expect(body.checks.openaiConfig).toBe("ok"); }); - }); describe("production anonymous retrieval scope", () => { From dfff84285340e05dc9a30396ac36aa292c303f2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 15:55:15 +0000 Subject: [PATCH 23/87] Polish answer suggestion chips across empty home, mobile composer, and modes Unify empty-state quick actions with compact chips, move mobile follow-ups into the footer composer, apply example chips to all search modes, add chip truncation, update mockups/tests, and trim unused ClinicalDashboard imports. Co-authored-by: BigSimmo --- src/app/globals.css | 9 +- src/components/ClinicalDashboard.tsx | 50 +---------- .../answer-follow-up-suggestions.tsx | 7 +- .../answer-result-surface.tsx | 12 +-- .../clinical-dashboard/answer-status.tsx | 54 +++++++----- .../answer-suggestion-chips.tsx | 10 +-- .../master-search-header.tsx | 19 +++++ .../universal-search-command-surface.tsx | 85 ++----------------- .../universal-search-command-mockups.tsx | 72 ++-------------- src/lib/ui-copy.ts | 1 + tests/ui-smoke.spec.ts | 16 +++- 11 files changed, 106 insertions(+), 229 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 4c930f85a..72e88fa8a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1276,10 +1276,11 @@ summary::-webkit-details-marker { .answer-suggestion-chip { display: inline-flex; - max-width: 100%; + max-width: min(100%, 18rem); min-height: 1.75rem; flex-shrink: 0; align-items: center; + overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--surface) 92%, transparent); @@ -1289,6 +1290,8 @@ summary::-webkit-details-marker { font-weight: 600; line-height: 1.25rem; text-align: left; + text-overflow: ellipsis; + white-space: nowrap; transition: border-color 160ms ease, background-color 160ms ease, @@ -1309,6 +1312,10 @@ summary::-webkit-details-marker { padding-inline: 0.25rem; } +.answer-footer-search-edge .answer-suggestion-row-composer-followups { + margin-bottom: -0.125rem; +} + @media (min-width: 640px) { .answer-suggestion-chip { font-size: 0.75rem; diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..bbab35dbe 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -12,7 +12,6 @@ import { ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -29,7 +28,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,7 +46,6 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, @@ -56,12 +53,6 @@ import { useState, } from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { formatCompactCitationLabel } from "@/lib/citations"; @@ -75,39 +66,25 @@ import { clinicalDivider, cn, EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, iconTilePremium, metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -145,22 +122,10 @@ import { } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, type EvidenceTabName, simpleClinicalTableProps, evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, QuoteCards, - SafetyFindingsListContent, } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; @@ -245,11 +210,8 @@ import { } from "@/lib/source-governance"; import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -266,7 +228,6 @@ import type { VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -645,14 +606,6 @@ function VisualEvidenceStrip({ ); } -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - function supportDotClass(supportLevel: string) { const normalized = supportLevel.toLowerCase(); if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; @@ -4217,6 +4170,9 @@ export function ClinicalDashboard({ void ask(); }} onCrossModeSearch={crossModeSearch} + composerFollowUpSuggestions={searchMode === "answer" ? answerFollowUpSuggestions : undefined} + onPickComposerFollowUpSuggestion={handlePickFollowUpSuggestion} + composerFollowUpSuggestionsDisabled={loading} composerPlaceholder={searchMode === "answer" && latestAnswerQuery ? "Ask a follow-up..." : undefined} mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"} mobileBottomSearchVariant={compactMobileBottomSearch ? "compact" : "default"} diff --git a/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx index eeb2a05de..19023b535 100644 --- a/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx +++ b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx @@ -6,10 +6,14 @@ export function AnswerFollowUpSuggestions({ suggestions, onPick, disabled = false, + className, + testId = "answer-follow-up-suggestions", }: { suggestions: string[]; onPick: (suggestion: string) => void; disabled?: boolean; + className?: string; + testId?: string; }) { return ( ); } diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 581e10c6d..1f06d7c1a 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -230,11 +230,13 @@ export function StagedAnswerResultSurface({ ) : null} {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( - +
+ +
) : null} diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index eff789b2e..00c30ef41 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -1,7 +1,8 @@ "use client"; -import { Clipboard, ClipboardCheck, MessageSquareText, Search, ShieldCheck, Sparkles, UploadCloud } from "lucide-react"; +import { Clipboard, ClipboardCheck, MessageSquareText, ShieldCheck } from "lucide-react"; +import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, floatingControl, sourceCard } from "@/components/ui-primitives"; import { answerEmptyState, answerLoading, copyButton } from "@/lib/ui-copy"; @@ -34,7 +35,7 @@ export function CopyButton({ } export function AnswerEmptyState({ - onPickSample, + onPickSample: _onPickSample, onSearchDocuments, onUploadDocument, desktopComposerSlotId, @@ -44,6 +45,21 @@ export function AnswerEmptyState({ onUploadDocument: () => void; desktopComposerSlotId?: string; }) { + const quickActions = [ + answerEmptyState.starters.searchDocuments.title, + answerEmptyState.starters.uploadDocument.title, + ]; + + function handleQuickAction(action: string) { + if (action === answerEmptyState.starters.searchDocuments.title) { + onSearchDocuments(); + return; + } + if (action === answerEmptyState.starters.uploadDocument.title) { + onUploadDocument(); + } + } + return ( onPickSample(answerEmptyState.starters.ask.samplePrompt), - }, - { - title: answerEmptyState.starters.searchDocuments.title, - description: answerEmptyState.starters.searchDocuments.description, - icon: Search, - onClick: onSearchDocuments, - }, - { - title: answerEmptyState.starters.uploadDocument.title, - description: answerEmptyState.starters.uploadDocument.description, - icon: UploadCloud, - onClick: onUploadDocument, - }, - ]} - footer={} + actions={[]} + footer={ +
+ + +
+ } /> ); } diff --git a/src/components/clinical-dashboard/answer-suggestion-chips.tsx b/src/components/clinical-dashboard/answer-suggestion-chips.tsx index ac6c316a7..3db85a05e 100644 --- a/src/components/clinical-dashboard/answer-suggestion-chips.tsx +++ b/src/components/clinical-dashboard/answer-suggestion-chips.tsx @@ -33,18 +33,14 @@ export function AnswerSuggestionChips({ className, )} > - {label ? ( - - ) : null} + {label ? {label} : null}
{suggestions.map((suggestion) => ( - - - Press - - / - - to search - -
+ ); } diff --git a/src/components/universal-search-command-mockups.tsx b/src/components/universal-search-command-mockups.tsx index f5bb6fc46..0fc2a765c 100644 --- a/src/components/universal-search-command-mockups.tsx +++ b/src/components/universal-search-command-mockups.tsx @@ -22,7 +22,7 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { chatComposerIconButton, @@ -31,6 +31,7 @@ import { chatSendButton, cn, } from "@/components/ui-primitives"; +import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -492,73 +493,18 @@ function matchesScopes(row: MatchRow, activeScopes: string[]) { return activeScopes.every((scope) => row.scopes.includes(scope)); } -function subscribeReducedMotion(onChange: () => void) { - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); - media.addEventListener("change", onChange); - return () => media.removeEventListener("change", onChange); -} - -function usePrefersReducedMotion() { - return useSyncExternalStore( - subscribeReducedMotion, - () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, - () => false, - ); -} - /* ------------------------------------------------------------------ */ -/* Layer 1 — context hint row with rotating examples */ +/* Layer 1 — context hint row with compact example chips */ /* ------------------------------------------------------------------ */ function ContextHintRow({ mode, onPickExample }: { mode: ModeConfig; onPickExample: (example: string) => void }) { - const reducedMotion = usePrefersReducedMotion(); - const [index, setIndex] = useState(0); - - // The demo remounts per mode (key={mode.id}), so index starts at 0 for each mode. - useEffect(() => { - const timer = window.setInterval(() => { - setIndex((current) => (current + 1) % mode.examples.length); - }, 4500); - return () => window.clearInterval(timer); - }, [mode]); - - const example = mode.examples[index % mode.examples.length]; - const ModeIcon = mode.icon; - return ( -
- - - - - Searching {mode.label.toLowerCase()} - - - - Try: - - - - Press - - / - - to search - - -
+ ); } diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index a84215cfc..dc90097a1 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -16,6 +16,7 @@ export const answerEmptyState = { heading: "How can I help?", subheading: "Ask a clinical question or search your documents.", starterActionsLabel: "Starter actions", + quickActionsLabel: "Quick actions", starters: { ask: { title: "Ask a question", diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3d2bae636..d96af31b2 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -58,6 +58,14 @@ function visibleAnswerSubmitButton(page: Page) { return page.locator('[aria-label="Generate source-backed answer"]:visible').first(); } +function visibleAnswerFollowUpSuggestions(page: Page) { + return page + .locator( + '[data-testid="answer-follow-up-suggestions"]:visible, [data-testid="answer-composer-follow-up-suggestions"]:visible', + ) + .first(); +} + async function isVisibleWithoutThrow(locator: Locator) { return locator.isVisible().catch(() => false); } @@ -683,7 +691,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("scope-command-popover")).toBeHidden(); await expect(page.getByTestId("scope-prompts-drawer")).toHaveCount(0); await expect(page.getByTestId("mobile-scope-popover")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Ask a question" })).toBeVisible(); + await expect(page.getByRole("button", { name: "lithium level timing" })).toBeVisible(); await expect(page.getByRole("button", { name: "Search documents" })).toBeVisible(); await expect(page.getByRole("button", { name: "Upload document" })).toBeVisible(); await expectDomIntegrity(page, { mobileNav: viewport.width <= 768 }); @@ -1242,7 +1250,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("plain-answer-response")).toHaveCount(1, { timeout: uiAssertionTimeoutMs }); await expect(page.getByTestId("user-question-bubble")).toHaveCount(1); await expect(page.getByTestId("user-question-bubble").first()).toContainText(firstQuestion); - await expect(page.getByTestId("answer-follow-up-suggestions")).toBeVisible(); + await expect(visibleAnswerFollowUpSuggestions(page)).toBeVisible(); const composer = visibleQuestionInput(page); await expect(composer).toHaveValue(""); @@ -1280,9 +1288,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await fillVisibleQuestionInput(page, "lithium dosing"); await visibleAnswerSubmitButton(page).click(); - await expect(page.getByTestId("answer-follow-up-suggestions")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + await expect(visibleAnswerFollowUpSuggestions(page)).toBeVisible({ timeout: uiAssertionTimeoutMs }); - const suggestion = page.getByTestId("answer-follow-up-suggestions").getByRole("button").first(); + const suggestion = visibleAnswerFollowUpSuggestions(page).getByRole("button").first(); const suggestionText = (await suggestion.textContent())?.trim(); expect(suggestionText).toBeTruthy(); await suggestion.click(); From b5380d89461ab9c40ba987a053c050d8ea378b77 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:12:27 +0800 Subject: [PATCH 24/87] checkpoint before checking out cursor/add-supabase-plugin-6f14 --- .worktrees/access-rollout | 1 + .worktrees/merge-verify | 1 + .worktrees/production-search-unblock-c40b | 1 + docs/source-review-priority-2026-07-02.md | 2 +- scripts/apply-governance-dashboard.mjs | 135 +++++++++++++++++ scripts/capture-support-chip-screenshots.ts | 137 ++++++++++++++++++ src/app/api/documents/bulk/route.ts | 4 +- src/components/ClinicalDashboard.tsx | 48 ++++-- src/components/DocumentViewer.tsx | 28 +--- .../clinical-dashboard/answer-content.tsx | 77 +++++++--- .../document-search-results.tsx | 9 +- .../source-preview-popover.tsx | 54 +++++++ .../source-review-queue-panel.tsx | 61 ++++++++ src/components/forms/form-detail-page.tsx | 1 + .../services/services-navigator-page.tsx | 2 +- src/components/ui-primitives.tsx | 13 +- src/lib/answer-render-policy.ts | 12 +- src/lib/search-request-token.ts | 13 ++ src/lib/source-governance.ts | 6 + src/lib/source-metadata.ts | 18 +++ tests/answer-render-policy.test.ts | 19 +++ tests/source-governance.test.ts | 9 +- 22 files changed, 577 insertions(+), 74 deletions(-) create mode 160000 .worktrees/access-rollout create mode 160000 .worktrees/merge-verify create mode 160000 .worktrees/production-search-unblock-c40b create mode 100644 scripts/apply-governance-dashboard.mjs create mode 100644 scripts/capture-support-chip-screenshots.ts create mode 100644 src/components/clinical-dashboard/source-preview-popover.tsx create mode 100644 src/components/clinical-dashboard/source-review-queue-panel.tsx create mode 100644 src/lib/search-request-token.ts diff --git a/.worktrees/access-rollout b/.worktrees/access-rollout new file mode 160000 index 000000000..dbd76ca88 --- /dev/null +++ b/.worktrees/access-rollout @@ -0,0 +1 @@ +Subproject commit dbd76ca88db9167754dc7f8baad83dc793889f81 diff --git a/.worktrees/merge-verify b/.worktrees/merge-verify new file mode 160000 index 000000000..c69302f07 --- /dev/null +++ b/.worktrees/merge-verify @@ -0,0 +1 @@ +Subproject commit c69302f07e9bf9441a4886e4038d98a9d6289de9 diff --git a/.worktrees/production-search-unblock-c40b b/.worktrees/production-search-unblock-c40b new file mode 160000 index 000000000..92368749c --- /dev/null +++ b/.worktrees/production-search-unblock-c40b @@ -0,0 +1 @@ +Subproject commit 92368749c235c1732b8dd65760439ef2b5bdd833 diff --git a/docs/source-review-priority-2026-07-02.md b/docs/source-review-priority-2026-07-02.md index 4c17926e3..29f73d510 100644 --- a/docs/source-review-priority-2026-07-02.md +++ b/docs/source-review-priority-2026-07-02.md @@ -8,7 +8,7 @@ ceiling 0.2) to 0.5398 in the afternoon run. The jump is not corpus decay: the r review-flagged sources are no longer buried and the metric now reports the true corpus state surfacing in golden-case top results. The bounded debt acceptance in `docs/release-source-metadata-debt-2026-06-30.json` was re-accepted at a 0.6 ceiling (expiry unchanged, -2026-07-31) on the condition that the documents below are clinically reviewed first. +2026-08-31) on the condition that the documents below are clinically reviewed first. Corpus context (live DB, 2026-07-02): 2,065 indexed documents — 1,397 current/locally_reviewed, 481 `review_due`, 132 `unknown` status, 130 `unverified` validation, 0 outdated, 0 poor-extraction. diff --git a/scripts/apply-governance-dashboard.mjs b/scripts/apply-governance-dashboard.mjs new file mode 100644 index 000000000..0957e5dc5 --- /dev/null +++ b/scripts/apply-governance-dashboard.mjs @@ -0,0 +1,135 @@ +import fs from "node:fs"; + +const path = "src/components/ClinicalDashboard.tsx"; +let s = fs.readFileSync(path, "utf8"); + +if (!s.includes("SourceReviewQueuePanel")) { + s = s.replace( + 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";', + 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";\nimport { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel";', + ); +} + +if (!s.includes("search-request-token")) { + s = s.replace( + `import { + frontendSourceGovernanceWarnings, + groupSourceGovernanceWarnings, + type SourceGovernanceWarning, +} from "@/lib/source-governance";`, + `import { + frontendSourceGovernanceWarnings, + groupSourceGovernanceWarnings, + serializeSourceGovernanceWarning, + type SourceGovernanceWarning, +} from "@/lib/source-governance"; +import { + invalidateSearchRequests, + isLatestSearchRequest, + type SearchRequestToken, +} from "@/lib/search-request-token";`, + ); +} + +s = s.replace( + "const searchRequestSeqRef = useRef(0);", + `const searchRequestSeqRef = useRef(0); + + function invalidateInFlightSearchRequests() { + searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current); + }`, +); + +s = s.replace( + "const requestId = ++searchRequestSeqRef.current;", + `const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId;`, +); + +s = s.replace( + /if \(requestId === searchRequestSeqRef\.current\) setAnswerProgress\(message\);/g, + "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message);", +); + +s = s.replace( + /if \(requestId === searchRequestSeqRef\.current\) \{/g, + "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {", +); + +s = s.replace( + "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n modeChangeFromUiRef.current = true;", + "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "function selectSearchMode(mode: AppModeId) {\n modeChangeFromUiRef.current = true;", + "function selectSearchMode(mode: AppModeId) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "function startNewChat() {\n modeChangeFromUiRef.current = true;", + "function startNewChat() {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message),", + "sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning),", +); + +s = s.replace( + `")) { + s = s.replace( + ` /> + + + `, + ` /> + + + + `, + ); +} + +const shortcutIdx = s.indexOf("async function runDocumentSearchShortcut"); +if (shortcutIdx !== -1) { + const canRunIdx = s.indexOf(" if (!canRunSearch) {", shortcutIdx); + const fnEnd = s.indexOf("\n function handleTagSearch", canRunIdx); + const block = s.slice(canRunIdx, fnEnd); + if (!block.includes("invalidateInFlightSearchRequests()")) { + const newBlock = block + .replace( + " setQuery(trimmedSearchText);", + " invalidateInFlightSearchRequests();\n setQuery(trimmedSearchText);", + ) + .replace( + " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n try {", + " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n const requestId = invalidateSearchRequests(searchRequestSeqRef.current);\n searchRequestSeqRef.current = requestId;\n\n try {", + ) + .replace( + " applySearchResult(payload);", + " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n applySearchResult(payload);\n }", + ) + .replace( + ' setError(requestError instanceof Error ? requestError.message : "Document search failed");', + ' if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setError(requestError instanceof Error ? requestError.message : "Document search failed");\n }', + ) + .replace( + " setLoading(false);\n setAnswerProgress(null);", + " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setLoading(false);\n setAnswerProgress(null);\n }", + ); + s = s.slice(0, canRunIdx) + newBlock + s.slice(fnEnd); + } +} + +fs.writeFileSync(path, s); +console.log("ClinicalDashboard governance edits applied"); diff --git a/scripts/capture-support-chip-screenshots.ts b/scripts/capture-support-chip-screenshots.ts new file mode 100644 index 000000000..2b8247c9b --- /dev/null +++ b/scripts/capture-support-chip-screenshots.ts @@ -0,0 +1,137 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; + +import { getPlaywrightBaseUrl } from "./playwright-base-url"; +import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; + +const outputDir = process.env.SCREENSHOT_DIR ?? join(process.cwd(), "artifacts", "screenshots"); +mkdirSync(outputDir, { recursive: true }); + +const question = "What clozapine monitoring items are shown in the table image?"; + +const readySetupChecks = [ + { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, + { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." }, + { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." }, + { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search schema ready." }, + { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." }, + { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required for UI smoke." }, +]; + +function answerStreamBody(payload: unknown) { + return [ + `event: progress\ndata: ${JSON.stringify({ stage: "retrieving", message: "Searching indexed documents." })}`, + `event: final\ndata: ${JSON.stringify(payload)}`, + "", + ].join("\n\n"); +} + +async function mockDemoApi(page: import("playwright-core").Page, baseUrl: string) { + await page.route(/\/api\/local-project-id$/, async (route) => { + await route.fulfill({ + json: { + appName: "Clinical KB", + projectId: "test-project", + identityPath: "/api/local-project-id", + localServer: { + currentUrl: baseUrl, + currentPort: Number(new URL(baseUrl).port || 4298), + projectPortStart: 4298, + projectPortEnd: 53210, + safeLocalOrigin: true, + requestOrigin: null, + requestReferer: null, + unsafeLocalCaller: null, + }, + }, + }); + }); + await page.route("**/api/setup-status**", async (route) => { + await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); + }); + await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + documents: demoDocuments, + demoMode: true, + pagination: { + limit: 150, + offset: 0, + total: demoDocuments.length, + nextOffset: demoDocuments.length, + hasMore: false, + }, + }, + }); + }); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + const body = route.request().postDataJSON() as { + query?: string; + documentId?: string; + documentIds?: string[]; + }; + const payload = demoAnswer(body?.query ?? question, body?.documentId, body?.documentIds); + const pathname = new URL(route.request().url()).pathname; + if (pathname.endsWith("/stream")) { + await route.fulfill({ + body: answerStreamBody(payload), + contentType: "text/event-stream; charset=utf-8", + headers: { "Cache-Control": "no-cache, no-transform" }, + }); + return; + } + await route.fulfill({ json: payload }); + }); +} + +async function main() { + const baseUrl = getPlaywrightBaseUrl(); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 820 } }); + await mockDemoApi(page, baseUrl); + await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle").catch(() => undefined); + + const questionInput = page + .locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible') + .first(); + await questionInput.fill(question); + await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click(); + + await page.getByTestId("plain-answer-response").waitFor({ state: "visible", timeout: 30_000 }); + await page.getByTestId("answer-follow-up-suggestions").waitFor({ state: "visible", timeout: 30_000 }); + await page.getByTestId("answer-support-action-row").waitFor({ state: "attached", timeout: 30_000 }); + + const mainContent = page.locator("#main-content"); + + await mainContent.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await page.waitForTimeout(500); + const expandedPath = join(outputDir, "support-chips-expanded-at-bottom.png"); + await page.screenshot({ path: expandedPath, fullPage: false }); + + await mainContent.evaluate((element) => { + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 180); + }); + await page + .waitForFunction(() => { + const row = document.querySelector('[data-testid="answer-support-action-row"]'); + return row?.getAttribute("data-collapsed") === "true"; + }, { timeout: 10_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + const collapsedPath = join(outputDir, "support-chips-collapsed-above-composer.png"); + await page.screenshot({ path: collapsedPath, fullPage: false }); + + const collapsed = await page.getByTestId("answer-support-action-row").getAttribute("data-collapsed"); + console.log(JSON.stringify({ outputDir, collapsed, expandedPath, collapsedPath, baseUrl }, null, 2)); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index c94551a28..6e0df0589 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -121,10 +121,10 @@ export async function POST(request: Request) { try { if (isDemoMode()) return NextResponse.json({ error: "Bulk edits are unavailable in demo mode." }, { status: 400 }); - const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid."); - const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); + + const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid."); const ids = Array.from(new Set(parsed.documentIds)); const { data: documents, error: documentsError } = await supabase diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index aa6c1cb2d..d05570158 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -145,6 +145,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; +import { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel"; import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, @@ -187,8 +188,14 @@ import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, + serializeSourceGovernanceWarning, type SourceGovernanceWarning, } from "@/lib/source-governance"; +import { + invalidateSearchRequests, + isLatestSearchRequest, + type SearchRequestToken, +} from "@/lib/search-request-token"; import { type SmartDocumentTag, type SmartDocumentTagFacet, @@ -2650,7 +2657,11 @@ export function ClinicalDashboard({ // resolve out of order; only the latest request may commit answer/sources/ // error/loading state, or a stale response would display one query's answer // under another query's composer text. - const searchRequestSeqRef = useRef(0); + const searchRequestSeqRef = useRef(0); + + function invalidateInFlightSearchRequests() { + searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current); + } function applySearchResult(payload: SearchResultModePayload, displayQuery?: string) { if (payload.kind === "documents") { @@ -2714,7 +2725,8 @@ export function ClinicalDashboard({ // library, whose single `document_labels.in()` request produces an // over-long PostgREST URL that fails on large corpora. Corpus search runs // unscoped (like Documents); users opt into label filters explicitly. - const requestId = ++searchRequestSeqRef.current; + const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId; setSearchMode(targetMode); // Answer mode keeps the composer as the draft source until a successful @@ -2768,7 +2780,7 @@ export function ClinicalDashboard({ // must also be discarded once a newer search takes over, or a slow stale // request repaints the progress banner under the newer query. const onProgress = (message: string | null) => { - if (requestId === searchRequestSeqRef.current) setAnswerProgress(message); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message); }; setLoading(true); setError(null); @@ -2842,7 +2854,7 @@ export function ClinicalDashboard({ } // M10: discard a stale response — a newer search owns the UI state. - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { applySearchResult(successfulPayload, trimmedQuery); if (successfulPayload.kind === "answer") { // The composer is a draft box in a conversation: clear it so the @@ -2861,11 +2873,11 @@ export function ClinicalDashboard({ } } } catch (requestError) { - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { setError(requestError instanceof Error ? requestError.message : "Search failed"); } } finally { - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { setLoading(false); setAnswerProgress(null); } @@ -2933,6 +2945,7 @@ export function ClinicalDashboard({ } function crossModeSearch(mode: AppModeId, crossQuery: string) { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setCommandScopes([]); @@ -2992,7 +3005,7 @@ export function ClinicalDashboard({ sourceChunkIds, citedChunkIds, sourceFiles, - sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message), + sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning), unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [], }), }); @@ -3068,6 +3081,7 @@ export function ClinicalDashboard({ return; } + invalidateInFlightSearchRequests(); setQuery(trimmedSearchText); setSearchMode(targetMode); setModeSearchSubmitted(true); @@ -3085,17 +3099,26 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); + const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId; + try { const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); const payload = await runWithRetries(() => requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode), ); - applySearchResult(payload); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + applySearchResult(payload); + } } catch (requestError) { - setError(requestError instanceof Error ? requestError.message : "Document search failed"); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setError(requestError instanceof Error ? requestError.message : "Document search failed"); + } } finally { - setLoading(false); - setAnswerProgress(null); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setLoading(false); + setAnswerProgress(null); + } } } @@ -3184,6 +3207,7 @@ export function ClinicalDashboard({ } function selectSearchMode(mode: AppModeId) { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -3227,6 +3251,7 @@ export function ClinicalDashboard({ } function startNewChat() { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; const href = appModeHomeHref("answer", { focus: true }); setQuery(""); @@ -4297,6 +4322,7 @@ export function ClinicalDashboard({ onReindex={reindexDocument} onEnrich={enrichDocument} /> + diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bd4c4d222..8d875334f 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1914,7 +1914,6 @@ export function DocumentViewer({ const [documentSearchError, setDocumentSearchError] = useState(null); const [reviewingTableFactId, setReviewingTableFactId] = useState(null); const [isOnline, setIsOnline] = useState(true); - const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer); @@ -1927,6 +1926,7 @@ export function DocumentViewer({ const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); const clientDemoMode = localNoAuthMode || serverDemoMode; + const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); useEffect(() => { @@ -2002,10 +2002,10 @@ export function DocumentViewer({ }, [isConfigured]); useEffect(() => { - if (!canUsePrivateApis && authStatus === "loading") { + if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; } - if (!canUsePrivateApis) { + if (!canViewSourceDocuments) { return () => undefined; } @@ -2141,7 +2141,7 @@ export function DocumentViewer({ }, [ authStatus, authorizationHeader, - canUsePrivateApis, + canViewSourceDocuments, clientDemoMode, documentId, chunkId, @@ -2208,15 +2208,6 @@ export function DocumentViewer({ }; }, []); - useEffect(() => { - if (canUsePrivateApis || authStatus !== "loading") { - return () => undefined; - } - - const timeout = window.setTimeout(() => setAuthLoadingTimedOut(true), 3000); - return () => window.clearTimeout(timeout); - }, [authStatus, canUsePrivateApis]); - async function summarize() { if (!canSummarizeDocument) { setSummaryError("Load a source document before summarising."); @@ -2247,15 +2238,8 @@ export function DocumentViewer({ } } - const authViewerError = - !canUsePrivateApis && (authStatus !== "loading" || authLoadingTimedOut) - ? isConfigured - ? "Sign in to open private source documents." - : "Supabase browser authentication is not configured for private source documents." - : null; - const effectiveLoadingDocument = !canUsePrivateApis - ? authStatus === "loading" && !authLoadingTimedOut && loadingDocument - : loadingDocument; + const authViewerError = null; + const effectiveLoadingDocument = loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index ff148551d..eefa782d3 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -23,6 +23,7 @@ import { chatMicroAction, cn, sourceCapsule, + statusDotDanger, statusDotMuted, statusDotReady, statusDotReview, @@ -35,9 +36,16 @@ import { comparableAnswerText, sanitizeAnswerDisplayText, } from "@/components/clinical-dashboard/display-text"; +import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; -import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; +import { + extractionQualityLabel, + normalizeSourceMetadata, + sourceStatusLabel, + sourceStatusNeedsAttention, + validationStatusShortLabel, +} from "@/lib/source-metadata"; import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer"; import { frontendSourceGovernanceWarnings, @@ -260,8 +268,15 @@ function sourceCapsuleText({ export function sourceStatusDotClass(metadata: ReturnType | null | undefined) { if (!metadata) return statusDotMuted; + if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") return statusDotDanger; + if ( + metadata.document_status === "review_due" || + metadata.clinical_validation_status === "unverified" || + metadata.extraction_quality === "partial" + ) { + return statusDotReview; + } if (metadata.document_status === "current") return statusDotReady; - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") return statusDotReview; return statusDotMuted; } @@ -282,7 +297,14 @@ function sourceBadgeLabel(index: number) { } function sourceBadgeToneClass(metadata: ReturnType, index: number) { - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") { + if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") { + return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; + } + if ( + metadata.document_status === "review_due" || + metadata.clinical_validation_status === "unverified" || + metadata.extraction_quality === "partial" + ) { return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; } if (index === 0) { @@ -302,6 +324,10 @@ function sourceSupportLabel(source: CapsulePreviewSource, index: number) { function sourceStatusShortLabel(metadata: ReturnType) { if (metadata.document_status === "review_due") return "Review due"; if (metadata.document_status === "outdated") return "Outdated"; + if (metadata.clinical_validation_status === "unverified") return validationStatusShortLabel(metadata); + if (metadata.extraction_quality === "partial" || metadata.extraction_quality === "poor") { + return extractionQualityLabel(metadata); + } if (metadata.document_status === "current") return "Current"; return sourceStatusLabel(metadata); } @@ -380,9 +406,10 @@ function SourcePreviewContent({ showHeader?: boolean; }) { const primaryPreviewSource = previewSources[0] ?? null; - const reviewDueSource = previewSources.find( - (source) => source.metadata.document_status === "review_due" || source.metadata.document_status === "outdated", - ); + const attentionSource = previewSources.find((source) => sourceStatusNeedsAttention(source.metadata)); + const attentionIsDanger = + attentionSource?.metadata.document_status === "outdated" || + attentionSource?.metadata.extraction_quality === "poor"; return ( <> @@ -447,8 +474,11 @@ function SourcePreviewContent({