diff --git a/.gitleaksignore b/.gitleaksignore index 8b3bd8187..16fe1ab80 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,5 @@ # generic-api-key rule on historical commit content scanned in PR history. 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330 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. 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/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index eaed5d200..9d4b35896 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -90,7 +90,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "registry", + bucket: "differentials", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) { diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 239579032..973aa2600 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -77,7 +77,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "registry", + bucket: "differentials", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) { diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 260e2a90a..cbb1753f4 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -81,7 +81,7 @@ export async function GET(request: Request) { const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "registry", + bucket: "differentials", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) { diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 32057ab41..a4b3d34f9 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -16,6 +16,7 @@ import { isAtomicReindexCandidate, isCommittedGenerationMetadata, } from "@/lib/reindex-pipeline"; +import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBodyOrDefault } from "@/lib/validation/body"; @@ -174,6 +175,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: images: committedImages, summary: enrichment.summary.summary, }); + invalidateRagCachesForDocumentMutation(user.id); return NextResponse.json({ mode, enrichment, diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 5dd6dc8ea..a7e765f1a 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -34,12 +34,15 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const supabase = createAdminClient(); const access = await publicAccessContext(_request, supabase); const { data: document, error } = await withOwnerReadScope( - supabase.from("documents").select("storage_path,file_type").eq("id", id), + supabase.from("documents").select("storage_path,file_type,status").eq("id", id), access.ownerId, ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); + if (document.status && document.status !== "indexed") { + return NextResponse.json({ error: "Document not found." }, { status: 404 }); + } const storage = supabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET); const signed = shouldDownload diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index a6853c1a9..ed70d3e7b 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -71,7 +71,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "registry", + bucket: "medications", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) { diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 7fc2cb999..ba75ecf33 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -89,7 +89,7 @@ export async function GET(request: Request) { const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "registry", + bucket: "medications", allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), }); if (rateLimit.limited) { diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index ba34855b8..16a86a91a 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; +import { jsonError } from "@/lib/http"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; @@ -409,10 +410,14 @@ async function readSetupStatusPayload() { } export async function GET(request: Request) { - const identity = localProjectRequestIdentityPayload(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } + try { + const identity = localProjectRequestIdentityPayload(request); + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); + } - return setupStatusResponse(await readSetupStatusPayload()); + return setupStatusResponse(await readSetupStatusPayload()); + } catch (error) { + return jsonError(error); + } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 72ddb6023..dacf7b78d 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -9,6 +9,7 @@ import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; @@ -82,6 +83,7 @@ export async function POST(request: Request) { let insertedDocumentOwnerId: string | null = null; try { + assertSafeLocalProjectRequest(request); supabase = createAdminClient(); const adminSupabase = supabase; const user = await requireAuthenticatedUser(request, adminSupabase); @@ -298,6 +300,9 @@ export async function POST(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } + if (error instanceof UnsafeLocalProjectOriginError) { + return localProjectOriginErrorResponse(error); + } return jsonError(error); } 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..a3732430a 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"; @@ -92,15 +84,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, @@ -188,10 +173,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, @@ -209,11 +191,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 = @@ -1067,26 +1045,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 +1547,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 +2433,6 @@ 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 +3917,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({