Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 44 additions & 41 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -360,8 +360,9 @@ export function ClinicalDashboard({
const submittedUrlModeMatchesActive =
!submittedUrlMode ||
(isAppModeId(submittedUrlMode) && isAppModeVisible(submittedUrlMode) && submittedUrlMode === searchMode);
const submittedUrlRunRequested = searchParams.get("run") === "1";
const submittedUrlQuery =
autoRunSearch && searchParams.get("run") === "1" && submittedUrlModeMatchesActive
autoRunSearch && submittedUrlRunRequested && submittedUrlModeMatchesActive
? (searchParams.get("q") ?? searchParams.get("query") ?? "").trim()
: "";

Expand DownExpand Up@@ -423,7 +424,7 @@ export function ClinicalDashboard({
// differentials results view can tell live-edited catalogue results apart
// from evidence that belongs to a previously submitted search.
const [differentialEvidenceQuery, setDifferentialEvidenceQuery] = useState<string | null>(null);
const clearDifferentialModeResultState = useCallback(() => {
const clearModeResultState = useCallback(() => {
resetAnswerThread();
setAnswer(null);
setSources([]);
Expand DownExpand Up@@ -1496,7 +1497,7 @@ export function ClinicalDashboard({
const shouldFocusComposer = searchParams.get("focus") === "1";
const hasUrlQuery = searchParams.has("q") || searchParams.has("query");
const frame = window.requestAnimationFrame(() => {
if (mode === "differentials") clearDifferentialModeResultState();
if (mode === "differentials") clearModeResultState();
setSearchMode(mode);
if (hasUrlQuery) setQuery(nextQuery);
setModeSearchSubmitted(false);
Expand All@@ -1506,7 +1507,7 @@ export function ClinicalDashboard({
if (shouldFocusComposer) focusComposerInput(true);
});
return () => window.cancelAnimationFrame(frame);
}, [searchParams, clearDifferentialModeResultState, focusComposerInput]);
}, [searchParams, clearModeResultState, focusComposerInput]);

useHomeModeSeed({ pathname, searchParams, lastAppMode });

Expand All@@ -1520,7 +1521,7 @@ export function ClinicalDashboard({
urlSearchBootstrappedRef.current = true;
const targetMode = mode;
const frame = window.requestAnimationFrame(() => {
if (targetMode === "differentials") clearDifferentialModeResultState();
if (targetMode === "differentials") clearModeResultState();
setSearchMode(targetMode);
// run=1 URLs name the latest answered question; the composer stays empty
// while an answer thread is active (including after localStorage restore).
Expand All@@ -1529,7 +1530,7 @@ export function ClinicalDashboard({
if (shouldFocusComposer && params.get("run") !== "1") focusComposerInput(true);
});
return () => window.cancelAnimationFrame(frame);
}, [clearDifferentialModeResultState, focusComposerInput]);
}, [clearModeResultState, focusComposerInput]);

const executeSearchRef = useRef(executeSearch);
executeSearchRef.current = executeSearch;
Expand DownExpand Up@@ -1859,7 +1860,7 @@ export function ClinicalDashboard({
setQuery(trimmedQuery);
}
if (modeSearch.kind !== "tools") setModeSearchSubmitted(true);
if (isDifferentialsMode) clearDifferentialModeResultState();
if (isDifferentialsMode) clearModeResultState();

if (modeSearch.kind === "tools") {
setLoading(false);
Expand DownExpand Up@@ -2232,6 +2233,13 @@ export function ClinicalDashboard({
const trimmedQuery = query.trim();
const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery;
const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch;
// Draft shared-home URLs must never auto-submit. A mode pick can update local
// mode/query one frame before the router drops the previous run=1 URL — suppress
// that stale frame only while the URL mode no longer matches local state.
// Intentional run=1 arrivals (Ask-this / crossModeSearch) keep mode+run aligned,
// so they must still submit even if modeChangeFromUiRef is still set.
if (pathname === "/" && !submittedUrlRunRequested) return;
if (modeChangeFromUiRef.current && !submittedUrlModeMatchesActive) return;
if (!autoRunSearch || !submittedSearchText || !canAutoRunMode || loading) return;
if (authStatus === "loading") return;
if (!privateScopeReadyForRoute(routedSearchContext.scopeRef, privateScopeStatus, restoredPrivateScopeRef)) return;
Expand DownExpand Up@@ -2270,6 +2278,9 @@ export function ClinicalDashboard({
void askRef.current(submittedSearchText, routedSearchContext, routedContextChanged);
}, [
autoRunSearch,
pathname,
submittedUrlRunRequested,
submittedUrlModeMatchesActive,
authStatus,
canRunSearch,
loading,
Expand DownExpand Up@@ -2313,7 +2324,7 @@ export function ClinicalDashboard({
return;
}
modeChangeFromUiRef.current = true;
if (mode === "differentials") clearDifferentialModeResultState();
if (mode === "differentials") clearModeResultState();
setQuery(crossQuery);
setModeSearchSubmitted(false);
setLoading(false);
Expand All@@ -2334,6 +2345,15 @@ export function ClinicalDashboard({
}
setSearchMode(mode);
router.push(href);
// Submit immediately for dashboard-owned modes. Auto-run alone is racy here:
// modeChangeFromUiRef stays set until the URL-sync effect runs, and a late or
// suppressed auto-run leaves the run=1 pending shell with no /api/answer call
// (Ask-this bridge). Seed the signature so a later auto-run does not double-fire.
if (mode === "answer" || mode === "documents") {
const navigationContext = { queryMode, scopeFilters } as const;
autoRunSearchSignatureRef.current = searchSubmissionSignature(mode, crossQuery.trim(), navigationContext);
void executeSearch(crossQuery, mode, scopeFilters, queryMode, false);
}
window.requestAnimationFrame(() => {
scrollSurface(mainRef.current, 0, resolveScrollBehavior());
});
Expand DownExpand Up@@ -2621,35 +2641,24 @@ export function ClinicalDashboard({
return;
}

// Results are on screen: carry the query into the newly picked mode rather
// than dropping it. crossModeSearch already owns that transition.
// Outside the shared home, every mode pick returns to `/`. Preserve the
// current question as an unsubmitted draft, but never carry `run=1` into the
// newly selected mode — only an explicit submit may open its result route.
const carriedQuery = query.trim() || submittedUrlQuery.trim();
if (carriedQuery) {
crossModeSearch(mode, carriedQuery);
return;
}

// Nothing to carry: return to the shared home with the mode preselected. This
// always stays on `/`, so the transition is dashboard-internal — no unmount,
// and no chrome flip from an eager mode set before a route landed.
const href = appModeSelectionHref(mode, { queryMode, scopeFilters });
const href = appModeSelectionHref(mode, {
query: carriedQuery || undefined,
queryMode,
scopeFilters,
});
modeChangeFromUiRef.current = true;
if (mode === "differentials") clearDifferentialModeResultState();
setQuery("");
if (mode === "answer") {
resetAnswerThread();
setAnswer(null);
setSources([]);
}
// Dashboard stays mounted on `/`, so an in-flight Answer/documents request
// would still look current after this navigation. Abort and bump the seq
// before clearing UI; otherwise a late applySearchResult can repaint the
// old answer and replaceState a run=1 URL over the shared-home draft.
stopSearch();
clearModeResultState();
Comment thread
cursor[bot] marked this conversation as resolved.
setQuery(carriedQuery);
setModeSearchSubmitted(false);
setLoading(false);
setError(null);
setAnswerProgress(null);
setSearchRelevance(null);
setSearchFacets(null);
setSearchScope(null);
setSourceGovernanceWarnings([]);
setDocumentMatches([]);
setSearchMode(mode);
router.push(href);
// Dashboard-internal mode flips keep the same scroller; jump to top so
Expand DownExpand Up@@ -3010,13 +3019,7 @@ export function ClinicalDashboard({
// docs/search-chrome-behaviour.md — a mode pick must not flip composer reserve.
const isHomeRoute = pathname === "/";
const showSharedHome =
isHomeRoute &&
!error &&
!answer &&
!loading &&
!modeSearchSubmitted &&
!submittedUrlQuery &&
!submittedAnswerSearchActive;
isHomeRoute && !submittedUrlRunRequested && !error && !answer && !loading && !submittedAnswerSearchActive;
const showAnswerPending =
activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error));
const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete";
Expand Down
20 changes: 8 additions & 12 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,19 +653,15 @@ function GlobalStandaloneSearchShellBody({
}
setLastAppMode(mode);

// The mode pill retargets the composer; it no longer navigates to a mode home.
// From anywhere with a query in play, carry that query into the newly picked
// mode's search page rather than dropping it (this is the same transition the
// cross-mode chips make). With nothing to carry, return to the shared home at
// `/` with the mode preselected — that is now the single starting point.
// The mode pill always returns to the shared home. Preserve any current query
// as an unsubmitted draft, but omit `run=1`; only an explicit submit may open
// the selected mode's dedicated search/results surface.
const carriedQuery = query.trim() || requestedQuery.trim();
if (carriedQuery) {
setMobileMenuOpen(false);
router.push(appModeHomeHref(mode, { query: carriedQuery, run: true, queryMode, scopeFilters }));
return;
}

const href = appModeSelectionHref(mode, { queryMode, scopeFilters });
const href = appModeSelectionHref(mode, {
query: carriedQuery || undefined,
queryMode,
scopeFilters,
});
const destination = new URL(href, window.location.origin);
const destinationSearch = destination.search.startsWith("?") ? destination.search.slice(1) : destination.search;
const alreadyOnDestination = pathname === destination.pathname && searchParamString === destinationSearch;
Expand Down
22 changes: 9 additions & 13 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,7 @@ import { Sheet } from "@/components/ui/sheet";
import {
appModeDefinition,
appModeDefinitions,
appModeHomeHref,
appModeSelectionHref,
appModeSearchConfig,
isSearchableAppMode,
visibleAppModeDefinitionsForSession,
Expand DownExpand Up@@ -924,15 +924,11 @@ export function MasterSearchHeader({
// Prefetch only the mode the user is about to choose — the highlighted option
// on open, then whichever option receives focus/pointer while scanning.
//
// Picking a mode no longer navigates; submitting does. So warm the route the
// composer will push to, not the mode home nobody lands on from here any more.
// The destination path is query-independent, so a placeholder query resolves
// the right route (/dsm/search, /factsheets/search, /tools, …) without pinning
// the payload for one specific query.
function prefetchModeDestination(modeId: AppModeId) {
// A pick always returns to the shared home; warm that exact URL rather than a
// mode-owned home or search route the user has not asked to open.
function prefetchModeSelection(modeId: AppModeId) {
if (modeId === searchMode) return;
const destination = appModeHomeHref(modeId, { query: "_", run: true });
const href = destination.split(/[?#]/, 1)[0] || "/";
const href = appModeSelectionHref(modeId);
if (prefetchedModeHrefsRef.current.has(href)) return;
prefetchedModeHrefsRef.current.add(href);
router.prefetch(href, {
Expand All@@ -954,7 +950,7 @@ export function MasterSearchHeader({
closeModeSurfaces();
const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length;
const highlighted = visibleAppModeOptions[nextIndex];
if (highlighted) prefetchModeDestination(highlighted.id);
if (highlighted) prefetchModeSelection(highlighted.id);
const phoneLayout = currentUsesPhoneSearchLayout();
setUsesPhoneSearchLayout(phoneLayout);
setModeMenuFocusIndex(nextIndex);
Expand All@@ -973,7 +969,7 @@ export function MasterSearchHeader({
return;
}
const highlighted = visibleAppModeOptions[selectedModeIndex];
if (highlighted) prefetchModeDestination(highlighted.id);
if (highlighted) prefetchModeSelection(highlighted.id);
setUsesPhoneSearchLayout(currentUsesPhoneSearchLayout());
setModeMenuFocusIndex(selectedModeIndex);
setModeMenuOpen(true);
Expand DownExpand Up@@ -1043,8 +1039,8 @@ export function MasterSearchHeader({
aria-label={`${mode.label}. ${mode.description}`}
tabIndex={active ? 0 : -1}
data-sheet-autofocus={usesPhoneSearchLayout && index === modeMenuFocusIndex ? "true" : undefined}
onFocus={() => prefetchModeDestination(mode.id)}
onPointerEnter={() => prefetchModeDestination(mode.id)}
onFocus={() => prefetchModeSelection(mode.id)}
onPointerEnter={() => prefetchModeSelection(mode.id)}
onKeyDown={(event) => handleModeOptionKeyDown(event, index)}
onClick={() => selectAppMode(mode)}
className={cn(
Expand Down
13 changes: 7 additions & 6 deletions tests/audit-navigation-auth-regressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,15 +135,16 @@ describe("audit navigation and auth regressions", () => {
"function handleModeTriggerKeyDown(",
);

expect(masterSearchHeaderSource).toContain("function prefetchModeDestination(modeId: AppModeId)");
expect(masterSearchHeaderSource).toContain("function prefetchModeSelection(modeId: AppModeId)");
expect(masterSearchHeaderSource).toContain("const href = appModeSelectionHref(modeId)");
expect(masterSearchHeaderSource).toContain("router.prefetch(href,");
expect(masterSearchHeaderSource).toContain("onInvalidate:");
expect(modeOptions).toContain("onFocus={() => prefetchModeDestination(mode.id)}");
expect(modeOptions).toContain("onPointerEnter={() => prefetchModeDestination(mode.id)}");
expect(modeOptions).toContain("onFocus={() => prefetchModeSelection(mode.id)}");
expect(modeOptions).toContain("onPointerEnter={() => prefetchModeSelection(mode.id)}");
// Menu-open paths warm only the highlighted option — never every visible home.
expect(openModeMenuWithFocus).toContain("prefetchModeDestination(highlighted.id)");
expect(toggleModeMenu).toContain("prefetchModeDestination(highlighted.id)");
expect(masterSearchHeaderSource).not.toContain("function prefetchModeDestinations(");
expect(openModeMenuWithFocus).toContain("prefetchModeSelection(highlighted.id)");
expect(toggleModeMenu).toContain("prefetchModeSelection(highlighted.id)");
expect(masterSearchHeaderSource).not.toContain("function prefetchModeSelections(");
expect(masterSearchHeaderSource).not.toContain("visibleAppModeOptions.forEach((mode) => router.prefetch");
expect(masterSearchHeaderSource).not.toContain(
"new Set(visibleAppModeOptions.map((mode) => appModeHomeHref(mode.id)))",
Expand Down
19 changes: 8 additions & 11 deletions tests/mode-menu-prefetch.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,16 +5,13 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { appModeHomeHref, visibleAppModeDefinitionsForSession, type AppModeId } from "@/lib/app-modes";
import { appModeSelectionHref, visibleAppModeDefinitionsForSession, type AppModeId } from "@/lib/app-modes";

/**
* The route the composer will push to for `modeId`. Picking a mode no longer
* navigates — submitting does — so the menu warms the mode's *search* destination
* rather than its home. The path is query-independent, so a placeholder query
* resolves the right route without pinning one query's payload.
* The shared-home URL the mode picker itself will open for `modeId`.
*/
function modeDestinationPath(modeId: AppModeId) {
return appModeHomeHref(modeId, { query: "_", run: true }).split(/[?#]/, 1)[0] || "/";
function modeSelectionHref(modeId: AppModeId) {
return appModeSelectionHref(modeId);
}

const router = vi.hoisted(() => ({
Expand DownExpand Up@@ -90,11 +87,11 @@ describe("mode menu destination prefetch", () => {
router.prefetch.mockReset();
});

it("prefetches a mode search destination when the user points at that option", async () => {
it("prefetches the shared-home selection URL when the user points at a mode", async () => {
const user = userEvent.setup();
const documents = guestModeHomes().find((mode) => mode.id === "documents");
expect(documents).toBeTruthy();
const documentsHref = modeDestinationPath("documents");
const documentsHref = modeSelectionHref("documents");

render(<MasterSearchHeader {...headerProps()} />);
await user.click(screen.getByRole("button", { name: /Mode Answer/i }));
Expand All@@ -111,7 +108,7 @@ describe("mode menu destination prefetch", () => {

it("warms a mode again after Next invalidates its cached payload", async () => {
const user = userEvent.setup();
const documentsHref = modeDestinationPath("documents");
const documentsHref = modeSelectionHref("documents");

render(<MasterSearchHeader {...headerProps()} />);
await user.click(screen.getByRole("button", { name: /Mode Answer/i }));
Expand All@@ -137,7 +134,7 @@ describe("mode menu destination prefetch", () => {
expect(answerIndex).toBeGreaterThanOrEqual(0);
const previous = modes[(answerIndex - 1 + modes.length) % modes.length];
expect(previous.id).not.toBe("answer");
const previousHref = modeDestinationPath(previous.id);
const previousHref = modeSelectionHref(previous.id);

render(<MasterSearchHeader {...headerProps()} />);
const trigger = screen.getByRole("button", { name: /Mode Answer/i });
Expand Down
Loading
Loading