Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/app/api/search/universal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type UniversalSearchResponse,
} from "@/lib/universal-search";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";
import { appModeIds } from "@/lib/app-modes";

export const runtime = "nodejs";

Expand All @@ -33,6 +34,7 @@ export const runtime = "nodejs";
const universalSearchQuerySchema = z.object({
q: z.string().trim().min(2).max(200),
limit: queryInteger({ fallback: 5, min: 1, max: 10 }),
mode: z.enum(appModeIds).optional(),
domains: z
.string()
.trim()
Expand Down Expand Up @@ -64,10 +66,20 @@ function universalResponse(

export async function GET(request: Request) {
try {
const { q, limit, domains } = parseRequestQuery(request, universalSearchQuerySchema, "Invalid universal query.");
const { q, limit, domains, mode } = parseRequestQuery(
request,
universalSearchQuerySchema,
"Invalid universal query.",
);

if (isDemoMode() || isLocalNoAuthMode()) {
const payload = await runUniversalSearch({ query: q, limitPerDomain: limit, domains, demo: true });
const payload = await runUniversalSearch({
query: q,
limitPerDomain: limit,
domains,
contextMode: mode,
demo: true,
});
return universalResponse({ ...payload, demoMode: true });
}

Expand All @@ -91,6 +103,7 @@ export async function GET(request: Request) {
query: q,
limitPerDomain: limit,
domains,
contextMode: mode,
supabase,
ownerId: access.ownerId,
demo: false,
Expand Down
14 changes: 14 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
} from "@/components/clinical-dashboard/answer-progress";
import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
import {
Expand Down Expand Up @@ -3019,6 +3020,11 @@ export function ClinicalDashboard({
activeModeResultKind === "answer" &&
answerProgressEvents.length > 0 &&
(loading || (Boolean(answer) && answerProgressCompleted));
const universalAlsoMatchesQuery = activeModeResultKind === "answer" ? (latestAnswerQuery ?? query) : query;
const showUniversalAlsoMatches =
(modeSearchSubmitted || activeModeResultKind === "tools" || activeModeResultKind === "favourites") &&
Boolean(universalAlsoMatchesQuery.trim()) &&
(activeModeResultKind === "answer" || activeModeResultKind === "tools" || activeModeResultKind === "favourites");
const showDesktopHomeComposer =
!error &&
(activeModeResultKind === "tools" ||
Expand Down Expand Up @@ -3586,6 +3592,10 @@ export function ClinicalDashboard({
</div>
) : null)}

{showUniversalAlsoMatches && activeModeResultKind !== "answer" ? (
<UniversalSearchAlsoMatches modeId={searchMode} query={universalAlsoMatchesQuery} />
) : null}

{activeModeResultKind === "differentials" ? (
<DifferentialsHome
query={query}
Expand Down Expand Up @@ -3745,6 +3755,10 @@ export function ClinicalDashboard({
}}
/>
) : null}

{showUniversalAlsoMatches && activeModeResultKind === "answer" ? (
<UniversalSearchAlsoMatches modeId={searchMode} query={universalAlsoMatchesQuery} />
) : null}
</section>

{showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null}
Expand Down
2 changes: 2 additions & 0 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {

import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template";
import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog";
import { useResultSort } from "@/components/use-result-sort";
import { cn } from "@/components/ui-primitives";
Expand Down Expand Up @@ -882,6 +883,7 @@ function SearchResultsView({
sortValue={sortValue}
onSortChange={setSortValue}
/>
<UniversalSearchAlsoMatches modeId="differentials" query={query} />
<p
data-testid="differentials-catalogue-notice"
className="flex items-start gap-2 rounded-lg border border-[color:var(--info-border)] bg-[color:var(--info-soft)]/50 px-3 py-1.5 text-xs font-semibold leading-5 text-[color:var(--info)] sm:py-2 sm:text-sm"
Expand Down
24 changes: 14 additions & 10 deletions src/components/clinical-dashboard/document-search-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template";
import { ResultSortControl, SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { useResultSort } from "@/components/use-result-sort";
import { SafeBoldText } from "@/components/SafeBoldText";
import {
Expand Down Expand Up @@ -884,16 +885,19 @@ function DocumentSearchResultsPanelImpl({
return (
<div data-testid="document-search-workspace" className="w-full space-y-3">
{trimmedQuery && !shouldShowHome ? (
<div className="hidden lg:block">
<SearchResultsHeaderBand
modeId="documents"
query={trimmedQuery}
matchCount={sortedMatches.length}
loading={loading}
sortValue={sortValue}
onSortChange={setSortValue}
/>
</div>
<>
<div className="hidden lg:block">
<SearchResultsHeaderBand
modeId="documents"
query={trimmedQuery}
matchCount={sortedMatches.length}
loading={loading}
sortValue={sortValue}
onSortChange={setSortValue}
/>
</div>
<UniversalSearchAlsoMatches modeId="documents" query={trimmedQuery} />
</>
) : null}
{recordMatchCount > 0 ||
matches.length > 0 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command
import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface";
import { appModeIcons } from "@/lib/app-mode-icons";
import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";

type FavouriteType =
"Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential";
Expand Down Expand Up @@ -1006,6 +1007,7 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
<div className="hidden lg:block">
<SearchResultsHeaderBand modeId="favourites" query={query} matchCount={scopedItems.length} />
</div>
<UniversalSearchAlsoMatches modeId="favourites" query={query} />

<ActiveFilterChips
searchTerm={query}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useMemo, useState } from "react";

import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template";
import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { considerationSummaryBadge } from "@/components/clinical-dashboard/medication-considerations";
import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context";
import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel";
Expand Down Expand Up @@ -457,6 +458,7 @@ function MedicationResults({
<div className="hidden lg:block">
<SearchResultsHeaderBand modeId="prescribing" query={query} matchCount={resultCount} />
</div>
<UniversalSearchAlsoMatches modeId="prescribing" query={query} />
<div className="min-w-0 space-y-2 sm:flex sm:items-end sm:justify-between sm:gap-4 sm:space-y-0">
<div className="min-w-0 space-y-1">
<p className="text-xs font-semibold uppercase text-[color:var(--text-soft)]">Medication search</p>
Expand Down
108 changes: 108 additions & 0 deletions src/components/clinical-dashboard/universal-search-also-matches.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import Link from "next/link";

import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search";
import { cn } from "@/components/ui-primitives";
import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes";
import { appModeIcons } from "@/lib/app-mode-icons";
import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context";

export function UniversalSearchAlsoMatches({
modeId,
query,
className,
}: {
modeId: AppModeId;
query: string;
className?: string;
}) {
const trimmedQuery = query.trim();
const universal = useUniversalSearch({
query: trimmedQuery,
enabled: trimmedQuery.length >= 2,
contextMode: modeId,
limitPerDomain: 2,
Comment thread
BigSimmo marked this conversation as resolved.
});
const preferred = new Set(universal.preferredDomains ?? []);
const groups = (() => {
const groupByDomain = new Map(universal.groups.map((group) => [group.kind, group]));
const orderedGroups = (universal.domainOrder ?? universal.groups.map((group) => group.kind))
.map((domain) => groupByDomain.get(domain))
.filter((group): group is NonNullable<typeof group> =>
Boolean(group && !preferred.has(group.kind) && group.items.length > 0),
);
const byMode = new Map<
AppModeId,
{ modeId: AppModeId; items: Array<(typeof universal.groups)[number]["items"][number]> }
>();

for (const group of orderedGroups) {
const targetModeId = universalSearchModeForDomain(group.kind);
if (targetModeId === modeId) continue;
const modeGroup = byMode.get(targetModeId) ?? { modeId: targetModeId, items: [] };
for (const item of group.items) {
if (modeGroup.items.length >= 2) break;
if (!modeGroup.items.some((existing) => existing.href === item.href)) modeGroup.items.push(item);
}
byMode.set(targetModeId, modeGroup);
}

return [...byMode.values()].filter((group) => group.items.length > 0).slice(0, 4);
})();

if (universal.query !== trimmedQuery || groups.length === 0) return null;

return (
<section
className={cn(
"basis-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-subtle)] p-2.5",
className,
)}
aria-label="Matches in other modes"
data-testid="universal-also-matches"
>
<div className="mb-2 flex items-center justify-between gap-3 px-1">
<p className="text-xs font-extrabold text-[color:var(--text-heading)]">Also matches in other modes</p>
<span className="text-2xs font-bold text-[color:var(--text-soft)]">Across Clinical KB</span>
</div>
<div className="grid gap-1 sm:grid-cols-2 xl:grid-cols-4">
{groups.map((group) => {
const targetModeId = group.modeId;
const targetMode = appModeDefinition(targetModeId);
const TargetIcon = appModeIcons[targetModeId];
return (
<div
key={targetModeId}
className="flex min-w-0 items-start gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]">
<TargetIcon className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-2xs font-bold uppercase tracking-wide text-[color:var(--clinical-accent)]">
{targetMode.label}
</span>
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
className="block truncate text-xs font-extrabold text-[color:var(--text)] hover:underline"
>
{item.title}
</Link>
))}
</span>
<Link
href={appModeHomeHref(targetModeId, { query: trimmedQuery, run: true })}
className="shrink-0 text-2xs font-bold text-[color:var(--text-muted)] hover:text-[color:var(--clinical-accent)]"
>
View all
</Link>
</div>
);
})}
</div>
</section>
);
}
Loading
Loading