-
-
+
+
Best answer
-
{best.title}
-
+
+
+ {best.title}
+
+
+
+
+ Open page
+
+
{onToggle ? : null}
-
{best.subtitle}
-
- {best.tags.map((tag) => (
+
+ {best.subtitle}
+
+
+ {visibleTags.map((tag) => (
{tag}
))}
+ {hiddenTagCount > 0 ? {`+${hiddenTagCount}`} : null}
);
@@ -582,13 +688,13 @@ function UrgencyCard({ results }: { results: DifferentialResult[] }) {
Highest urgency
- {urgentResults.map((result, index) => (
+ {urgentResults.map((result) => (
-
+
{result.title}
@@ -602,11 +708,13 @@ function SourceStatusCard({
sourceCount,
evidenceState,
loading,
+ sourcesChecked,
onRunSourceSearch,
}: {
sourceCount: number;
evidenceState: DifferentialEvidenceState;
loading: boolean;
+ sourcesChecked: boolean;
onRunSourceSearch: () => void;
}) {
const hasSourceEvidence = evidenceState === "source-backed";
@@ -623,27 +731,35 @@ function SourceStatusCard({
{hasSourceEvidence ? "Source-backed" : "Guided local differential"}
- {hasSourceEvidence ? `${sourceCount.toLocaleString()} sources` : "Evidence pending"}
+ {hasSourceEvidence
+ ? `${sourceCount.toLocaleString()} sources`
+ : sourcesChecked
+ ? "0 matches"
+ : "Evidence pending"}
- {hasSourceEvidence ? "Imported catalogue" : "Run source search"}
+ {hasSourceEvidence ? "Imported catalogue" : sourcesChecked ? "Sources checked" : "Run source search"}
{hasSourceEvidence
? `${sourceCount.toLocaleString()} source${sourceCount === 1 ? "" : "s"}`
- : "Not yet checked"}
+ : sourcesChecked
+ ? "No matches"
+ : "Not yet checked"}
{hasSourceEvidence
? "Catalogue matches are ranked from the imported, locally reviewed differentials library."
- : "Showing reviewed local differential records. Run source search to validate against indexed documents."}
+ : sourcesChecked
+ ? "No indexed documents matched this query. Showing catalogue-only results."
+ : "Showing reviewed local differential records. Run source search to validate against indexed documents."}
- {!hasSourceEvidence ? (
+ {!hasSourceEvidence && !sourcesChecked ? (
void;
}) {
const safetyLead = results.find((result) => result.status === "emergent") ?? best;
@@ -691,6 +809,7 @@ function InterpretationRail({
sourceCount={sourceCount}
evidenceState={evidenceState}
loading={loading}
+ sourcesChecked={sourcesChecked}
onRunSourceSearch={onRunSourceSearch}
/>
@@ -722,29 +841,54 @@ function SearchResultsView({
[catalog.matches],
);
const [kindFilter, setKindFilter] = useState<"all" | "presentation" | "diagnosis">("all");
+ const [sortMode, setSortMode] = useState("relevance");
const [selectedIds, setSelectedIds] = useState>(() => new Set());
- // Selection and filter follow the ranked result set: seed the top two for
- // comparison and drop stale ids whenever a new query changes the results
+ // Selection, filter, and sort follow the ranked result set: seed the top two
+ // for comparison and drop stale ids whenever a new query changes the results
// (render-time sync, matching the repo's set-state-in-render pattern).
const resultSignature = results.map((result) => result.id).join("|");
const [lastResultSignature, setLastResultSignature] = useState(resultSignature);
if (lastResultSignature !== resultSignature) {
setLastResultSignature(resultSignature);
setKindFilter("all");
- setSelectedIds(new Set(results.slice(0, 2).map((result) => result.id)));
+ setSortMode("relevance");
+ setSelectedIds(
+ new Set(
+ results
+ .filter((result) => result.kind === "diagnosis")
+ .slice(0, 2)
+ .map((result) => result.id),
+ ),
+ );
}
const presentationCount = results.filter((result) => result.kind === "presentation").length;
const diagnosisCount = results.length - presentationCount;
- const visibleResults = kindFilter === "all" ? results : results.filter((result) => result.kind === kindFilter);
+ const visibleResults = sortResults(
+ kindFilter === "all" ? results : results.filter((result) => result.kind === kindFilter),
+ sortMode,
+ );
const best = results[0] ?? null;
- const selectedCount = selectedIds.size;
+ // Same lead the desktop interpretation rail uses for its safety card.
+ const safetyLead = results.find((result) => result.status === "emergent") ?? best;
+ const comparisonIds = useMemo(
+ () =>
+ new Set(
+ results
+ .filter((result) => result.kind === "diagnosis" && selectedIds.has(result.id))
+ .map((result) => result.id),
+ ),
+ [results, selectedIds],
+ );
+ const selectedCount = comparisonIds.size;
// Catalogue results follow composer edits live, but document evidence only
// updates on an executed source search — treat evidence fetched for a
// different query as pending so the two panels never claim to be in sync.
const evidenceIsCurrent = (evidenceQuery ?? "").trim().toLowerCase() === query.trim().toLowerCase();
const currentDocumentMatches = evidenceIsCurrent ? documentMatches : undefined;
const hasSourceEvidence = Boolean(currentDocumentMatches?.length);
+ // Distinguish between "not searched yet" (undefined) and "searched with zero results" (defined but empty)
+ const sourcesChecked = evidenceIsCurrent && documentMatches !== undefined;
const evidenceState: DifferentialEvidenceState = hasSourceEvidence ? "source-backed" : "guided";
// Count the sources that actually matched this search, never the whole
// indexed library - the surrounding copy states these reflect real matches.
@@ -771,17 +915,17 @@ function SearchResultsView({
data-testid="differentials-search-results"
className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 pb-[calc(9rem+env(safe-area-inset-bottom))] sm:px-4 lg:px-0 lg:pb-0"
>
-
-
-
+ {/* Query context lives here on every breakpoint — on phones this is the
+ only place the submitted query is visible above the fold. */}
+
@@ -870,35 +1014,42 @@ function SearchResultsView({
-
- {hasSourceEvidence ? (
-
- ) : (
+ {!hasSourceEvidence && !sourcesChecked ? (
+
- )}
- {hasSourceEvidence ? "Compare top 3" : "Run source search"}
-
-
-
- Filters
-
+ {loading ? "Searching sources" : "Run source search"}
+
+ ) : sourcesChecked && !hasSourceEvidence ? (
+
+
+ No source matches found
+
+ ) : null}
+
toggleSelected(best.id)}
+ onToggle={best.kind === "diagnosis" ? () => toggleSelected(best.id) : undefined}
/>
+ {safetyLead?.safety ? (
+
+
+
+ Safety first:
+ {safetyLead.safety}
+
+
+ ) : null}
-
+
{visibleResults.length} result{visibleResults.length === 1 ? "" : "s"}
{" "}
- · {hasSourceEvidence ? "Ranked by relevance" : "Catalogue ranking"}
+ · {hasSourceEvidence ? "Source-backed" : "Catalogue ranking"}
-
- Sort
-
-
+
- {!hasSourceEvidence ? (
+ {!hasSourceEvidence && !sourcesChecked ? (
-
- Showing ranked catalogue records. Source-library evidence has not been checked for this query yet.
+
+ Sources not checked for this query yet.
-
- {loading ? "Searching sources" : "Run source search"}
+
+ {loading ? "Searching…" : "Run source search"}
+ ) : sourcesChecked && !hasSourceEvidence ? (
+
+
+
+ No source matches found for this query.
+
+
) : null}
@@ -950,25 +1105,29 @@ function SearchResultsView({
const globalIndex = results.findIndex((entry) => entry.kind === result.kind && entry.id === result.id);
const isBest = result.kind === best.kind && result.id === best.id;
return (
-
+ // The best answer is already featured above the phone list,
+ // so its ranked duplicate only renders from the desktop
+ // breakpoint (hiding the wrapper keeps the grid gap clean).
+
toggleSelected(result.id)}
- />
-
-
- toggleSelected(result.id)}
+ onToggle={result.kind === "diagnosis" ? () => toggleSelected(result.id) : undefined}
/>
+ {!isBest ? (
+
+ toggleSelected(result.id) : undefined}
+ />
+
+ ) : null}
);
})}
@@ -976,20 +1135,30 @@ function SearchResultsView({
View all catalogue matches ({results.length})
-
+
-
-
- Compare selected ({selectedCount})
-
-
+ {selectedCount > 0 ? (
+
+
+ Compare selected
+
+ {selectedCount}
+
+
+
+ ) : (
+
+
+ Tick results to compare them side by side
+
+ )}
)}
- {best ? : null}
+ {best ? (
+
+ ) : null}
Clinical decision support only. Review before use.
diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx
index bf334662d..f84fb3dea 100644
--- a/src/components/differentials/differential-presentation-workflow-page.tsx
+++ b/src/components/differentials/differential-presentation-workflow-page.tsx
@@ -593,11 +593,25 @@ function MobileTabs({ workflow }: { workflow: DifferentialPresentationWorkflow }
export function DifferentialPresentationWorkflowPage({
query = "",
presentationSlug = "acute-confusion-encephalopathy",
+ selectedIds = [],
}: {
query?: string;
presentationSlug?: string;
+ selectedIds?: string[];
}) {
- const workflow = getPresentationWorkflow(presentationSlug) ?? acuteConfusionPresentationWorkflow;
+ const baseWorkflow = getPresentationWorkflow(presentationSlug) ?? acuteConfusionPresentationWorkflow;
+ const requestedIds = new Set(selectedIds);
+ const workflow = requestedIds.size
+ ? (() => {
+ let selectedCount = 0;
+ const candidates = baseWorkflow.candidates.map((candidate) => {
+ const selected = requestedIds.has(candidate.slug);
+ if (selected) selectedCount += 1;
+ return { ...candidate, selected };
+ });
+ return { ...baseWorkflow, candidates, selectedCount };
+ })()
+ : baseWorkflow;
const candidates = getCandidates(workflow);
return (
diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts
index 4e6ed2290..7723e77b7 100644
--- a/src/lib/differentials.ts
+++ b/src/lib/differentials.ts
@@ -75,6 +75,36 @@ export function getPresentationWorkflow(slug: string | null | undefined) {
return differentialPresentations().find((presentation) => presentation.id === normalizedSlug) ?? null;
}
+export function getPresentationWorkflowForDiagnosisIds(ids: Iterable) {
+ const requestedIds = new Set(Array.from(ids, (id) => id.trim().toLowerCase()).filter(Boolean));
+ if (!requestedIds.size) return null;
+
+ let bestMatch: DifferentialPresentationWorkflow | null = null;
+ let bestMatchCount = 0;
+ for (const presentation of differentialPresentations()) {
+ const matchCount = presentation.candidates.reduce(
+ (count, candidate) => count + (requestedIds.has(candidate.slug) ? 1 : 0),
+ 0,
+ );
+ if (matchCount > bestMatchCount) {
+ bestMatch = presentation;
+ bestMatchCount = matchCount;
+ }
+ }
+ return bestMatch;
+}
+
+export function getPresentationWorkflowSelectionForDiagnosisIds(ids: Iterable) {
+ const diagnosisIds = Array.from(new Set(Array.from(ids, (id) => id.trim().toLowerCase()).filter(Boolean)));
+ const workflow = getPresentationWorkflowForDiagnosisIds(diagnosisIds);
+ if (!workflow) return null;
+ const candidateIds = new Set(workflow.candidates.map((candidate) => candidate.slug));
+ return {
+ workflow,
+ diagnosisIds: diagnosisIds.filter((id) => candidateIds.has(id)),
+ };
+}
+
export const acuteConfusionPresentationWorkflow: DifferentialPresentationWorkflow =
getPresentationWorkflow("acute-confusion-encephalopathy") ?? differentialPresentations()[0]!;
diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts
index be7322124..be87015fe 100644
--- a/tests/differentials.test.ts
+++ b/tests/differentials.test.ts
@@ -10,6 +10,8 @@ import {
differentialStaticParams,
getDifferentialRecord,
getPresentationWorkflow,
+ getPresentationWorkflowForDiagnosisIds,
+ getPresentationWorkflowSelectionForDiagnosisIds,
loadDifferentialSnapshot,
rankDifferentialRecords,
rankPresentationWorkflows,
@@ -20,6 +22,24 @@ import {
type DifferentialRecordMatch,
} from "@/lib/differentials";
+describe("presentation workflow routing", () => {
+ it("routes selected diagnoses to a workflow that contains them", () => {
+ expect(getPresentationWorkflowForDiagnosisIds(["bipolar-depression-mixed-state"])?.id).toBe(
+ "suicidal-ideation-suicide-attempt-self-harm",
+ );
+ expect(getPresentationWorkflowForDiagnosisIds([])).toBeNull();
+ });
+
+ it("forwards only diagnoses supported by the selected workflow", () => {
+ const selection = getPresentationWorkflowSelectionForDiagnosisIds([
+ "wernicke-encephalopathy",
+ "major-depressive-disorder",
+ ]);
+ expect(selection?.workflow.id).toBe("acute-confusion-encephalopathy");
+ expect(selection?.diagnosisIds).toEqual(["wernicke-encephalopathy"]);
+ });
+});
+
const deliriumEntry = `=== ENTRY 1 ===
Delirium / Acute Confusion / Encephalopathy
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 93fb4b121..5877ba930 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -31,11 +31,6 @@ async function gotoApp(page: Page, path: string) {
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
}
-async function gotoCriticalApp(page: Page, path: string) {
- await page.goto(path, { waitUntil: "domcontentloaded" });
- await expect(page.locator("#main-content, main").first()).toBeVisible({ timeout: 30_000 });
-}
-
async function expectSingleMedicationPage(page: Page) {
// The medication route renders inside GlobalMockupSearchShell, whose Suspense
// fallback and resolved client subtree both render `children`. During a
@@ -299,7 +294,11 @@ async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) {
documentId?: string;
documentIds?: string[];
};
- const query = body.query ?? "What monitoring is required?";
+ const query = typeof body.query === "string" ? body.query.trim() : "";
+ if (!query || query.length > 2000) {
+ await route.fulfill({ status: 400, json: { error: "A query between 1 and 2000 characters is required." } });
+ return;
+ }
options.onAnswerRequest?.(query);
if (options.answerDelayMs) {
await new Promise((resolve) => setTimeout(resolve, options.answerDelayMs));
@@ -817,7 +816,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } });
});
- await gotoCriticalApp(page, "/");
+ await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0);
@@ -887,7 +886,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
test("tablet shows icon rail without drawer trigger or expand control @critical", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/?mode=answer");
+ await gotoApp(page, "/?mode=answer");
await waitForDemoDashboardReady(page);
await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0);
@@ -973,7 +972,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/");
+ await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
const settings = accountSettingsDialog(page);
@@ -1132,7 +1131,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
test("demo answer flow reaches a source-backed answer @critical", async ({ browserName, page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/");
+ await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
const question = "What clozapine monitoring items are shown in the table image?";
@@ -1878,13 +1877,17 @@ test.describe("Clinical KB UI smoke coverage", () => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
let requestCount = 0;
+ let resolveCurrentResponse!: () => void;
+ const currentResponseDelivered = new Promise((resolve) => {
+ resolveCurrentResponse = resolve;
+ });
await page.route(/\/api\/search$/, async (route) => {
requestCount += 1;
const currentRequest = requestCount;
if (currentRequest === 1) await new Promise((resolve) => setTimeout(resolve, 500));
const sourceCount = currentRequest === 1 ? 2 : 1;
- await route
- .fulfill({
+ try {
+ await route.fulfill({
json: {
documentMatches: Array.from({ length: sourceCount }, (_, index) => ({
document_id: `00000000-0000-4000-8000-00000000000${index}`,
@@ -1893,8 +1896,11 @@ test.describe("Clinical KB UI smoke coverage", () => {
score: 0.9 - index * 0.1,
})),
},
- })
- .catch(() => undefined);
+ });
+ if (currentRequest > 1) resolveCurrentResponse();
+ } catch (error) {
+ if (currentRequest > 1) throw error;
+ }
});
await page.goto("/differentials?q=acute+confusion&run=1", { waitUntil: "domcontentloaded" });
@@ -1905,11 +1911,10 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
await expect.poll(() => requestCount).toBeGreaterThan(baselineRequestCount);
+ await currentResponseDelivered;
const sourceStatus = page.getByRole("heading", { name: "Source status" }).locator("..");
const singularSourceCount = sourceStatus.getByText("1 source", { exact: true });
await expect(singularSourceCount).toBeVisible();
- await page.waitForTimeout(600);
- await expect(singularSourceCount).toBeVisible();
await expect(sourceStatus).not.toContainText("2 sources");
});
@@ -2029,7 +2034,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/?mode=prescribing&q=acamprosate%20renal%20dose&run=1");
+ await gotoApp(page, "/?mode=prescribing&q=acamprosate%20renal%20dose&run=1");
const globalSearchInput = page.getByTestId("global-search-input");
await expect(page.getByRole("button", { name: "Mode Medication" })).toBeVisible({ timeout: 30_000 });
@@ -2043,7 +2048,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectSingleMedicationPage(page);
await expect(page.getByRole("link", { name: "Back to medication search" })).toBeVisible();
- await gotoCriticalApp(page, "/mockups/medication-prescribing");
+ await gotoApp(page, "/mockups/medication-prescribing");
await expect(page).toHaveURL(/\/medications\/acamprosate$/);
await expectSingleMedicationPage(page);
expect(parentNodeErrors).toEqual([]);
@@ -2086,7 +2091,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
test("document search mode lists matching documents and scope actions @critical", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/");
+ await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
await switchToDocumentSearchMode(page);
@@ -2173,7 +2178,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
test("search regressions avoid fetch errors and open viewer hits @critical", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
- await gotoCriticalApp(page, "/");
+ await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
await switchToDocumentSearchMode(page);
@@ -2186,7 +2191,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByRole("heading", { name: /Search command centre|Find source evidence/ })).toBeVisible();
const demoDocId = "11111111-1111-4111-8111-111111111111";
- await gotoCriticalApp(page, `/documents/${demoDocId}?chunk=55555555-5555-4555-8555-555555555555`);
+ await gotoApp(page, `/documents/${demoDocId}?chunk=55555555-5555-4555-8555-555555555555`);
await expect(page).toHaveURL(/chunk=55555555-5555-4555-8555-555555555555/);
await expect(page.locator("#source-evidence").getByTestId("highlighted-source-passage")).toContainText(
"Patient safety plan should include",
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 4740c1cf9..8b9c599fc 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -1294,6 +1294,12 @@ test.describe("Clinical KB tools launcher", () => {
expect(desktopTableBox?.width ?? 0).toBeGreaterThan(900);
await expectNoPageHorizontalOverflow(page);
+ await gotoLauncher(page, "/differentials/presentations?ids=wernicke-encephalopathy");
+ await expect(page).toHaveURL(/ids=wernicke-encephalopathy/);
+ await expect(
+ page.getByRole("heading", { name: `Selected differentials (1 of ${workflow.totalCount})` }).first(),
+ ).toBeVisible();
+
await page.setViewportSize({ width: 390, height: 844 });
await gotoLauncher(page, "/differentials/presentations");