diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..863c7f745 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# SessionStart hook for Claude Code on the web. +# The app is engine-strict on Node 24.x / npm 11.x, but web containers ship an +# older Node on PATH, so nothing installs or runs until Node 24 is present. +# Installs Node 24 into $HOME/.node24 (cached with the container), exposes it +# via $CLAUDE_ENV_FILE, and installs npm dependencies. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +NODE_VERSION="24.13.0" +NODE_HOME="$HOME/.node24" +NODE_BIN="$NODE_HOME/node-v${NODE_VERSION}-linux-x64/bin" + +current_major="$(node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' || echo 0)" +if [ "$current_major" != "24" ] && [ ! -x "$NODE_BIN/node" ]; then + echo "[session-start] Installing Node ${NODE_VERSION} (found v${current_major:-none})" + mkdir -p "$NODE_HOME" + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + | tar -xJ -C "$NODE_HOME" +fi + +if [ -x "$NODE_BIN/node" ]; then + export PATH="$NODE_BIN:$PATH" + echo "export PATH=\"$NODE_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE" +fi + +echo "[session-start] Using node $(node -v) / npm $(npm -v)" + +cd "$CLAUDE_PROJECT_DIR" +# npm ci keeps the lockfile untouched (npm install rewrites peer/optional +# metadata and dirties the worktree); skip entirely when the cached container +# already has node_modules. +if [ ! -d node_modules ]; then + npm ci --no-audit --no-fund + echo "[session-start] Dependencies installed" +else + echo "[session-start] node_modules already present, skipping install" +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..e06b0338e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/data/differentials-snapshot.json b/data/differentials-snapshot.json index d425a138c..a680edf12 100644 --- a/data/differentials-snapshot.json +++ b/data/differentials-snapshot.json @@ -27730,13 +27730,6 @@ } ], "presets": [ - { - "id": "scenario-presets", - "query": "# Scenario Presets", - "signals": [], - "entryIds": [], - "presentationSlugs": [] - }, { "id": "1-older-adult-acute-confusion", "query": "older adult acute confusion", @@ -27788,14 +27781,6 @@ } ], "redFlagFlows": [ - { - "id": "red-flag-flows", - "title": "# Red Flag Flows", - "entryId": "", - "presentationSlug": "red-flag-flows", - "bedsideQuestions": "", - "keyRedFlags": "" - }, { "id": "1-suicide", "title": "1. Suicide", @@ -27873,15 +27858,7 @@ "medicine": ["medication", "iatrogenic", "toxicity"], "qt": ["qtc", "arrhythmia", "toxicity"], "catatonia": ["mutism", "shutdown", "stupor"], - "field": ["weight"], - "presentation": ["2.6"], - "clinicalhinge": ["2.2"], - "mustnotmiss": ["2.0"], - "immediateactions": ["1.7"], - "mimics": ["1.5"], - "investigations": ["1.2"], - "tags": ["1.1"], - "optiontext": ["1.0"] + "field": ["weight"] }, "governance": { "version": "v10", diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 053ed64a4..039c65d42 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -189,3 +189,9 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Shipped on `claude/search-cross-mode-links-qscj1n`:** post-answer "Also in your library" strip (`src/lib/cross-mode-links.ts` + `CrossModeLinksStrip`), thread-wide entity fallback, word-boundary field matching in `rankCatalogRecords` (substring hits like "renal" inside "adrenaline" no longer count as name/title matches), `fields=index` slim mode on `/api/medications`, cross-mode click telemetry via `/api/search/interaction` (`crossMode` target, `metadata.interaction: "cross_mode_link_open"`), the same strip on documents-mode results, and answer `crossModes` command-surface parity. - **Verification debt:** `npm run verify:release` (and its governance/eval gates) has not been run for this workstream — the authoring environment has no live Supabase/OpenAI keys. Run it from a secrets-equipped environment after merge; the cross-mode surface itself is additive/navigational, so `verify:cheap` + `verify:ui` are the load-bearing local gates. - **Telemetry note:** cross-mode clicks write `rag_query_misses` rows with `clicked_document_id: null` and the target mode/slug in `metadata`; retrieval-quality reviews that aggregate misses by document should filter on `metadata.interaction`. + +## Answer-thread Back button: URL and visible answer can disagree (2026-07-06) + +- **Behaviour:** inside an Answer thread, browser Back changes the URL (`/?mode=answer&q=A&run=1` ← `...q=B&run=1`) but the rendered answer/thread does not change. Two guards produce this: the auto-run effect skips when `run=1` is already present, and the answer view early-returns when an answer is already on screen (`ClinicalDashboard`). This is thread persistence by design, not an accident — clearing the thread on Back would destroy in-progress clinical context. +- **Open product decision:** either (a) accept that the URL is not a faithful pointer to the visible answer inside a thread (current state), or (b) make Back restore the previous question's answer from thread history. Option (b) needs answer-thread state keyed by URL and re-render on `popstate`/param change; it is a deliberate feature, not a bug fix. +- **Guardrail until decided:** do not "fix" the skipped auto-run on Back in isolation — re-running the search on Back would clobber the visible thread with a regenerated (and possibly different) answer, which is worse than either deliberate option. diff --git a/scripts/lib/parse-differentials-export.ts b/scripts/lib/parse-differentials-export.ts index d48067711..4a5715cac 100644 --- a/scripts/lib/parse-differentials-export.ts +++ b/scripts/lib/parse-differentials-export.ts @@ -400,8 +400,16 @@ function mergeDiagnosisRecords(existing: DifferentialRecord, incoming: Different }; } -export function parseScenarioPresets(markdown: string): DifferentialScenarioPreset[] { +// Splitting on "## " leaves any document preamble (the top-level "# Title" +// heading and intro prose) as a phantom first section; it must be dropped or +// it becomes a bogus record (e.g. a "# Scenario Presets" preset). +function markdownSections(markdown: string) { const sections = markdown.split(/(?:^|\n)##\s+/).filter(Boolean); + return markdown.trimStart().startsWith("## ") ? sections : sections.slice(1); +} + +export function parseScenarioPresets(markdown: string): DifferentialScenarioPreset[] { + const sections = markdownSections(markdown); return sections.map((section, index) => { const lines = section.split("\n"); const titleLine = lines[0]?.trim() ?? `Preset ${index + 1}`; @@ -430,7 +438,7 @@ export function parseScenarioPresets(markdown: string): DifferentialScenarioPres } export function parseRedFlagFlows(markdown: string): DifferentialRedFlagFlow[] { - const sections = markdown.split(/(?:^|\n)##\s+/).filter(Boolean); + const sections = markdownSections(markdown); return sections.map((section, index) => { const title = section.split("\n")[0]?.trim() ?? `Flow ${index + 1}`; const entryId = section.match(/Entry\s+(\d+[A-Z]?)/i)?.[1]?.toUpperCase() ?? ""; @@ -463,7 +471,9 @@ export function parseSearchAliases(markdown: string): Record { const values = match[2] .split(",") .map((item) => item.trim().toLowerCase()) - .filter(Boolean); + // The export also carries field-weight tables (e.g. "| tags | 1.1 |"); + // bare numbers are ranking weights, not clinical synonyms. + .filter((item) => Boolean(item) && !/^\d+(\.\d+)?$/.test(item)); if (token && values.length) aliases[token] = values; } return aliases; diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4711cafb0..cb996d8fa 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -14,7 +14,6 @@ import { medicationValidationStatus, rowGovernance, rowToMedicationRecord, - type MedicationRecordRow, } from "@/lib/medication-records"; import { medicationToSearchResult, diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 29b864a64..310c3c055 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -15,7 +15,6 @@ import { rowGovernance, rowToServiceRecord, type RegistryRecordKind, - type RegistryRecordRow, } from "@/lib/registry-records"; import { fetchOwnerRegistryRowsWithSeed } from "@/lib/registry-seed"; import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/services"; diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index 38a1c38de..6048c35e0 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -14,5 +14,7 @@ export default async function FavouritesPage({ searchParams }: FavouritesPagePro const params = searchParams ? await searchParams : {}; const query = firstSearchParam(params.q)?.trim() ?? ""; - return ; + // No key={query} remount: query is a pure prop, and remounting on query + // change wiped the set/type/view/sort selections when clearing a search. + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 868b63df5..d7872603c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4129,7 +4129,6 @@ export function ClinicalDashboard({ ) : null ) : showAnswerHome ? ( setSearchMode("documents")} onUploadDocument={openUploadDrawer} desktopComposerSlotId={desktopHomeComposerSlotId} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 212e522f4..0b27f596c 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2166,6 +2166,7 @@ export function DocumentViewer({ }, [ authStatus, authorizationHeader, + canUsePrivateApis, canViewSourceDocuments, clientDemoMode, documentId, diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 00c30ef41..68fcbfd91 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -35,12 +35,10 @@ export function CopyButton({ } export function AnswerEmptyState({ - onPickSample: _onPickSample, onSearchDocuments, onUploadDocument, desktopComposerSlotId, }: { - onPickSample: (sample: string) => void; onSearchDocuments: () => void; onUploadDocument: () => void; desktopComposerSlotId?: string; diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index cb6593489..939c3cc44 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -645,13 +645,19 @@ function FavouritesTable({ })} {tableRows.length === 0 ? ( - - -

No favourites match

-

- Clear filters or search to show saved clinical work. -

- + {/* The Evidence column is hidden below lg, so span 5 there and 6 at lg+. */} + {[ + { colSpan: 5, className: "px-4 py-10 text-center lg:hidden" }, + { colSpan: 6, className: "hidden px-4 py-10 text-center lg:table-cell" }, + ].map(({ colSpan, className }) => ( + + +

No favourites match

+

+ Clear filters or search to show saved clinical work. +

+ + ))} ) : null} diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index c4bc6cc38..208717f02 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -95,10 +95,14 @@ function ResultTabs({ formsCount }: { formsCount: number }) { + ); @@ -227,20 +231,22 @@ function ToggleRow({ function RefineRail() { return ( -
+

Refine

-
+
@@ -252,7 +258,12 @@ function RefineRail() { function NextSteps() { const steps = [ - { icon: FileText, title: "Open Form 4A", subtitle: "View the Transport order form" }, + { + icon: FileText, + title: "Open Form 4A", + subtitle: "View the Transport order form", + href: "/forms/transport-crisis-form", + }, { icon: Workflow, title: "View transport pathway", subtitle: "Explore before, current, parallel, after" }, { icon: FileText, title: "Check source evidence", subtitle: "See matching sections and snippets" }, ]; @@ -260,23 +271,32 @@ function NextSteps() {

Next steps

- {steps.map(({ icon: Icon, title, subtitle }) => ( - - ))} + {steps.map(({ icon: Icon, title, subtitle, href }) => { + const rowClassName = cn( + "grid w-full grid-cols-[28px_1fr_18px] items-center gap-3 rounded-lg border-b border-[color:var(--border)] py-3 text-left transition last:border-b-0 lg:grid-cols-[32px_1fr_18px] lg:px-2 lg:py-4", + href ? "hover:bg-[color:var(--surface-subtle)]" : "cursor-not-allowed opacity-70", + searchFocusRing, + ); + const rowContent = ( + <> + + + {title} + {subtitle} + + + + ); + return href ? ( + + {rowContent} + + ) : ( + + ); + })}
); @@ -336,8 +356,10 @@ function PathwayPanel() {
- +
); } @@ -527,8 +549,10 @@ function MobilePathway() { diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx index aeba0ddf1..dee63477d 100644 --- a/src/components/master-document-flow-mockups.tsx +++ b/src/components/master-document-flow-mockups.tsx @@ -274,18 +274,16 @@ function findDocument(slug: string | null) { return documents.find((document) => document.slug === slug) ?? defaultDocument; } -function findEvidence(document: DocumentFixture, id: string | null) { - return document.evidence.find((evidence) => evidence.id === id) ?? primaryEvidence(document); +function findEvidence(document: DocumentFixture, id: string | null): EvidenceFixture | undefined { + // Only this document's own evidence: falling back to another document's + // fixture rendered mismatched evidence under the wrong title on deep links. + return document.evidence.find((evidence) => evidence.id === id) ?? document.evidence[0]; } function searchHref(query = defaultQuery) { return documentsSearchHref({ query }); } -function primaryEvidence(document: DocumentFixture) { - return document.evidence[0] ?? defaultDocument.evidence[0]; -} - function evidenceForType(document: DocumentFixture, type: "all" | EvidenceType) { if (type !== "all") return document.evidence.find((evidence) => evidence.type === type); return document.evidence[0]; @@ -917,7 +915,7 @@ export function MasterDocumentSearch() { ); } -function DocumentPreview({ selectedEvidence }: { selectedEvidence: EvidenceFixture }) { +function DocumentPreview({ selectedEvidence }: { selectedEvidence?: EvidenceFixture }) { return (
@@ -963,7 +961,7 @@ function DocumentPreview({ selectedEvidence }: { selectedEvidence: EvidenceFixtu key={cell} className="border border-[color:var(--border)] px-3 py-3 font-medium text-[color:var(--text-heading)]" > - {selectedEvidence.type === "table" && rowIndex === 0 ? ( + {selectedEvidence?.type === "table" && rowIndex === 0 ? ( {cell} ) : ( cell @@ -1218,9 +1216,11 @@ export function MasterDocumentReader() {
-

Highlighted hit 1 of 3

+

+ {selectedEvidence ? "Highlighted hit 1 of 3" : "No extracted evidence"} +

- {selectedEvidence.title} + {selectedEvidence ? selectedEvidence.title : "This document has no extracted evidence yet."}

@@ -1417,7 +1417,7 @@ export function MasterDocumentReader() { document={document} evidence={evidence} query={query} - selected={evidence.id === selectedEvidence.id} + selected={evidence.id === selectedEvidence?.id} /> ))} @@ -1524,6 +1524,54 @@ export function MasterEvidenceDetail() { const query = searchParams.get("q")?.trim() || defaultQuery; const document = findDocument(searchParams.get("document")); const evidence = findEvidence(document, searchParams.get("evidence") ?? searchParams.get("chunk")); + + if (!evidence) { + return ( + +
+
+

No extracted evidence

+

+ {document.title} has no extracted evidence yet, so there is no evidence detail to show. +

+
+ + Open document + + + Back to results + +
+
+
+
+ ); + } + + return ; +} + +function MasterEvidenceDetailContent({ + document, + evidence, + query, +}: { + document: DocumentFixture; + evidence: EvidenceFixture; + query: string; +}) { const [tab, setTab] = useState( evidence.type === "quote" ? "Quote" : evidence.type === "image" ? "Image" : "Table", ); diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index d3c41e848..6fe93282f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -13,6 +13,7 @@ import { DollarSign, ExternalLink, Phone, + ShieldAlert, ShieldCheck, SlidersHorizontal, Users, @@ -22,6 +23,7 @@ import { import { useMemo, useState } from "react"; import { cn } from "@/components/ui-primitives"; +import { ModeHomeStatusNotice } from "@/components/mode-home-template"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { SearchResultsEmptyState, @@ -437,8 +439,12 @@ export function ServicesNavigatorPage() { const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); const registryLoading = registry.status === "loading"; + // Demo mode is served by the registry API as status "ready" with fixture + // records, so unauthorized/error must not silently fall back to fixtures — + // the home and detail pages surface the same conditions as notices. + const registryBlocked = registry.status === "unauthorized" || registry.status === "error"; const searchableRecords = useMemo( - () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + () => (registry.status === "ready" ? registry.records : []), [registry.records, registry.status], ); const matches = useMemo(() => { @@ -508,6 +514,22 @@ export function ServicesNavigatorPage() { > {registryLoading ? ( + ) : registryBlocked ? ( + registry.status === "unauthorized" ? ( + + ) : ( + + ) ) : query.trim() && scopedMatches.length === 0 ? ( { + const presets = parseScenarioPresets( + `# Scenario Presets\n\nIntro prose mentioning Entry 26.\n\n## 1. Older adult acute confusion\n- **Query:** \`older adult acute confusion\``, + ); + expect(presets).toHaveLength(1); + expect(presets[0]?.query).toBe("older adult acute confusion"); + + const aliases = parseSearchAliases("| tags | 1.1 |\n| delirium | confusion, 2.0, fluctuation |"); + expect(aliases.tags).toBeUndefined(); + expect(aliases.delirium).toEqual(["confusion", "fluctuation"]); + }); }); describe("differential records", () => {