From eafaffb97588f0459f469ca5daab2f6bfda5009f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:41:08 +0800 Subject: [PATCH 1/5] fix: harden production demo guards --- .github/workflows/ci.yml | 31 ++ docs/clinical-governance.md | 8 + docs/process-hardening.md | 6 +- src/app/api/answer/route.ts | 6 +- src/app/api/answer/stream/route.ts | 14 +- src/app/api/eval-cases/route.ts | 11 +- src/app/api/search/interaction/route.ts | 11 +- src/app/api/search/route.ts | 48 +- src/app/api/setup-status/route.ts | 25 +- .../mockups/recent-searches-bottom/page.tsx | 223 ++------- src/components/ClinicalDashboard.tsx | 422 ++++++++++------ src/components/applications-launcher-page.tsx | 203 ++------ .../favourites-prototype-data.ts | 127 +++++ .../master-search-header.tsx | 82 +++- .../clinical-dashboard/relevance-score.ts | 6 +- src/instrumentation.ts | 33 ++ src/lib/citations.ts | 25 +- src/lib/env.ts | 16 +- src/lib/public-rate-limit.ts | 103 ---- src/lib/query-privacy.ts | 24 +- src/lib/rag-eval-cases.ts | 378 +++++++++++++++ src/lib/rag.ts | 456 +++++++++++++++++- src/lib/source-governance.ts | 2 +- src/lib/tools.ts | 110 +---- tests/citations.test.ts | 14 +- tests/document-relevance-score.test.ts | 6 + tests/env-demo-mode.test.ts | 81 ++++ tests/eval-cases-route.test.ts | 40 +- tests/instrumentation.test.ts | 84 ++++ tests/privacy.test.ts | 49 +- tests/private-access-routes.test.ts | 77 ++- tests/rag-answer-fallback.test.ts | 2 +- tests/rag-eval-cases.test.ts | 49 +- tests/rag-trust.test.ts | 16 +- tests/retrieval-query-variants.test.ts | 10 +- tests/search-interaction-route.test.ts | 36 ++ tests/setup-status-route.test.ts | 39 ++ tests/source-governance.test.ts | 6 + tests/ui-smoke.spec.ts | 54 ++- tests/ui-stress.spec.ts | 7 +- tests/ui-tools.spec.ts | 35 +- vitest.config.mts | 4 +- 42 files changed, 2182 insertions(+), 797 deletions(-) create mode 100644 src/components/clinical-dashboard/favourites-prototype-data.ts create mode 100644 src/instrumentation.ts delete mode 100644 src/lib/public-rate-limit.ts create mode 100644 tests/env-demo-mode.test.ts create mode 100644 tests/instrumentation.test.ts create mode 100644 tests/setup-status-route.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0a04c6ec..21aacb0b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: pull_request: branches: ["**"] workflow_dispatch: + schedule: + - cron: "0 18 * * 0" permissions: contents: read @@ -61,3 +63,32 @@ jobs: - name: Chromium UI smoke run: npm run test:e2e:chromium + + release-browser-matrix: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: https://sjrfecxgysukkwxsowpy.supabase.co + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder-ci-anon-key + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Full browser UI matrix + run: npm run test:e2e diff --git a/docs/clinical-governance.md b/docs/clinical-governance.md index b3592dce07..024288a2a3 100644 --- a/docs/clinical-governance.md +++ b/docs/clinical-governance.md @@ -36,3 +36,11 @@ Use the `.github/pull_request_template.md` clinical governance section for any c - Confirm unknown or outdated source metadata is treated conservatively. - Confirm demo/synthetic content remains separated from real clinical sources. - Confirm clinical decision-support behavior changes have deployment classification and TGA SaMD impact reviewed before production use. + +## Verification Records + +### RLS & access scoping — 2026-06-28 +- Supabase **security advisors: 0 findings** for `Clinical KB Database` (`sjrfecxgysukkwxsowpy`). The linter specifically flags missing RLS / insecure policies, so a clean run confirms RLS is enabled and policy-covered across `public` tables. +- Supabase **performance advisors: INFO only** — unused indexes (expected on a low-traffic database; do not drop pre-launch) and one auth connection-strategy tip (switch to percentage-based allocation when scaling instance size). +- **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). +- **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/process-hardening.md b/docs/process-hardening.md index ad8ce6aabf..448874a2f0 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -7,7 +7,7 @@ This document turns the current process review into phased, durable repo practic - `npm run verify:cheap` is the default broad local gate for source/config/test changes: lint, typecheck, and unit tests. - `npm run verify:ui` is the default UI gate: Chromium Playwright smoke, stress, and accessibility media checks. - `npm run verify:release` is the release-confidence gate: lint, typecheck, unit tests, build, and the full Playwright browser project set. -- CI now installs Chromium and runs the Chromium UI gate after build. +- CI now installs Chromium and runs the Chromium UI gate after build on all branches; a gated release-browser job runs the full Playwright browser matrix on `main`, `release/*`, manual dispatch, and the weekly schedule. - `tests/ui-accessibility.spec.ts` covers reduced-motion and forced-colors dashboard usability so those modes are no longer only reviewed by inspection. - `tests/ui-tools.spec.ts` covers the `/applications` launcher at mobile and desktop sizes. - `AGENTS.md` now points future agents to these gates and to this document. @@ -36,13 +36,13 @@ This document turns the current process review into phased, durable repo practic - `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 24.x or npm 11.x when run through npm. - CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 24. - `npm run check:edge:functions` is the Deno type gate for the Supabase `indexing-v3-agent` Edge Function. -- Decide whether CI should run all Playwright browser projects on protected branches, release branches, or a scheduled workflow instead of every push. +- Tune the full-browser CI cadence if release branches or weekly schedules prove too slow or too sparse. - Add explicit review ownership for clinical source governance, outdated-source handling, incident review, and decommission decisions. - Record production-readiness outcomes in release notes whenever clinical workflow, source governance, privacy, or deployment assumptions change. ## Known limits -- Chromium UI coverage is active in CI now; Firefox and WebKit remain available through `npm run test:e2e` and `npm run verify:release`. +- Chromium UI coverage is active in CI on all branches; Firefox and WebKit run in the gated release-browser CI job and remain available locally through `npm run test:e2e` and `npm run verify:release`. - The new accessibility media smoke verifies usability and layout in reduced-motion and forced-colors modes; it is not a full WCAG audit. - The format gate intentionally ignores `.tmp-visual/` and `scratch/`; those folders are local investigation output, not release source. - Process scripts do not commit, push, deploy, mutate Supabase data, or run dependency updates. diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index fdd5b37654..2176d12810 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -97,12 +97,16 @@ export async function POST(request: Request) { relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); if (hasDangerSourceGovernanceWarning(warnings)) { + // Build the refusal explicitly — never spread ...answer here, or the original + // (refused) sources/smartPanel/smartApiPlan would still reach the client and + // defeat the refusal. Keep only the safe "unsupported" contract fields, matching + // the empty-scope branch above. return NextResponse.json({ - ...answer, answer: sourceGovernanceRefusalAnswer, grounded: false, confidence: "unsupported", citations: [], + sources: [], scope: { ...scope, queryMode: body.queryMode }, sourceGovernanceWarnings: warnings, }); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index b877ee33e1..2b13e9cb31 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -15,7 +15,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; -import { requireAuthenticatedUser } from "@/lib/supabase/auth"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; @@ -64,7 +64,9 @@ function streamErrorPayload(error: unknown) { if (error instanceof Error) { return { message: "Answer generation failed. Retry with a narrower question.", - status: 503, + // Match the non-streaming /api/answer route, which returns 500 for a + // generic answer-generation failure. + status: 500, details: { code: error.name }, }; } @@ -159,12 +161,14 @@ function streamAnswer(body: AnswerBody, ownerId?: string) { relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, }); if (hasDangerSourceGovernanceWarning(warnings)) { + // Explicit refusal payload — do not spread ...answer (see /api/answer): + // the refused sources/smartPanel/smartApiPlan must not reach the client. send("final", { - ...answer, answer: sourceGovernanceRefusalAnswer, grounded: false, confidence: "unsupported", citations: [], + sources: [], scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, }); @@ -213,8 +217,8 @@ export async function POST(request: Request) { return streamAnswer(body, user.id); } catch (error) { - if (error instanceof Error && error.name === "AuthenticationError") { - return Response.json({ error: "Authentication required." }, { status: 401 }); + if (error instanceof AuthenticationError) { + return unauthorizedResponse(error); } if (error instanceof z.ZodError) { return jsonError(error, 400); diff --git a/src/app/api/eval-cases/route.ts b/src/app/api/eval-cases/route.ts index 00d52480a1..d461c416cc 100644 --- a/src/app/api/eval-cases/route.ts +++ b/src/app/api/eval-cases/route.ts @@ -4,7 +4,12 @@ import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; -import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import { searchScopeFiltersSchema } from "@/lib/search-scope"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -117,7 +122,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const normalizedQuery = parsed.data.query.toLowerCase().replace(/\s+/g, " ").trim(); + const normalizedQuery = normalizedQueryTextForStorage(parsed.data.query); const sourceChunkIds = uniqueUuidValues(parsed.data.sourceChunkIds); const citedChunkIds = uniqueUuidValues(parsed.data.citedChunkIds); const sourceFiles = uniqueValues(parsed.data.sourceFiles); @@ -149,7 +154,7 @@ export async function POST(request: Request) { miss_reason: missReason, expected_document_id: expectedDocumentId, expected_chunk_id: expectedChunkId, - candidate_aliases: normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12), + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(parsed.data.query).slice(0, 12)), promoted_eval_case: true, promoted_at: new Date().toISOString(), metadata: { diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index 40b7483994..7c283d1508 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { isDemoMode } from "@/lib/env"; -import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; @@ -76,14 +81,14 @@ export async function POST(request: Request) { await supabase.from("rag_query_misses").insert({ owner_id: user.id, query: queryTextForStorage(body.query), - normalized_query: normalizeQueryText(body.query), + normalized_query: normalizedQueryTextForStorage(body.query), query_class: body.queryClass ?? null, clicked_document_id: clickedDocumentId, clicked_chunk_id: clickedChunkId, top_files: safeFileName ? [safeFileName] : [], top_chunk_ids: clickedChunkId ? [clickedChunkId] : [], miss_reason: "clicked_result", - candidate_aliases: normalizedClinicalSearchTokens(body.query).slice(0, 10), + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)), candidate_labels: safeTitle ? [ { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f3bc4b87dd..11d975fa39 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -16,7 +16,12 @@ import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; import { sourceGovernanceWarnings } from "@/lib/source-governance"; -import { normalizeQueryText, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { + normalizedQueryTextForStorage, + queryDerivedTokensForStorage, + queryPrivacyMetadata, + queryTextForStorage, +} from "@/lib/query-privacy"; import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types"; export const runtime = "nodejs"; @@ -86,43 +91,48 @@ function buildDocumentMatchesFromResults(results: SearchResult[], limit: number) file_name: string; bestPages: number[]; bestChunkIds: string[]; - imageCount: number; - tableCount: number; + // Track unique image ids: the same image is often hydrated onto several of + // a document's chunks, so summing per-chunk counts would inflate the badge. + imageIds: Set; + tableImageIds: Set; score: number; } >(); for (const result of results) { - const current = grouped.get(result.document_id); const score = result.hybrid_score ?? result.similarity; const page = result.page_number ?? null; const clinicalImages = result.images?.filter((image) => isClinicalImageEvidence(image)) ?? []; - const tableCount = clinicalImages.filter((image) => image.source_kind === "table_crop").length; - const imageCount = clinicalImages.length; + let current = grouped.get(result.document_id); if (!current) { - grouped.set(result.document_id, { + current = { document_id: result.document_id, title: result.title, file_name: result.file_name, - bestPages: page ? [page] : [], - bestChunkIds: [result.id], - imageCount, - tableCount, + bestPages: [], + bestChunkIds: [], + imageIds: new Set(), + tableImageIds: new Set(), score, - }); - continue; + }; + grouped.set(result.document_id, current); } current.score = Math.max(current.score, score); if (page && !current.bestPages.includes(page)) current.bestPages.push(page); if (!current.bestChunkIds.includes(result.id)) current.bestChunkIds.push(result.id); - current.imageCount += imageCount; - current.tableCount += tableCount; + for (const image of clinicalImages) { + if (!image.id) continue; + current.imageIds.add(image.id); + if (image.source_kind === "table_crop") current.tableImageIds.add(image.id); + } } return Array.from(grouped.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) - .map((document) => ({ + .map(({ imageIds, tableImageIds, ...document }) => ({ ...document, + imageCount: imageIds.size, + tableCount: tableImageIds.size, labels: [], summarySnippet: null, matchReason: `Matched ${document.bestChunkIds.length} indexed passage${ @@ -337,7 +347,7 @@ function candidatePromotions(query: string, results: SearchResult[]) { confidence: label.confidence, })); return { - aliases: Array.from(new Set(queryTerms)).slice(0, 10), + aliases: queryDerivedTokensForStorage(Array.from(new Set(queryTerms)).slice(0, 10)), labels: topLabels, }; } @@ -370,7 +380,7 @@ function logWeakSearch(args: { .insert({ owner_id: args.ownerId, query: queryTextForStorage(args.query), - normalized_query: normalizeQueryText(args.query), + normalized_query: normalizedQueryTextForStorage(args.query), query_class: args.queryClass, route: args.route ?? null, retrieval_strategy: args.retrievalStrategy ?? null, @@ -478,7 +488,7 @@ function logRetrievalDiagnostics(args: { await args.supabase.from("rag_retrieval_logs").insert({ owner_id: args.ownerId, query: queryTextForStorage(args.query), - normalized_query: normalizeQueryText(args.query), + normalized_query: normalizedQueryTextForStorage(args.query), query_class: (args.telemetry.query_class as string) ?? null, retrieval_strategy: (args.telemetry.retrieval_strategy as string) ?? null, candidate_count: args.results.length, diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index ba34855b83..bf0551c94f 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,6 +2,7 @@ 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"; @@ -408,11 +409,27 @@ async function readSetupStatusPayload() { } } -export async function GET(request: Request) { +async function requireProductionSetupStatusAuth(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); + if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { + return identity; } + await requireAuthenticatedUser(request, createAdminClient()); + return identity; +} - return setupStatusResponse(await readSetupStatusPayload()); +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; + } } diff --git a/src/app/mockups/recent-searches-bottom/page.tsx b/src/app/mockups/recent-searches-bottom/page.tsx index 2783ef3ea1..f78e9a3138 100644 --- a/src/app/mockups/recent-searches-bottom/page.tsx +++ b/src/app/mockups/recent-searches-bottom/page.tsx @@ -1,15 +1,8 @@ import { - BookOpenCheck, Clock3, FileText, Filter, - Menu, - Mic, - Moon, - Plus, Search, - Send, - Sparkles, X, } from "lucide-react"; @@ -27,107 +20,9 @@ const sourceCards = [ ["Acamprosate prescribing note", "Dose thresholds, contraindications, PBS access", "Current"], ] as const; -function HeaderMockup() { - return ( -
-
- - - - -
- - - - AK - - -
-
-
- ); -} - -function SidebarMockup() { - return ( - - ); -} - function RecentSearchRail() { return ( -
+
Recent @@ -155,44 +50,6 @@ function RecentSearchRail() { ); } -function BottomComposerMockup() { - return ( -
- -
- - - - -
-
- ); -} - function SourcePreviewGrid() { return (
@@ -219,54 +76,46 @@ function SourcePreviewGrid() { export default function RecentSearchesBottomMockupPage() { return ( -
-
- -
- -
-
-
-
-
- - - - - Ready - - - All sources - -
-

- Ask from your indexed clinical sources -

-

- Recent searches sit near the bottom composer as low-emphasis shortcuts, leaving the answer canvas - quiet until the clinician starts a new query. -

-
+
+
+
+
+
+
+ + + + + Ready + + + All sources + +
+

+ Ask from your indexed clinical sources +

+

+ Recent searches sit near the bottom composer as low-emphasis shortcuts, leaving the answer canvas quiet + until the clinician starts a new query. +

+
- + -
-
- -

- Scope and mode stay primary -

- - Recent is secondary - -
-
+
+
+ +

Scope and mode stay primary

+ + Recent is secondary +
-
- +
-
+ +
); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 578f2c612d..93e038c071 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -56,6 +56,7 @@ import { import { type CSSProperties, FormEvent, + type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, @@ -142,6 +143,16 @@ import { sourceDisplayTitle, } from "@/components/clinical-dashboard/display-text"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; +import { + favouriteItems, + favouritePrototypeCount, + favouriteSets, + favouriteTabs, + favouriteTypeCount, + type FavouriteItem, + type FavouriteSet, + type FavouriteTabId, +} from "@/components/clinical-dashboard/favourites-prototype-data"; import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { DocumentSearchResultsPanel, @@ -5331,32 +5342,61 @@ function DrawerGroupLabel({ title }: { title: string }) { } const sidebarToolItems = [ - { label: "Formulation", icon: Brain, href: "/applications" }, - { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, + { label: "Documents", icon: FileText, href: "/?mode=documents" }, { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, - { label: "Diffs", icon: Search, href: "/applications" }, + { label: "Applications", icon: ListChecks, href: "/applications" }, ] as const; +type SidebarIdentity = { + displayName: string; + initials: string; + detail: string; + signedIn: boolean; +}; + +function deriveSidebarIdentity(email: string | null | undefined): SidebarIdentity { + const normalized = email?.trim(); + if (!normalized) { + return { displayName: "Guest", initials: "G", detail: "Not signed in", signedIn: false }; + } + const handle = normalized.split("@")[0] || normalized; + const parts = handle.split(/[._\-+]+/).filter(Boolean); + const initials = (parts.length >= 2 ? `${parts[0][0]}${parts[1][0]}` : handle.slice(0, 2)).toUpperCase() || "U"; + const displayName = + parts.length > 0 ? parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") : normalized; + return { displayName, initials, detail: normalized, signedIn: true }; +} + function ClinicalSidebarContent({ recentQueries, + identity, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, showHeader = true, onCollapsedChange, onNavigate, }: { recentQueries: string[]; + identity: SidebarIdentity; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications?: () => void; showHeader?: boolean; onCollapsedChange?: (collapsed: boolean) => void; onNavigate?: () => void; }) { - const visibleRecentQueries = recentQueries.slice(0, 5); + const [chatFilter, setChatFilter] = useState(""); + const normalizedChatFilter = chatFilter.trim().toLowerCase(); + const matchingRecentQueries = normalizedChatFilter + ? recentQueries.filter((recent) => recent.toLowerCase().includes(normalizedChatFilter)) + : recentQueries; + const visibleRecentQueries = matchingRecentQueries.slice(0, 5); return (
@@ -5400,6 +5440,9 @@ function ClinicalSidebarContent({ setChatFilter(event.target.value)} + aria-label="Search recent chats" className="h-11 w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] pl-9 pr-3 text-sm font-medium text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20" /> @@ -5438,7 +5481,7 @@ function ClinicalSidebarContent({ textMuted, )} > - Recent chats will appear here. + {normalizedChatFilter ? "No recent chats match your search." : "Recent chats will appear here."}

)}
@@ -5494,19 +5537,26 @@ function ClinicalSidebarContent({ Guide & help -
- AK + {identity.initials} - Dr A. Khan + {identity.displayName} -
@@ -5518,18 +5568,22 @@ function ClinicalSidebarContent({ function ClinicalDesktopSidebar({ collapsed, recentQueries, + identity, onCollapsedChange, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, }: { collapsed: boolean; recentQueries: string[]; + identity: SidebarIdentity; onCollapsedChange: (collapsed: boolean) => void; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications: () => void; }) { if (collapsed) { @@ -5550,7 +5604,13 @@ function ClinicalDesktopSidebar({ - - -
- AK +
+ {identity.initials}
); @@ -5585,10 +5655,12 @@ function ClinicalDesktopSidebar({ > @@ -5598,18 +5670,22 @@ function ClinicalDesktopSidebar({ function ClinicalMobileSidebar({ open, recentQueries, + identity, onOpenChange, onNewChat, onPickRecent, onOpenGuide, + onOpenSettings, onPrefetchApplications, }: { open: boolean; recentQueries: string[]; + identity: SidebarIdentity; onOpenChange: (open: boolean) => void; onNewChat: () => void; onPickRecent: (query: string) => void; onOpenGuide: () => void; + onOpenSettings: () => void; onPrefetchApplications: () => void; }) { return ( @@ -5625,9 +5701,11 @@ function ClinicalMobileSidebar({ onOpenChange(false)} /> @@ -5635,128 +5713,128 @@ function ClinicalMobileSidebar({ ); } -type FavouriteType = "medications" | "documents" | "sources" | "sets"; -type FavouriteTabId = "all" | FavouriteType; - -type FavouriteItem = { - id: string; - title: string; - type: Exclude; - set: string; - meta: string; - sourceMeta: string; - primaryAction: string; - icon: typeof FileText; - keywords: string; -}; - -type FavouriteSet = { - id: string; - title: string; - count: number; - meta: string; - keywords: string; -}; - -const favouriteTabs: Array<{ - id: FavouriteTabId; - label: string; - shortLabel: string; - icon: typeof FileText; -}> = [ - { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, - { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, - { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, - { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, - { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, -]; +function SettingsDialog({ + open, + onClose, + identity, + theme, + onToggleTheme, + onSignOut, + onOpenGuide, +}: { + open: boolean; + onClose: () => void; + identity: SidebarIdentity; + theme: "light" | "dark"; + onToggleTheme: () => void; + onSignOut: () => void; + onOpenGuide: () => void; +}) { + const themeOptions: Array<{ value: "light" | "dark"; label: string }> = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ]; -const favouriteItems: FavouriteItem[] = [ - { - id: "acamprosate-renal-screen", - title: "Acamprosate renal screen", - type: "medications", - set: "Ward round", - meta: "Medication page · renal cautions · dose notes", - sourceMeta: "3 sources", - primaryAction: "Open", - icon: Pill, - keywords: "acamprosate renal screen medication dose safety ward round pbs", - }, - { - id: "lithium-monitoring-guideline", - title: "Lithium monitoring guideline", - type: "documents", - set: "Prescribing safety", - meta: "PDF · p.4-9 · 2 tables", - sourceMeta: "PDF", - primaryAction: "Ask", - icon: FileText, - keywords: "lithium monitoring guideline blood tests shared care renal toxicity", - }, - { - id: "clozapine-monitoring-table", - title: "Clozapine monitoring table", - type: "sources", - set: "Clozapine clinic", - meta: "Saved table · ANC monitoring", - sourceMeta: "Table", - primaryAction: "Source", - icon: Quote, - keywords: "clozapine monitoring table anc fbc neutrophil clinic", - }, - { - id: "renal-dose-search", - title: "renal dose saved search", - type: "sources", - set: "Ward round", - meta: "Saved query · medicines + documents", - sourceMeta: "Run", - primaryAction: "Run", - icon: Search, - keywords: "renal dose saved search kidney egfr medicines documents", - }, - { - id: "qt-prolongation-quote", - title: "QT prolongation quote", - type: "sources", - set: "Prescribing safety", - meta: "Source card · prescribing safety", - sourceMeta: "Quote", - primaryAction: "Copy", - icon: Quote, - keywords: "qt prolongation quote source card prescribing safety", - }, -]; + return ( + +
+
+
+ +

Appearance

+
+

Choose the workspace theme. Saved to this browser.

+
+ {themeOptions.map((option) => { + const active = option.value === theme; + return ( + + ); + })} +
+
-const favouriteSets: FavouriteSet[] = [ - { - id: "ward-round", - title: "Ward round", - count: 12, - meta: "Medication pages, renal checks, forms", - keywords: "ward round acamprosate lithium renal mht forms", - }, - { - id: "prescribing-safety", - title: "Prescribing safety", - count: 9, - meta: "Dose limits, pregnancy, renal cautions", - keywords: "prescribing safety dose pregnancy renal qt interactions", - }, - { - id: "clozapine-clinic", - title: "Clozapine clinic", - count: 7, - meta: "Monitoring, ANC table, counselling", - keywords: "clozapine clinic monitoring anc table counselling", - }, -]; +
+
+ +

Account

+
+
+ + {identity.initials} + + + + {identity.displayName} + + + + {identity.detail} + + +
+ {identity.signedIn ? ( + + ) : ( +

+ Sign in from the search header to access private documents and saved reviews. +

+ )} +
-function favouriteTypeCount(type: FavouriteTabId) { - if (type === "all") return favouriteItems.length + favouriteSets.length; - if (type === "sets") return favouriteSets.length; - return favouriteItems.filter((item) => item.type === type).length; + +
+
+ ); } function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) { @@ -5782,6 +5860,8 @@ function FavouritesHub({ const [tabMenuOpen, setTabMenuOpen] = useState(false); const [selectedSetId, setSelectedSetId] = useState(null); const tabMenuRef = useRef(null); + const tabButtonRef = useRef(null); + const tabOptionRefs = useRef>([]); const normalizedQuery = query.trim(); const selectedSet = selectedSetId ? favouriteSets.find((set) => set.id === selectedSetId) : null; const tabItems = @@ -5802,6 +5882,50 @@ function FavouritesHub({ const itemCount = favouriteItems.length; const setCount = favouriteSets.length; const activeFilterCount = (normalizedQuery ? 1 : 0) + (selectedSet ? 1 : 0); + const selectedTabIndex = Math.max( + 0, + favouriteTabs.findIndex((tab) => tab.id === selectedTab), + ); + + function focusFavouriteTabOption(index: number) { + const nextIndex = (index + favouriteTabs.length) % favouriteTabs.length; + tabOptionRefs.current[nextIndex]?.focus(); + } + + function openFavouriteTabMenu(focusIndex: number) { + setTabMenuOpen(true); + window.requestAnimationFrame(() => focusFavouriteTabOption(focusIndex)); + } + + function handleFavouriteTabTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + openFavouriteTabMenu(selectedTabIndex); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + openFavouriteTabMenu(selectedTabIndex - 1); + } + } + + function handleFavouriteTabOptionKeyDown(event: ReactKeyboardEvent, index: number) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusFavouriteTabOption(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusFavouriteTabOption(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusFavouriteTabOption(0); + } else if (event.key === "End") { + event.preventDefault(); + focusFavouriteTabOption(favouriteTabs.length - 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setTabMenuOpen(false); + window.requestAnimationFrame(() => tabButtonRef.current?.focus()); + } + } useEffect(() => { if (!tabMenuOpen) return undefined; @@ -5816,6 +5940,7 @@ function FavouritesHub({ if (event.key === "Escape") { event.preventDefault(); setTabMenuOpen(false); + window.requestAnimationFrame(() => tabButtonRef.current?.focus()); } } @@ -5873,11 +5998,14 @@ function FavouritesHub({
diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 39ba7b6483..8c5772ceff 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { BookOpen, - Brain, Check, ChevronRight, ClipboardList, @@ -11,7 +10,6 @@ import { FileText, Grid2X2, Globe2, - ListChecks, Mic, Menu, MoreVertical, @@ -19,14 +17,11 @@ import { Pin, PinOff, Plus, - Puzzle, Search, Send, Settings, Sparkles, Star, - Stethoscope, - Users, X, type LucideIcon, } from "lucide-react"; @@ -43,8 +38,6 @@ import { sidebarToolTile as sidebarApplicationTile, textMuted, } from "@/components/ui-primitives"; -import { toolCatalog } from "@/lib/tools"; - type LauncherStatus = "ready" | "recent" | "review_due"; type LauncherCategory = "clinical" | "admin" | "recent"; @@ -87,110 +80,13 @@ const filterOptions = [ ] as const; const sidebarApplicationItems = [ - { label: "Formulation", icon: Brain, href: "/applications" }, - { label: "DSM-5", icon: BookOpen, href: "/applications" }, + { label: "Answer", icon: Sparkles, href: "/?mode=answer" }, + { label: "Documents", icon: FileText, href: "/?mode=documents" }, { label: "Meds", icon: Pill, href: "/?mode=prescribing" }, - { label: "Diffs", icon: Search, href: "/applications" }, + { label: "Favourites", icon: Star, href: "/?mode=favourites" }, ] as const; -function toolById(id: string) { - return toolCatalog.find((tool) => tool.id === id); -} - -function hrefForTool(id: string, fallback: string) { - return toolById(id)?.href ?? fallback; -} - -function isExternalTool(id: string) { - return toolById(id)?.target === "external"; -} - const launcherApps: LauncherApp[] = [ - { - id: "differential-diagnosis", - title: "Differential Diagnosis", - description: "Generate and explore differential diagnoses.", - detail: "Compare likely differentials, rule-outs, red flags, and competing diagnostic explanations.", - href: hrefForTool("differentials", "http://127.0.0.1:53375"), - external: isExternalTool("differentials"), - icon: Stethoscope, - category: "recent", - workflow: "Assessment", - lastUsed: "Today, 10:24 AM", - status: "recent", - sourceToolId: "differentials", - relatedIds: ["specifiers", "services", "clinical-kb-search"], - quickActions: ["Start differential", "Review red flags", "Compare rule-outs", "Open assessment history"], - recentWorkflows: [ - { title: "Chest pain differential", date: "Today, 10:24 AM" }, - { title: "Headache workup", date: "Yesterday, 3:11 PM" }, - { title: "Fatigue assessment", date: "May 10, 2025" }, - ], - }, - { - id: "specifiers", - title: "Specifiers", - description: "Add clinical specifiers and refine conditions.", - detail: "Review severity, course, qualifiers, and specifier language for a diagnosis.", - href: hrefForTool("specifiers", "http://127.0.0.1:58123"), - external: isExternalTool("specifiers"), - icon: ListChecks, - category: "clinical", - workflow: "Assessment", - lastUsed: "Yesterday, 4:15 PM", - status: "ready", - sourceToolId: "specifiers", - relatedIds: ["differential-diagnosis", "formulation", "clinical-kb-search"], - quickActions: ["Open specifier review", "Check course descriptors", "Review severity", "Browse qualifiers"], - recentWorkflows: [ - { title: "Mood episode specifiers", date: "Yesterday, 4:15 PM" }, - { title: "Anxiety course review", date: "May 11, 2025" }, - { title: "Psychosis qualifiers", date: "May 8, 2025" }, - ], - }, - { - id: "services", - title: "Services", - description: "Browse and manage clinical services.", - detail: "Find referral pathways, access points, service matching, and destination options.", - href: hrefForTool("services", "http://127.0.0.1:53174"), - external: isExternalTool("services"), - icon: Users, - category: "clinical", - workflow: "Care planning", - lastUsed: "May 12, 2025", - status: "ready", - sourceToolId: "services", - relatedIds: ["formulation", "documents", "clinical-kb-search"], - quickActions: ["Find referral pathway", "Browse service options", "Review access criteria", "Open saved pathway"], - recentWorkflows: [ - { title: "Community referral options", date: "May 12, 2025" }, - { title: "Crisis pathway review", date: "May 9, 2025" }, - { title: "Outpatient access check", date: "May 7, 2025" }, - ], - }, - { - id: "formulation", - title: "Formulation", - description: "Create and manage clinical formulations.", - detail: "Structure case formulations to support assessment, conceptualisation, care planning, and team reuse.", - href: hrefForTool("formulation", "http://localhost:53210"), - external: isExternalTool("formulation"), - icon: Puzzle, - category: "recent", - workflow: "Care planning", - lastUsed: "Today, 9:02 AM", - status: "recent", - sourceToolId: "formulation", - relatedIds: ["differential-diagnosis", "medication-prescribing", "documents", "clinical-kb-search"], - quickActions: ["Create new formulation", "Browse my formulations", "Shared with me", "Formulation templates"], - recentWorkflows: [ - { title: "Mood & anxiety formulation - Jane D.", date: "Today, 9:02 AM" }, - { title: "Complex case review - M. Smith", date: "May 12, 2025" }, - { title: "Care plan formulation - P. Johnson", date: "May 9, 2025" }, - { title: "Discharge formulation - K. Patel", date: "May 7, 2025" }, - ], - }, { id: "medication-prescribing", title: "Medication Prescribing", @@ -205,7 +101,7 @@ const launcherApps: LauncherApp[] = [ lastUsed: "May 12, 2025", status: "review_due", sourceToolId: "medications", - relatedIds: ["formulation", "documents", "clinical-kb-search"], + relatedIds: ["documents", "clinical-kb-search", "favourites"], quickActions: ["Create new prescription", "Browse formulary", "Review interactions", "Medication templates"], recentWorkflows: [ { title: "Medication review - Sam T.", date: "May 12, 2025" }, @@ -225,7 +121,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "May 10, 2025", status: "ready", - relatedIds: ["clinical-kb-search", "favourites", "formulation"], + relatedIds: ["clinical-kb-search", "favourites", "medication-prescribing"], quickActions: ["Search documents", "Browse library", "Open source PDF", "Review indexed documents"], recentWorkflows: [ { title: "Lithium monitoring guideline", date: "May 10, 2025" }, @@ -245,7 +141,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "Today, 8:45 AM", status: "recent", - relatedIds: ["documents", "clinical-kb-search", "formulation"], + relatedIds: ["documents", "clinical-kb-search", "medication-prescribing"], quickActions: ["Open saved items", "Manage pinned sets", "Review due favourites", "Add current answer"], recentWorkflows: [ { title: "Ward round set", date: "Today, 8:45 AM" }, @@ -265,7 +161,7 @@ const launcherApps: LauncherApp[] = [ workflow: "Reference", lastUsed: "Today, 7:30 AM", status: "ready", - relatedIds: ["documents", "favourites", "differential-diagnosis"], + relatedIds: ["documents", "favourites", "medication-prescribing"], quickActions: ["Ask clinical question", "Search indexed guidelines", "Open document scope", "Review sources"], recentWorkflows: [ { title: "Lithium monitoring search", date: "Today, 7:30 AM" }, @@ -275,18 +171,13 @@ const launcherApps: LauncherApp[] = [ }, ]; -const seedPinnedIds = ["formulation", "differential-diagnosis", "medication-prescribing"]; +const seedPinnedIds = ["clinical-kb-search", "medication-prescribing", "documents"]; const recentActivity = [ - { id: "formulation", label: "Formulation opened", date: "Today, 9:02 AM", icon: Puzzle }, - { - id: "differential-diagnosis", - label: "Differential Diagnosis launched", - date: "Today, 10:24 AM", - icon: Stethoscope, - }, + { id: "clinical-kb-search", label: "Clinical KB Search opened", date: "Today, 7:30 AM", icon: Search }, { id: "medication-prescribing", label: "Medication Prescribing reviewed", date: "May 12, 2025", icon: Pill }, { id: "documents", label: "Documents opened", date: "May 10, 2025", icon: FileText }, + { id: "favourites", label: "Favourites reviewed", date: "Today, 8:45 AM", icon: Star }, ] as const; function appById(id: string) { @@ -937,7 +828,7 @@ function ApplicationsHeader({ export function ApplicationsLauncherPage() { const [query, setQuery] = useState(""); const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); - const [selectedId, setSelectedId] = useState("formulation"); + const [selectedId, setSelectedId] = useState("clinical-kb-search"); const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); @@ -990,8 +881,8 @@ export function ApplicationsLauncherPage() { /> -
-
+
+
@@ -1005,6 +896,37 @@ export function ApplicationsLauncherPage() {
+
+ + + + +
@@ -1120,43 +1042,6 @@ export function ApplicationsLauncherPage() { /> -
- - - - -
); } diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts new file mode 100644 index 0000000000..9ddf9d4b8c --- /dev/null +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -0,0 +1,127 @@ +import { FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; + +export type FavouriteType = "medications" | "documents" | "sources" | "sets"; +export type FavouriteTabId = "all" | FavouriteType; + +export type FavouriteItem = { + id: string; + title: string; + type: Exclude; + set: string; + meta: string; + sourceMeta: string; + primaryAction: string; + icon: typeof FileText; + keywords: string; +}; + +export type FavouriteSet = { + id: string; + title: string; + count: number; + meta: string; + keywords: string; +}; + +export const favouriteTabs: Array<{ + id: FavouriteTabId; + label: string; + shortLabel: string; + icon: typeof FileText; +}> = [ + { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, + { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, + { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, + { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, + { id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder }, +]; + +export const favouriteItems: FavouriteItem[] = [ + { + id: "acamprosate-renal-screen", + title: "Acamprosate renal screen", + type: "medications", + set: "Ward round", + meta: "Medication page · renal cautions · dose notes", + sourceMeta: "3 sources", + primaryAction: "Open", + icon: Pill, + keywords: "acamprosate renal screen medication dose safety ward round pbs", + }, + { + id: "lithium-monitoring-guideline", + title: "Lithium monitoring guideline", + type: "documents", + set: "Prescribing safety", + meta: "PDF · p.4-9 · 2 tables", + sourceMeta: "PDF", + primaryAction: "Ask", + icon: FileText, + keywords: "lithium monitoring guideline blood tests shared care renal toxicity", + }, + { + id: "clozapine-monitoring-table", + title: "Clozapine monitoring table", + type: "sources", + set: "Clozapine clinic", + meta: "Saved table · ANC monitoring", + sourceMeta: "Table", + primaryAction: "Source", + icon: Quote, + keywords: "clozapine monitoring table anc fbc neutrophil clinic", + }, + { + id: "renal-dose-search", + title: "renal dose saved search", + type: "sources", + set: "Ward round", + meta: "Saved query · medicines + documents", + sourceMeta: "Run", + primaryAction: "Run", + icon: Search, + keywords: "renal dose saved search kidney egfr medicines documents", + }, + { + id: "qt-prolongation-quote", + title: "QT prolongation quote", + type: "sources", + set: "Prescribing safety", + meta: "Source card · prescribing safety", + sourceMeta: "Quote", + primaryAction: "Copy", + icon: Quote, + keywords: "qt prolongation quote source card prescribing safety", + }, +]; + +export const favouriteSets: FavouriteSet[] = [ + { + id: "ward-round", + title: "Ward round", + count: 12, + meta: "Medication pages, renal checks, forms", + keywords: "ward round acamprosate lithium renal mht forms", + }, + { + id: "prescribing-safety", + title: "Prescribing safety", + count: 9, + meta: "Dose limits, pregnancy, renal cautions", + keywords: "prescribing safety dose pregnancy renal qt interactions", + }, + { + id: "clozapine-clinic", + title: "Clozapine clinic", + count: 7, + meta: "Monitoring, ANC table, counselling", + keywords: "clozapine clinic monitoring anc table counselling", + }, +]; + +export function favouriteTypeCount(type: FavouriteTabId) { + if (type === "all") return favouriteItems.length + favouriteSets.length; + if (type === "sets") return favouriteSets.length; + return favouriteItems.filter((item) => item.type === type).length; +} + +export const favouritePrototypeCount = favouriteItems.length + favouriteSets.length; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 68089c97d8..2f2f773824 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1,6 +1,15 @@ "use client"; -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react"; +import { + FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type Ref, +} from "react"; import { Activity, @@ -172,6 +181,8 @@ export function MasterSearchHeader({ const dailyActionsMenuRef = useRef(null); const firstDailyActionRef = useRef(null); const modeMenuRef = useRef(null); + const modeButtonRef = useRef(null); + const modeOptionRefs = useRef>([]); const scopeDetailsRef = useRef(null); const scopeSummaryRef = useRef(null); const scopeFilterInputRef = useRef(null); @@ -294,6 +305,51 @@ export function MasterSearchHeader({ } if ("href" in mode && mode.href) window.location.assign(mode.href); } + + const selectedModeIndex = Math.max( + 0, + visibleAppModeOptions.findIndex((mode) => mode.id === selectedAppMode.id), + ); + + function focusModeOption(index: number) { + const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; + modeOptionRefs.current[nextIndex]?.focus(); + } + + function openModeMenuWithFocus(index: number) { + setModeMenuOpen(true); + window.requestAnimationFrame(() => focusModeOption(index)); + } + + function handleModeTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + openModeMenuWithFocus(selectedModeIndex); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + openModeMenuWithFocus(selectedModeIndex - 1); + } + } + + function handleModeOptionKeyDown(event: ReactKeyboardEvent, index: number) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusModeOption(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusModeOption(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusModeOption(0); + } else if (event.key === "End") { + event.preventDefault(); + focusModeOption(visibleAppModeOptions.length - 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setModeMenuOpen(false); + window.requestAnimationFrame(() => modeButtonRef.current?.focus()); + } + } const collectionOptions = useMemo(() => { const values = new Set(); for (const document of documents) { @@ -370,6 +426,7 @@ export function MasterSearchHeader({ if (event.key === "Escape") { event.preventDefault(); setModeMenuOpen(false); + window.requestAnimationFrame(() => modeButtonRef.current?.focus()); } } @@ -643,11 +700,14 @@ export function MasterSearchHeader({ )} >