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/site-map.md b/docs/site-map.md index 65e6e3f52..ca5a37db3 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -5,7 +5,7 @@ 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`. +- `/applications` - Route discovered from app directory 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 +40,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 +601,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/scripts/eval-quality.ts b/scripts/eval-quality.ts index 0bde64faf..0c1749ceb 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -45,6 +45,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -176,6 +178,10 @@ function parseArgs(argv: string[]): EvalQualityArgs { args.skipPreflight = true; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -476,6 +482,11 @@ export function buildEvalQualityReport(args: { `retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`, ); } + if (retrievalSummary.force_embedding_failure_count > 0) { + thresholdFailures.push( + `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`, + ); + } if (governance.stale_rate > qualityThresholds.staleTopResultRate) { thresholdFailures.push( `top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`, @@ -664,6 +675,8 @@ ${markdownTable([ ## Retrieval Decision Metrics ${markdownTable([ + ["Force-embedding cases", retrieval.force_embedding_case_count], + ["Force-embedding failures", retrieval.force_embedding_failure_count], ["Embedding skipped rate", retrieval.embedding_skipped_rate], ["Median text candidate budget", retrieval.median_text_candidate_budget], ["Second-stage rerank rate", retrieval.second_stage_rerank_rate], @@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: { topK: retrievalLimitForGoldenCase(testCase), minSimilarity: 0.12, skipCache: true, - forceEmbedding: testCase.forceEmbedding, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index edeb89564..2a46d96f2 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { export type GoldenRetrievalResult = { id: string; query: string; + forceEmbedding: boolean; expectedQueryClass: string; actualQueryClass: string | null; expectedDocumentSubstrings: string[]; @@ -516,6 +517,11 @@ export function evaluateGoldenRetrievalCase(args: { const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; + const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( + (sum, [layer, count]) => + ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum, + 0, + ); const hitAtK = documentHitsAtK.missing.length === 0 && contentHitsAtK.missing.length === 0 && @@ -533,10 +539,23 @@ export function evaluateGoldenRetrievalCase(args: { if (args.testCase.expectTableEvidence && !tableEvidenceFound) { failures.push("expected table evidence in top 5"); } + if (args.testCase.forceEmbedding) { + if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run"); + if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache"); + if ( + args.telemetry.retrieval_strategy === "text_fast_path" || + args.telemetry.retrieval_strategy === "document_lookup_fast_path" + ) { + failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`); + } + if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate"); + if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates"); + } return { id: args.testCase.id, query: args.testCase.query, + forceEmbedding: args.testCase.forceEmbedding ?? false, expectedQueryClass: args.testCase.expectedQueryClass, actualQueryClass, expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings, @@ -612,6 +631,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] } return counts; }, {}); + const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); return { case_count: results.length, document_recall_at_5: Number( @@ -647,6 +667,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] embedding_skip_reason_counts: embeddingSkipReasonCounts, text_fast_path_reason_counts: textFastPathReasonCounts, retrieval_layer_counts: layerCounts, + force_embedding_case_count: forceEmbeddingResults.length, + force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length, median_text_candidate_budget: percentile(textCandidateBudgets, 50), second_stage_rerank_rate: Number( (results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4), @@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType = { "/": "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/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 55d6a1558..4f404a004 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -8,6 +8,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -280,13 +281,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("*") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(request, supabase); + const { data: document, error } = await withOwnerReadScope( + supabase.from("documents").select("*").eq("id", id), + access.ownerId, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index 4ab60b1ed..d153f9859 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -5,7 +5,8 @@ import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -181,13 +182,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(request, supabase); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); @@ -197,7 +196,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: p_document_id: id, p_query: query, match_count: limit, - p_owner_id: user.id, + p_owner_id: access.ownerId, }); if (!rpcError) { diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 043d01118..5dd6dc8ea 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -5,7 +5,8 @@ import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,13 +32,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("storage_path,file_type") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(_request, supabase); + const { data: document, error } = await withOwnerReadScope( + supabase.from("documents").select("storage_path,file_type").eq("id", id), + access.ownerId, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 52814c3a6..8fa0958be 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -4,7 +4,8 @@ import { demoDocuments } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -130,11 +131,11 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - let query = supabase - .from("documents") - .select(DOCUMENT_LIST_COLUMNS, { count: "exact" }) - .eq("owner_id", user.id) + const access = await publicAccessContext(request, supabase); + let query = withOwnerReadScope( + supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }), + access.ownerId, + ) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index b061aaf8a..9ca925d49 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -6,7 +6,8 @@ import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,7 +32,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); + const access = await publicAccessContext(_request, supabase); const { data: image, error } = await supabase .from("document_images") .select("document_id,storage_path,mime_type,caption,metadata") @@ -41,12 +42,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); if (!image) return NextResponse.json({ error: "Image not found." }, { status: 404 }); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", image.document_id) - .eq("owner_id", user.id) - .maybeSingle(); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", image.document_id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index bfa21c873..a966ab37a 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -62,6 +62,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } + if (!hasPublicApiAuthSignal(request)) { + const payload = publicRegistryDetailPayload(kind, normalizedSlug); + if (!payload) return notFoundResponse(normalizedSlug); + return registryResponse({ + ...payload, + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 5897a1525..ab07badfb 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -78,6 +78,13 @@ export async function GET(request: Request) { }); } + if (!hasPublicApiAuthSignal(request)) { + return registryResponse({ + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index bf0551c94..ba34855b8 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -409,27 +408,11 @@ async function readSetupStatusPayload() { } } -async function requireProductionSetupStatusAuth(request: Request) { +export async function GET(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { - return identity; + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); } - await requireAuthenticatedUser(request, createAdminClient()); - return identity; -} -export async function GET(request: Request) { - try { - const identity = await requireProductionSetupStatusAuth(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } - - return setupStatusResponse(await readSetupStatusPayload()); - } catch (error) { - if (error instanceof AuthenticationError) { - return unauthorizedResponse(error); - } - throw error; - } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/src/app/globals.css b/src/app/globals.css index b1873a02d..f59435eea 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,16 @@ 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; + height: 1.1rem; + width: 1.1rem; } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..a3732430a 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, @@ -45,69 +40,32 @@ import { Wrench, X, } from "lucide-react"; -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 CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +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 { 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, @@ -126,45 +84,18 @@ import { type SetupCheck, type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { - GuideDialog, - GuideTrigger, - SectionHeading, - UtilityDrawer, -} from "@/components/clinical-dashboard/dashboard-shell"; -import { - cleanDisplayTitle, - 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, - 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 +129,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,21 +167,13 @@ 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 SmartDocumentTag, type SmartDocumentTagFacet } from "@/lib/document-tags"; import type { ClinicalDocument, DocumentMatch, @@ -263,19 +186,12 @@ 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"; +import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = @@ -487,367 +403,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. @@ -1490,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 = { @@ -2010,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. @@ -2896,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(() => { @@ -4009,17 +3545,17 @@ export function ClinicalDashboard({ const showDegradedNotice = !isOnline || apiUnavailable; 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. @@ -4037,6 +3573,7 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const isDeployedApp = process.env.NODE_ENV === "production"; const renderDegradedNotice = () => (

{!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."} + : isDeployedApp + ? "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."}

); @@ -4376,12 +3917,7 @@ export function ClinicalDashboard({ }} /> ) : activeModeResultKind === "tools" ? ( - + ) : activeModeResultKind === "favourites" ? ( (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/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index b47b1267a..3669e3c3e 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -12,7 +12,6 @@ import { FileText, Grid2X2, Pill, - Plus, Search, ShieldCheck, Sparkles, @@ -22,14 +21,14 @@ import { X, type LucideIcon, } from "lucide-react"; -import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn } from "@/components/ui-primitives"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; type LauncherStatus = "ready" | "recent" | "review_due"; type LauncherArea = "assessment" | "reference" | "care" | "coordination" | "saved"; -type LauncherVariant = "standalone" | "dashboard-tools"; type LauncherFilter = "all" | LauncherArea | "more"; type LauncherApp = { @@ -54,19 +53,6 @@ type LauncherApp = { output: string; }; -type LauncherCopy = { - heading: string; - description: string; - searchAriaLabel: string; - searchPlaceholder: string; - openSelectedAriaLabel: string; - allSectionLabel: string; - allColumnLabel: string; - countNoun: string; - emptyTitle: string; - emptyBody: string; -}; - const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -311,29 +297,11 @@ const launcherApps: LauncherApp[] = [ }, ]; -const standaloneLauncherCopy: LauncherCopy = { - heading: "Applications", - description: - "Open the clinical applications and connected workflows you use for assessment, prescribing, documents, and saved work.", - searchAriaLabel: "Search applications", - searchPlaceholder: "Search applications...", - openSelectedAriaLabel: "Open selected application", - allSectionLabel: "All applications", - allColumnLabel: "Application", - countNoun: "applications", - emptyTitle: "No applications match", - emptyBody: "Clear the search or try another clinical workflow, app name, or category.", -}; - -const dashboardToolsLauncherCopy: LauncherCopy = { +const toolsLauncherCopy = { heading: "Tools", description: "Open the clinical tools and connected workflows you use for assessment, prescribing, documents, and saved work.", - searchAriaLabel: "Search tools", - searchPlaceholder: "Search tools...", - openSelectedAriaLabel: "Open selected tool", allSectionLabel: "All tools", - allColumnLabel: "Tool", countNoun: "tools", emptyTitle: "No tools match", emptyBody: "Clear the search or try another clinical workflow, tool name, or category.", @@ -444,58 +412,6 @@ function ToolChips({ app, includeStatus = false }: { app: LauncherApp; includeSt ); } -function ToolSearch({ - value, - onChange, - onSubmit, - copy, - className, -}: { - value: string; - onChange: (query: string) => 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 [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); - 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 [detailOpen, setDetailOpen] = useState(false); + const copy = toolsLauncherCopy; const normalizedQuery = query.trim().toLowerCase(); + const queryDerivedId = useMemo(() => initialToolId(query), [query]); + const [selection, setSelection] = useState({ + queryKey: normalizedQuery, + id: initialToolId(query), + }); + const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -935,34 +848,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) { - setSelectedId(id); + setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } - function submitSearch() { - if (filteredApps[0]) openTool(filteredApps[0].id); - } - return (
@@ -970,7 +874,7 @@ export function ApplicationsLauncherWorkspace({

{copy.heading} @@ -980,22 +884,11 @@ export function ApplicationsLauncherWorkspace({

- {desktopComposerSlotId ? ( -
- ) : ( - - )} + {composerSlotId ? ( +
+ ) : null} -
+
@@ -1007,7 +900,7 @@ export function ApplicationsLauncherWorkspace({
@@ -1050,9 +943,7 @@ export function ApplicationsLauncherWorkspace({

- {isDashboardTools ? ( - - ) : null} + setDetailOpen(false)} />
@@ -1060,5 +951,5 @@ export function ApplicationsLauncherWorkspace({ } export function ApplicationsLauncherPage() { - return ; + 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/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 05c7f3ae2..98cfb3b0e 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -44,6 +44,7 @@ import { import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; type ViewMode = FavouritesViewMode; @@ -952,6 +953,11 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string }) +
+
diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 58761d61a..d01800254 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,12 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; - // 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; // 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 +1134,7 @@ export function MasterSearchHeader({ onModeSelect={selectAppModeById} onPlacementChange={setActionMenuPlacement} triggerClassName="answer-footer-search-action" + triggerRef={scopeSummaryRef} integrated={usesFooterChipLayout} /> @@ -1348,53 +1198,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({