diff --git a/docs/answer-page-redesign-handover.md b/docs/answer-page-redesign-handover.md index 2e487ca76..ec8c16b20 100644 --- a/docs/answer-page-redesign-handover.md +++ b/docs/answer-page-redesign-handover.md @@ -731,3 +731,85 @@ panel: card, the rail cards, the drawer and print are fixed together. Display only. --- + +### 12.9 What direction B cost, and how it was repaid (2026-09-07) + +Keeping one panel was right. Keeping the narrower one was a coverage loss nobody wrote down at +the time, and it is invisible from a screenshot. + +`UniversalSearchAlsoMatches` is fed by `/api/search/universal` and reaches twelve domains. +`CrossModeLinksSection` was fed by four catalogues loaded in the browser — medications, +services, forms, differentials. So from 2026-08-26 a clinician who asked a question in Answer +mode could never be shown a matching DSM diagnosis, dictionary term, formulation, specifier, +therapy or tool, though all sixteen other modes could. The register in +`tests/universal-also-matches-mode-coverage.test.ts` recorded the exemption honestly and +recorded the wrong thing about it: that Answer answers cross-mode discovery elsewhere, without +saying that "elsewhere" reached a third of the surface the tray reached. + +**Widened rather than replaced.** The line keeps its disclosure, its place inside the answer +thread between the governed caution and the follow-ups, its walk back through earlier turns when +a follow-up drops the entity name, and the per-record `Search in ` control and +`cross_mode_link_open` telemetry the mode-level tray has never had. What changed is only where +the links come from: `buildCrossModeLinksFromUniversalSearch` maps universal-search groups into +the same `CrossModeLink` shape, gated on a query term naming the record at a word boundary — the +same weight and minimum term length the differentials path already used, because a content-only +score cannot be trusted on a question full of filler. + +Three things hold it inside the 12.8 decision rather than undoing it: + +- The lookup is opt-in per surface (`universalMode`) and off by default. Every mode but Answer + mounts the tray, so a surface that ran this too would print one record twice — which is what + §12.8 removed, and what the prescribing page later shipped anyway. +- The consumed domains and their complement come from one array. `documents` is excluded because + an answer already cites its documents in the evidence rail and the drawer, and the four + catalogue domains are excluded because the local half already resolves them. +- It is withdrawn while a generation is in flight, matching `answer && !loading` on the tray, so + the lookup never races the answer stream and an open tray never holds matches for the question + being replaced. An errored or empty group contributes nothing. + +The strip's ceiling stays at four, the number the catalogue half alone allowed. Five was set +while this was still a collapsed disclosure, where an extra row cost nothing until the reader +opened it; with the strip open at every width every row is unconditional height on the answer +surface, so the old ceiling holds and this change stays about reach rather than size. The cap +only binds when the catalogue half filled the strip on its own, which is a question already well +served; the common answer names none of those four record types, and that is where the widened +reach earns its place. + +Calculators, Factsheets, Sources, On Call and Favourites are still unreachable from here, and +not by omission — they contribute no cross-entity search domain at all +(`universal-search-mode-context.ts`), so nothing can resolve a link to them from any surface. + +**The line keeps its single toggle, and now rests open (owner decision, 2026-09-07).** Both ends +of this were built and looked at on the day the data was widened, and both were wrong. + +Closed by default, as direction B shipped it, hides the matches behind a tap nobody has a reason +to take: a closed tray cannot show what it holds, so the reader has to spend the tap to find out +whether it was worth spending. Removing the toggle entirely, which was the first correction, put +four unconditional rows into the answer's evidence stack with no way to put them away — roughly +90px of resting height on a phone, on the densest surface in the product, which is the trade +§12.8 made in the other direction for a reason. + +The hybrid is one control that starts open. The matches are there to be read without a tap, and +the space is one tap away for a reader who wants the answer and the follow-ups closer together. +`variant="line"` and `CrossModeLinksLine` are the presentation again, so the `card` strip stays +what the DSM diagnosis page and the mockups use. + +The header lost its preview of the first three record names in the same change. Those names +earned their room while the line rested closed and had no other way to say what it held; open, +they were naming the cards immediately below them. The header is label, rule and count now, at +every width, which is the device the "Also matches" tray already uses. + +Worth recording because it nearly went the other way: `CrossModeLinksLine` was almost deleted +while it briefly had no consumer. `check:dead-code-candidate` refused the removal — the symbol +was introduced on 2026-08-26, inside the gate's 30-day window ("likely awaiting its consumer") — +and the deletion was dropped rather than forced past the threshold. A day later it was the +presentation again. The gate's heuristic looked wrong at the time and was right in substance. + +Three browser tests carry the disclosure as their contract. The 1280px case asserts the rail's +own computed display in both states rather than the trigger's word for it: width is where this +broke before, when a `hidden` beside a `md:flex` in one class list lost to the media-query rule +from 768px up and left a rail painted open while its trigger reported it closed. Now that the +line rests open, the same mechanic would hide a broken collapse instead, which is why the closed +state is the one pinned on computed display. + +--- diff --git a/docs/branch-review-records/173c8dc623608fe60a08ef7070b302e3649b721fce26c5690363f025b23b6d14.record.md b/docs/branch-review-records/173c8dc623608fe60a08ef7070b302e3649b721fce26c5690363f025b23b6d14.record.md new file mode 100644 index 000000000..7279b1f0f --- /dev/null +++ b/docs/branch-review-records/173c8dc623608fe60a08ef7070b302e3649b721fce26c5690363f025b23b6d14.record.md @@ -0,0 +1 @@ +| 2026-09-07 | claude/side-navigation-reliability-0sg48b | ee0cf48b5ae370475de1526bd843ac9f62ef55a2 | prlanded | PR #2637 merged 2026-09-05T11:11:08Z by BigSimmo via true merge commit ee0cf48 (auto-merge armed, three update-branch merges of main by the owner beforehand). Tree of ee0cf48 is identical to branch tip a943909 (git diff --stat empty); fix commit 1a6adff is an ancestor of origin/main; no late or orphaned commits; remote branch already deleted by GitHub. Sidebar shortcuts now switch mode in place via onSelectMode and useHomeModeSeed consumes the UI-change flag one-shot. | git diff --stat ee0cf48 a943909 empty; git merge-base --is-ancestor 1a6adff origin/main true; PR read via GitHub MCP state=closed merged=true; CI on head a943909 green before merge | diff --git a/docs/outstanding-issues-inbox/45cda6a2-725f-4fc7-bbd8-2350c7280d72.json b/docs/outstanding-issues-inbox/45cda6a2-725f-4fc7-bbd8-2350c7280d72.json new file mode 100644 index 000000000..386b83e48 --- /dev/null +++ b/docs/outstanding-issues-inbox/45cda6a2-725f-4fc7-bbd8-2350c7280d72.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "45cda6a2-725f-4fc7-bbd8-2350c7280d72", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "PR required failed repeatedly on a UI-only branch with no cause ever identified", + "detail": "Session 2026-09-07, branch claude/jolly-keller-vy36h2 (the also-matches disclosure). The pr-required aggregate went red several times while every local gate was green: 15 static gates, whole-tree Prettier, 229 + 161 focused unit tests, 129 + 28 Chromium tests. A push-cancellation theory was offered and then disproved by a run with a quiet window. CI log access was blocked by the babysit marker for the whole session, so the failing job was never read and the branch merged with the cause unknown. Next action: on the next unexplained pr-required failure, unlock the marker first (CLAUDE_ALLOW_PR_FOLLOW=1) and read the failing job log before theorising. Two contributing factors worth ruling out: a second agent was pushing merge commits to the same branch concurrently, and main merged about twelve changes during the session.", + "source": "docs/branch-review-records + session 331c4d4d", + "issueUlid": "01M1XP1FE7K06J63XSYVBS40HA" + } +} diff --git a/docs/outstanding-issues-inbox/4d148590-e6c2-4dde-b07d-111f312c827e.json b/docs/outstanding-issues-inbox/4d148590-e6c2-4dde-b07d-111f312c827e.json new file mode 100644 index 000000000..bbd16da76 --- /dev/null +++ b/docs/outstanding-issues-inbox/4d148590-e6c2-4dde-b07d-111f312c827e.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "4d148590-e6c2-4dde-b07d-111f312c827e", + "createdOn": "2026-09-07", + "action": "update", + "payload": { + "id": "#ZKR5YK", + "detail": "UPDATE 2026-09-06: this row is now partly stale and its Sources example is gone. PR #2672 consolidated /sources: its four-card mode home was deleted and the bare path now redirects to the shared home at /?mode=sources, so Sources no longer owns a home of its own and the mode pill landing there is correct rather than a miss. The row's remaining live cases are Favourites and Tools. Favourites is settled by an explicit owner decision (#V0EDR4, 2026-08-23: two purpose-specific surfaces are deliberate), which leaves Tools as the only open instance: changeMode still builds appModeSelectionHref, so picking Tools lands on /?mode=tools and the legacy client-only launcher (applications-launcher-page.tsx, data-testid tools-home) rather than canonical /tools (ToolsSearchResultsPage). Verified on main 0177bed by a browser probe: /?mode=tools renders tools-home. Documents and Medication also dropped off this row for the same reason as Sources - both bare paths redirect now. STILL AN OWNER RULING, not a defect to fix unilaterally: the pill deliberately always returns to the shared home, so special-casing Tools trades one inconsistency for another, and removing the second launcher means deleting a component that six assertions in tests/favourites-auth-gate.dom.test.tsx still render directly. Re-scope this row to Tools only.", + "baseRowFingerprint": "4a2c45298f781607394bb54bdde280518d7e1e6c1706ae00e2b05526613fa232" + } +} diff --git a/docs/outstanding-issues-inbox/aca18f9d-f748-40cd-918a-51ecb9b91f1c.json b/docs/outstanding-issues-inbox/aca18f9d-f748-40cd-918a-51ecb9b91f1c.json new file mode 100644 index 000000000..24a35d825 --- /dev/null +++ b/docs/outstanding-issues-inbox/aca18f9d-f748-40cd-918a-51ecb9b91f1c.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "aca18f9d-f748-40cd-918a-51ecb9b91f1c", + "createdOn": "2026-09-07", + "action": "cancel", + "payload": { + "requestId": "4d148590-e6c2-4dde-b07d-111f312c827e", + "reason": "Withdrawn in favour of the competing request 4036f064, a done on the same row backed by an actual implementation (outcome: mode pill now routes to dedicated mode homes). My request only re-scoped #ZKR5YK to Tools after verifying that its Sources, Documents and Medication examples are gone (all three bare paths redirect now) and that Favourites is settled by owner decision #V0EDR4. A re-scope is pointless against a close that supersedes the whole row, and two pending mutations on one row block reconciliation for everyone (#686WHW). The verified findings are recorded in the PR that carried this session's fixes instead, so they are not lost if that close is ever reopened." + } +} diff --git a/docs/site-map.md b/docs/site-map.md index 34df1cdd7..6c9996f72 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -117,7 +117,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir | Specifiers | `/?mode=specifiers` | `/specifiers/search?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | | Formulation | `/?mode=formulation` | `/formulation/search?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | | Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; submitted searches resolve to `/?mode=prescribing&q=…&run=1`. | -| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias. | +| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` redirects here, so Tools has one surface. | | Calculators | `/?mode=calculators` | `/calculators/search?q=PHQ-9&focus=1&run=1` | `/calculators/search` is the browsable calculator catalogue and scored-results surface; an empty query lists every calculator. | | Factsheets | `/?mode=factsheets` | `/factsheets/search?q=sertraline&focus=1&run=1` | `/factsheets/search` is the query-and-filter surface; `/factsheets/topics` organises the library by category; `/factsheets/[slug]` records. | | Dictionary | `/?mode=dictionary` | `/dictionary/search?q=MSE&focus=1&run=1` | `/dictionary/search` is one catalogue for both searching and browsing; `/dictionary/browse` redirects to it. Also `/topics`, `/topics/[slug]`, `/compare` and `/dictionary/[slug]` records; `/dictionary/sources` redirects to Sources. | @@ -1437,27 +1437,27 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Route ownership/source map -| Area | Source | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Root dashboard and query modes | `src/app/(search-app)/page.tsx, src/lib/app-modes.ts` | -| Global shell layouts | `src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx` | -| Services | `src/app/(search-app)/services, src/lib/services.ts, src/app/api/registry/records` | -| Forms | `src/app/(search-app)/forms, src/lib/forms.ts, src/app/api/registry/records` | -| Favourites | `src/app/(search-app)/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | -| Differentials | `src/app/(search-app)/differentials, src/lib/differentials.ts` | -| DSM-5 Diagnosis | `src/app/(search-app)/dsm, src/components/dsm, src/lib/dsm.ts` | -| Specifiers | `src/app/(search-app)/specifiers, src/components/specifiers, src/lib/specifiers.ts` | -| Formulation | `src/app/(search-app)/formulation, src/components/formulation, src/lib/formulation.ts` | -| Medications | `src/app/(search-app)/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | -| Documents | `src/app/(search-app)/documents, src/lib/document-flow-routes.ts` | -| Calculators | `src/app/(search-app)/calculators, src/components/calculators` | -| Therapy Compass | `src/app/(search-app)/therapy-compass, src/lib/therapies.ts` | -| Factsheets | `src/app/(search-app)/factsheets, src/components/factsheets` | -| Dictionary | `src/app/(search-app)/dictionary, src/lib/dictionary.ts` | -| Safety Plan | `src/app/safety-plan, src/components/patient-safety-plan.tsx` | -| Privacy | `src/app/privacy` | -| Tools | `src/app/(search-app)/tools, src/components/tools/tools-search-results-page.tsx, src/components/applications-launcher-page.tsx (the retained `/?mode=tools` alias)` | -| Sources | `src/app/(search-app)/sources, src/components/sources, src/lib/sources` | -| On Call | `src/app/(search-app)/on-call, src/components/on-call` | -| Caring Contacts workspace | `src/app/caring-contacts, src/components/caring-contacts/workspace, src/lib/caring-contacts-routes.ts` | -| Mockups | `src/app/mockups` | +| Area | Source | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Root dashboard and query modes | `src/app/(search-app)/page.tsx, src/lib/app-modes.ts` | +| Global shell layouts | `src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx` | +| Services | `src/app/(search-app)/services, src/lib/services.ts, src/app/api/registry/records` | +| Forms | `src/app/(search-app)/forms, src/lib/forms.ts, src/app/api/registry/records` | +| Favourites | `src/app/(search-app)/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | +| Differentials | `src/app/(search-app)/differentials, src/lib/differentials.ts` | +| DSM-5 Diagnosis | `src/app/(search-app)/dsm, src/components/dsm, src/lib/dsm.ts` | +| Specifiers | `src/app/(search-app)/specifiers, src/components/specifiers, src/lib/specifiers.ts` | +| Formulation | `src/app/(search-app)/formulation, src/components/formulation, src/lib/formulation.ts` | +| Medications | `src/app/(search-app)/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | +| Documents | `src/app/(search-app)/documents, src/lib/document-flow-routes.ts` | +| Calculators | `src/app/(search-app)/calculators, src/components/calculators` | +| Therapy Compass | `src/app/(search-app)/therapy-compass, src/lib/therapies.ts` | +| Factsheets | `src/app/(search-app)/factsheets, src/components/factsheets` | +| Dictionary | `src/app/(search-app)/dictionary, src/lib/dictionary.ts` | +| Safety Plan | `src/app/safety-plan, src/components/patient-safety-plan.tsx` | +| Privacy | `src/app/privacy` | +| Tools | `src/app/(search-app)/tools, src/components/tools/tools-search-results-page.tsx, src/components/tools/tool-quick-actions.tsx` | +| Sources | `src/app/(search-app)/sources, src/components/sources, src/lib/sources` | +| On Call | `src/app/(search-app)/on-call, src/components/on-call` | +| Caring Contacts workspace | `src/app/caring-contacts, src/components/caring-contacts/workspace, src/lib/caring-contacts-routes.ts` | +| Mockups | `src/app/mockups` | diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index fdde9909c..a1720f4a2 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -275,7 +275,7 @@ const routeOwnershipRows = [ ["Privacy", "src/app/privacy"], [ "Tools", - "src/app/(search-app)/tools, src/components/tools/tools-search-results-page.tsx, src/components/applications-launcher-page.tsx (the retained `/?mode=tools` alias)", + "src/app/(search-app)/tools, src/components/tools/tools-search-results-page.tsx, src/components/tools/tool-quick-actions.tsx", ], ["Sources", "src/app/(search-app)/sources, src/components/sources, src/lib/sources"], ["On Call", "src/app/(search-app)/on-call, src/components/on-call"], @@ -532,7 +532,7 @@ function renderModePageIndex() { home: appModeHomeHref("tools"), search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), detail: - "Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias.", + "Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` redirects here, so Tools has one surface.", }, { mode: "Calculators", diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 44f39da69..51265086b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,4 +1,6 @@ "use client"; + +import { useSubmittedModeSearch } from "@/components/clinical-dashboard/use-submitted-mode-search"; import { useSettingsState } from "./clinical-dashboard/SettingsStateProvider"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -112,6 +114,7 @@ import { import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, + resultUsable, applyRenamedDocumentToAnswer, compactScopeFilters, hasActiveIndexingWork, @@ -179,7 +182,6 @@ import { useAppPreferences } from "@/components/clinical-dashboard/use-app-prefe import type { SearchFacets } from "@/components/clinical-dashboard/document-search-results"; import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { - answerPayloadIsUsable, classifyAnswerError, createAnswerRequestWatchdog, generateQuerySuggestions, @@ -343,9 +345,11 @@ function ClinicalDashboardContent({ (retainTarget = false) => scheduleComposerFocus(composerInputRef, retainTarget), [composerInputRef], ); - const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => - Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), - ); + const { modeSearchSubmitted, submittedModeQuery, setModeSearchSubmitted } = useSubmittedModeSearch({ + autoRunSearch, + initialQuery, + initialSearchMode, + }); // focus=1 means "focus on entry", not "keep the dock focused after results". // Suppress autofocus once a mode search/answer has been submitted so hide-on- // scroll can reclaim chrome on result views (Answer and other bottom docks). @@ -1745,13 +1749,6 @@ function ClinicalDashboardContent({ throw lastError; } - function resultUsable(payload: SearchResultModePayload) { - if (payload.kind === "documents") { - return payload.sources.length > 0 || payload.documentMatches.length > 0; - } - return answerPayloadIsUsable(payload.payload); - } - // Audit M10: monotonically increasing token identifying the latest search. // Concurrent searches (URL-bootstrap auto-search racing a user submit) can // resolve out of order; only the latest request may commit answer/sources/ @@ -1861,7 +1858,7 @@ function ClinicalDashboardContent({ if (modeSearch.resultKind !== "answer") { setQuery(trimmedQuery); } - if (modeSearch.kind !== "tools") setModeSearchSubmitted(true); + if (modeSearch.kind !== "tools") setModeSearchSubmitted(true, trimmedQuery); if (isDifferentialsMode) clearModeResultState(); if (modeSearch.kind === "tools") { @@ -2149,7 +2146,7 @@ function ClinicalDashboardContent({ if (!trimmedSearchText) return; setSearchMode("prescribing"); setQuery(trimmedSearchText); - setModeSearchSubmitted(true); + setModeSearchSubmitted(true, trimmedSearchText); setLoading(false); setError(null); setAnswerProgress(null); @@ -2467,7 +2464,7 @@ function ClinicalDashboardContent({ if (targetMode === "documents") { setQuery(trimmedSearchText); setSearchMode("documents"); - setModeSearchSubmitted(true); + setModeSearchSubmitted(true, trimmedSearchText); setLoading(false); setError(null); setAnswerProgress(null); @@ -2495,7 +2492,7 @@ function ClinicalDashboardContent({ setQuery(trimmedSearchText); setSearchMode(targetMode); - setModeSearchSubmitted(true); + setModeSearchSubmitted(true, trimmedSearchText); setLoading(true); setError(null); const targetModeSearch = appModeSearchConfig(targetMode); @@ -2990,7 +2987,13 @@ function ClinicalDashboardContent({ activeModeResultKind === "answer" && answerProgressEvents.length > 0 && (loading || (Boolean(answer) && answerProgressCompleted)); - const universalAlsoMatchesQuery = activeModeResultKind === "answer" ? (latestAnswerQuery ?? query) : query; + // Answer mode already keyed off the generated answer's query. Every other mode keys off + // the submitted query for the same reason: typing without pressing Enter must not fetch + // cross-mode matches for the draft, nor replace the tray and its count with results the + // primary cards do not share. Tools and Favourites never record a submission, so they + // fall through to `query`, which is the only query they have. + const universalAlsoMatchesQuery = + activeModeResultKind === "answer" ? (latestAnswerQuery ?? query) : (submittedModeQuery ?? query); // Answer-mode also-matches wait for a completed generation (`answer && !loading`) // so the panel never sits under the drafting skeleton/stepper. Tools/Favourites // still mount on submission. Follow-ups hide the panel while loading so stale @@ -3014,11 +3017,10 @@ function ClinicalDashboardContent({ activeModeResultKind === "services" || activeModeResultKind === "forms") && modeSearchSubmitted)); - // `/tools` owns the tools catalogue, but the legacy `/?mode=tools` entry - // still renders this dashboard path. Keep both entry points composer-free so - // the alias cannot mount a second ownership model (hero/page/dock) behind - // the canonical route's no-composer contract. Modes that only borrow the - // `tools` result kind remain on the shared home and are intentionally exempt. + // `/tools` owns the tools catalogue and stays composer-free, so a dashboard + // path reaching the tools result kind must not mount a second ownership model + // (hero/page/dock) behind it. Modes that only borrow the `tools` result kind + // remain on the shared home and are intentionally exempt. const toolsDirectoryWithoutComposer = activeModeResultKind === "tools" && !showSharedHome; const showDesktopHomeComposer = !error && @@ -3317,7 +3319,24 @@ function ClinicalDashboardContent({ onAsk={ask} onClearQuery={() => { setQuery(""); - if (!answer) setModeSearchSubmitted(false); + if (answer) return; + // Clearing an initial Answer request must invalidate and abort it + // before navigation. Otherwise its late result can repaint the + // answer and restore the submitted URL after the shared home opens. + if (loading) stopSearch(); + setModeSearchSubmitted(false); + // Clear the URL too, or `showSharedHome` (which reads `run=1` off the URL) + // stays suppressed while the mode branch, now query-less, falls back to the + // retired home its route was consolidated away from — `medication-home` and + // `document-search-empty-state`, both pinned by browser cases. + // `appModeSelectionHref`, not `appModeHomeHref`: these modes are all + // dashboard-owned, and `appModeHomeHref("prescribing")` answers + // `/medications`, which only 307s back here. + if (submittedUrlRunRequested) { + router.replace(appModeSelectionHref(searchMode, { focus: true, queryMode, scopeFilters }), { + scroll: false, + }); + } }} onClearScope={() => setSelectedDocumentIds([])} onQueryModeChange={setQueryMode} @@ -3815,6 +3834,7 @@ function ClinicalDashboardContent({ followUpSuggestions={answerFollowUpSuggestions} onPickFollowUpSuggestion={handleFollowUpSuggestionPick} followUpSuggestionsDisabled={loading} + generating={loading} onScopeDocument={handleScopeDocument} /> diff --git a/src/components/answer-chat-perfected-v2-mockups.tsx b/src/components/answer-chat-perfected-v2-mockups.tsx index 7c2f9774e..f9944a27b 100644 --- a/src/components/answer-chat-perfected-v2-mockups.tsx +++ b/src/components/answer-chat-perfected-v2-mockups.tsx @@ -1315,7 +1315,7 @@ function ConsecutiveLineSpecimen() {

Marks on consecutive lines

-

+

void; followUpSuggestionsDisabled?: boolean; + /** A generation is in flight — true for a follow-up, while the prior answer is still on screen. */ + generating?: boolean; /** Narrows the search to one document, from the source drawer's overflow menu. */ onScopeDocument?: (documentId: string) => void; }) { @@ -594,7 +597,22 @@ function StagedAnswerResultSurfaceImpl({ is one thing; removing a navigation route on the same evidence is another. Still collapsed, still below the caution. */} {crossModeQueries?.length && onCrossModeSearch ? ( - + // `universalMode` is what lets this line reach DSM, Formulation, + // Specifiers, Therapy, Dictionary and Tools, which no catalogue in + // the browser can resolve. Answer is the only surface that may pass + // it: every other mode mounts `UniversalSearchAlsoMatches`, and a + // second lookup there would print the same records twice. + // + // Withdrawn while generating, matching the rule the sibling tray + // follows on this mode (`answer && !loading` in ClinicalDashboard), + // so the lookup never races the answer stream and the open tray + // never holds matches for the question being replaced. + ) : null} {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( diff --git a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts index 077955445..7996b17ae 100644 --- a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts +++ b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts @@ -1,3 +1,4 @@ +import type { SearchResultModePayload } from "@/components/clinical-dashboard/clinical-dashboard-payloads"; // Pure domain helpers extracted from ClinicalDashboard.tsx (#51 — centralise // domain logic into a reusable, unit-tested module). These are verbatim moves: // behaviour is unchanged, and the module is framework-free (no React) so each @@ -6,7 +7,7 @@ import type { SetupCheck } from "@/components/clinical-dashboard/DocumentManagerPanel"; import { navigationHashes } from "@/components/clinical-dashboard/dashboard-contracts"; -import { makeSearchError } from "@/components/clinical-dashboard/search-utils"; +import { answerPayloadIsUsable, makeSearchError } from "@/components/clinical-dashboard/search-utils"; import type { ClinicalDocument, ImportBatch, IngestionJob, RagAnswer, RelatedDocument } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import type { ClinicalQueryMode } from "@/lib/clinical-query-mode"; @@ -15,7 +16,7 @@ import type { ClinicalQueryMode } from "@/lib/clinical-query-mode"; // `normalizedPollDelay`. Shared with the dashboard's polling loop. export const setupRecheckPollMs = 60_000; -/** Keep the retained `/?mode=tools` launcher separate from every other idle mode home. */ +/** Keep the tools result kind separate from every other idle mode home. */ export function shouldShowSharedHome({ pathname, mode, @@ -251,3 +252,10 @@ export function mergeDocumentRefresh(current: ClinicalDocument[], updates: Clini }; }); } + +export function resultUsable(payload: SearchResultModePayload) { + if (payload.kind === "documents") { + return payload.sources.length > 0 || payload.documentMatches.length > 0; + } + return answerPayloadIsUsable(payload.payload); +} diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index e0f4d933e..f56ee8f71 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -7,12 +7,16 @@ import { ArrowUpRight, ChevronDown, Layers, Search, type LucideIcon } from "luci import { cn, eyebrowText, semanticChipTone, sourceCard, type SemanticChipTone } from "@/components/ui-primitives"; import { logCrossModeLinkOpen } from "@/components/clinical-dashboard/source-actions"; +import { shouldRunUniversalAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches-state"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; +import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; import { appModeIcons } from "@/lib/app-mode-icons"; import { appModeHomeHref, type AppModeId } from "@/lib/app-modes"; import { APP_MODE_ACCENT } from "@/lib/category-identity"; import { buildCrossModeLinksForThread, + buildCrossModeLinksFromUniversalSearch, + crossModeUniversalExcludedDomains, type CrossModeDifferentialCatalog, type CrossModeLink, type CrossModeLinkBadge, @@ -32,6 +36,21 @@ const cardActionControl = type CrossModeLinksVariant = "card" | "compact" | "responsive-compact" | "line"; +// The ceiling on one library strip once both halves have contributed. +// +// Four, which is what the catalogue half alone allowed before the widened lookup +// existed. Five was set while this block was still a collapsed disclosure, where +// an extra row cost nothing until the reader opened it. It is open at every +// width now (owner decision, 2026-09-07), so every row is unconditional height +// on the answer surface, between the governed caution and the follow-ups. +// Holding the old ceiling keeps this change about reach rather than about size. +// +// The cap only binds when the catalogue half filled the strip on its own, and a +// question naming four medication, service, form or differential records is +// already well served. The common answer names none of them, which is where the +// widened reach earns its place and where this number never applies. +const crossModeStripMaxLinks = 4; + type CrossModeLinkCardProps = { link: CrossModeLink; Icon: LucideIcon; @@ -162,12 +181,25 @@ export function CrossModeLinksSection({ enabled = true, onModeSearch, variant = "card", + universalMode, }: { queries: Array; enabled?: boolean; // Defaults to navigating to the target mode with the search pre-run. onModeSearch?: (mode: AppModeId, query: string) => void; variant?: CrossModeLinksVariant; + /** + * Opt in to the cross-entity lookup that reaches DSM, Formulation, Specifiers, + * Therapy, Dictionary and Tools, naming the mode the surface belongs to. + * + * Off by default, and that default is load-bearing. A surface that already + * mounts `UniversalSearchAlsoMatches` (every mode but Answer) would otherwise + * run the same query against the same endpoint twice and print the results in + * two panels — the duplication removed on 2026-08-26. Pass it only from a + * surface that mounts no other cross-mode panel, and pass `undefined` while a + * generation is in flight so the lookup never races the answer stream. + */ + universalMode?: AppModeId; }) { const router = useRouter(); const services = useRegistryRecords("service", { enabled, view: "search" }); @@ -201,16 +233,56 @@ export function CrossModeLinksSection({ }); }, [enabled, queriesKey, medications.data, services.records, forms.records, differentials]); - if (links.length === 0) return null; - const telemetryQuery = queriesKey.split("\u0000").at(-1) ?? ""; + // The newest turn, not the turn the catalogue links came from. The catalogue + // path walks back through the thread on purpose, because a follow-up often + // drops the entity name; the cross-entity lookup follows the question on + // screen instead, which is the same query `ClinicalDashboard` already gives + // the sibling "Also matches" tray on this mode. + const universalQuery = universalMode ? telemetryQuery : ""; + // Read during render rather than in an effect, which is safe here only because + // it changes no markup: the universal half starts empty on the server and on + // the first client paint either way, and this value reaches nothing but the + // hook's `enabled`. A restored answer thread sitting on an unsubmitted shared + // home must not fetch, which is the rule this helper owns. + const universalSubmitted = + Boolean(universalMode) && + shouldRunUniversalAlsoMatches( + universalMode ?? "answer", + typeof window === "undefined" ? null : window.location.search, + universalQuery, + ); + const universal = useUniversalSearch({ + query: universalQuery, + enabled: enabled && universalSubmitted, + contextMode: universalMode ?? "answer", + excludeDomains: crossModeUniversalExcludedDomains, + limitPerDomain: 2, + }); + const universalLinks = useMemo(() => { + // `universal.query !== universalQuery` is the stale guard: the hook keeps the + // previous query's groups while the next request is in flight, and a card + // answering the prior question is exactly the guess this surface must not make. + if (!universalMode || universal.query !== universalQuery) return []; + return buildCrossModeLinksFromUniversalSearch(universalQuery, universal.groups, { + existing: links, + // Whatever the catalogue half left unused, up to the strip's ceiling. + maxTotal: Math.max(0, crossModeStripMaxLinks - links.length), + }); + }, [universalMode, universal.query, universal.groups, universalQuery, links]); + + const allLinks = universalLinks.length > 0 ? [...links, ...universalLinks] : links; + + if (allLinks.length === 0) return null; const handleModeSearch = onModeSearch ?? ((mode: AppModeId, query: string) => { router.push(appModeHomeHref(mode, { query, focus: true, run: true })); }); - return ; + return ( + + ); } /** @@ -220,12 +292,14 @@ export function CrossModeLinksSection({ * Under an answer this block used to be a permanently expanded rail sitting * directly above a second, near-identical panel of mode matches. Two panels * asking the same question ("where else does this appear") read as one panel - * repeated. This collapses to a single row carrying a preview of what is inside, - * closes the answer's evidence/safety stack, and opens to exactly the rail it - * always was before the follow-up conversation begins. + * repeated. This is the single row that replaced them: it closes the answer's + * evidence/safety stack and collapses to give the space back before the + * follow-up conversation begins. * - * The preview names come from the resolved links, so the line can never - * advertise a match the expanded rail does not list. + * The header carries the label, a rule and the count — no preview of the record + * names (owner decision, 2026-09-07). The names were there to tell a closed line + * what it held, and the line rests open, so they were repeating the cards + * directly beneath them. */ function CrossModeLinksLine({ links, @@ -236,13 +310,17 @@ function CrossModeLinksLine({ onModeSearch: (mode: AppModeId, query: string) => void; query: string; }) { - const [open, setOpen] = useState(false); + // Open on arrival, and still collapsible — the hybrid the owner asked for on + // 2026-09-07, after seeing both ends of it. Closed-by-default hid the matches + // behind a tap nobody had a reason to take, since a closed tray cannot show + // what it holds. No toggle at all put four unconditional rows into the + // answer's evidence stack with no way to put them away. One control, already + // open, gives the reader the matches for free and the space back on request. + const [open, setOpen] = useState(true); // `useId`, not a constant: two of these can mount at once (the answer thread // and a historical turn), and a duplicate id is a DOM-integrity failure the // smoke audit fails on. const panelId = useId(); - const preview = links.slice(0, 3).map((link) => link.title); - const rest = links.length - preview.length; const countLabel = links.length === 1 ? "1 match" : `${links.length} matches`; return (

Also in your library - {/* Label, rule, count — the editorial section-header device the "Also - matches" tray uses. The preview names replace the rule from sm up, - where there is room for them; at 320px the label plus both of them - would not fit, and the count is the half that still says something - when the names are gone. */} - - - {preview.join(" · ")} - {rest > 0 ? ` · +${rest}` : null} - - {/* Visual cue only — the button's accessible name stays the label above, - so a screen reader is not read the count twice. */} + {/* Label, rule, count — the same editorial section-header device the + "Also matches" tray uses, and now at every width. A preview of the + first three record names used to take the rule's place from sm up; + it earned that room while the line rested closed and had no other + way to say what it held. Resting open, it was naming the cards + immediately below it. */} + + {/* The visible count is decorative — it is dropped entirely below sm, and + `hidden` hides it from assistive tech as well as from the eye. This + tray has no live region to carry the number, so without the sr-only + copy the count would never reach a screen reader at any width, and + the closed control would announce as a door onto an unknown. */} {countLabel} + {countLabel} import("@/components/applications-launcher-page").then((module) => module.ApplicationsLauncherWorkspace), - // The retained `/?mode=tools` dashboard alias owns this legacy client-only - // launcher. Canonical `/tools` is rendered by ToolsSearchResultsPage. + // Legacy client-only launcher, now unreachable: `/?mode=tools` redirects to + // `/tools`, which ToolsSearchResultsPage renders. Kept only until the guest + // coverage in tests/favourites-auth-gate.dom.test.tsx is re-homed onto it. { ssr: false, loading: () => }, ); diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index fe3a29b4f..923a96ffa 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -269,7 +269,7 @@ function DocumentPagePreview({ document, href }: { document: DocumentMatch; href href={href} aria-label={`Preview page ${pageNumber} of ${document.title}`} data-testid="document-page-preview" - className="group relative z-10 flex h-28 w-20 shrink-0 flex-col overflow-hidden rounded-lg border border-t-[3px] border-[color:var(--border-lux)] border-t-[color:var(--clinical-accent)] bg-[color:var(--surface)] shadow-[var(--e2)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] motion-reduce:transform-none motion-reduce:transition-none sm:h-32 sm:w-24" + className="group relative z-10 flex h-28 w-20 shrink-0 flex-col overflow-hidden rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface)] shadow-[var(--e2)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] motion-reduce:transform-none motion-reduce:transition-none sm:h-32 sm:w-24" > {hasCoverUrl ? ( // Private signed covers stay unoptimized so bearer URLs never enter `/_next/image`. diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 122dda142..55367f1c4 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1487,7 +1487,19 @@ export function MasterSearchHeader({ mediaQuery.removeEventListener("change", syncTarget); host.parentNode?.removeChild(host); if (composerSlotId) { - setModeHomeComposerReservePending(document.getElementById(composerSlotId), false); + // Ready-gate, same as adoption: only a slot whose own segment has + // hydrated is ours to clear. This cleanup also runs on an ordinary + // dependency re-run, and stripping the SSR marker there leaves a window + // in which the DOM lacks an attribute the page's client render still + // produces — a page segment hydrating inside it logs a + // `data-composer-reserve` mismatch (client "pending" vs server null). + // Nothing is stranded by skipping: the effect body re-establishes the + // reserve on the very next tick, and a real unmount takes the slot + // element with it. Invariant 15's three required clears (media query + // mismatch, suppressed composer, portal fallback) live in `syncTarget` + // and the suppression branch above, and are untouched. + const slot = document.getElementById(composerSlotId); + if (isDesktopComposerSlotReady(slot)) setModeHomeComposerReservePending(slot, false); } setDesktopComposerPortalActive(false); setDesktopComposerPortalHost(null); diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 9c5f086d4..8cb85a087 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -83,11 +83,10 @@ export function UniversalSearchAlsoMatches({ // between the composer and the results the search actually asked for. It used // to open itself from sm up. const [expanded, setExpanded] = useState(false); - // The sm breakpoint (640px) no longer decides whether the panel is open — the - // disclosure does, at every width. It still decides two things the disclosure - // cannot: whether the cross-mode lookup runs before the user opens anything - // (so the closed header can state a real count), and whether a no-match tray - // is dropped entirely rather than left as a header that opens onto nothing. + // The sm breakpoint (640px) no longer decides whether the panel is open, nor + // whether the lookup runs — the disclosure owns the first and the lookup is + // eager at every width. All it still decides is how many mode cards an opened + // tray may show, four on a wide viewport against three on a phone. const [isWide, setIsWide] = useState(false); const [viewportReady, setViewportReady] = useState(false); useEffect(() => { @@ -106,13 +105,14 @@ export function UniversalSearchAlsoMatches({ // arrive; a speculative phone disclosure would add dead space to short // answers that have no cross-mode matches. // - // Deliberately unchanged by the collapse: the lookup still runs on submit from - // sm up even while the tray is shut. That is what lets a closed header say - // "3 related modes" and lets the whole tray disappear when nothing matched. A - // closed control that cannot say what is behind it is a blind door, and - // making the fetch wait for the click would turn every desktop open into a - // spinner over a panel that may hold nothing. - const searchActive = submissionActive && (isWide || modeId === "answer" || expanded); + // The lookup runs on submit at every width, including phones, even while the + // tray is shut. That is what lets a closed header say "3 related modes" and + // lets the whole tray disappear when nothing matched. A closed control that + // cannot say what is behind it is a blind door, and the phone was the width + // where that bit: it showed "Tap to open" and could open onto nothing. The + // cost is one extra cross-mode lookup per phone search, accepted so that the + // closed row states a real count and an empty tray is never rendered at all. + const searchActive = submissionActive; const universal = useUniversalSearch({ query: trimmedQuery, enabled: trimmedQuery.length >= 2 && searchActive, @@ -165,18 +165,14 @@ export function UniversalSearchAlsoMatches({ : matchCount > 0 ? `${matchCountLabel(matchCount)} also match this search.` : emptyMessage; - const headerMeta = searchPending - ? "Searching…" - : !searchActive - ? "Tap to open" - : matchCount > 0 - ? matchCountLabel(matchCount) - : "No other matches"; + const headerMeta = searchPending ? "Searching…" : matchCount > 0 ? matchCountLabel(matchCount) : "No other matches"; if (!submissionActive) return null; if (!viewportReady || trimmedQuery.length < 2) return null; if (modeId === "answer" && currentGroups.length === 0) return null; - if (isWide && !searchPending && currentGroups.length === 0) return null; + // At every width now, not just from sm up: the phone lookup is eager, so a + // header that would open onto nothing is dropped instead of offered. + if (!searchPending && currentGroups.length === 0) return null; return (
>; setAnswer: Dispatch>; setSources: Dispatch>; - setModeSearchSubmitted: Dispatch>; + /** Also records the submitted query, so a later composer edit cannot pass for a submission. */ + setModeSearchSubmitted: (submitted: boolean, submittedText?: string) => void; setQuery: Dispatch>; setAnswerThreadBootstrapped: Dispatch>; }) { @@ -131,7 +132,9 @@ export function useAnswerThreadBootstrap({ latestAnswerTurnRef.current = persisted.latestTurn; setAnswer(persisted.latestTurn.answer); setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); + // Restoring a persisted thread is a submission of that turn's query, not of + // whatever happens to be in the composer. + setModeSearchSubmitted(true, persisted.latestTurn.query); setQuery(""); autoRunSearchSignatureRef.current = persisted.latestSubmissionSignature; } diff --git a/src/components/clinical-dashboard/use-home-mode-seed.ts b/src/components/clinical-dashboard/use-home-mode-seed.ts index 0bdd9fe27..21a677fb2 100644 --- a/src/components/clinical-dashboard/use-home-mode-seed.ts +++ b/src/components/clinical-dashboard/use-home-mode-seed.ts @@ -42,7 +42,8 @@ export function useHomeModeSeed({ setQuery: Dispatch>; setQueryMode: Dispatch>; setScopeFilters: Dispatch>; - setModeSearchSubmitted: Dispatch>; + /** Also records the submitted query, so a later composer edit cannot pass for a submission. */ + setModeSearchSubmitted: (submitted: boolean, submittedText?: string) => void; setLoading: Dispatch>; setError: Dispatch>; setAnswerProgress: Dispatch>; diff --git a/src/components/clinical-dashboard/use-submitted-mode-search.ts b/src/components/clinical-dashboard/use-submitted-mode-search.ts new file mode 100644 index 000000000..7490b50dd --- /dev/null +++ b/src/components/clinical-dashboard/use-submitted-mode-search.ts @@ -0,0 +1,33 @@ +import { useCallback, useState } from "react"; +import type { AppModeId } from "@/lib/app-modes"; + +/** Keeps the visible results attached to their submitted query while the composer is edited. */ +export function useSubmittedModeSearch({ + autoRunSearch, + initialQuery, + initialSearchMode, +}: { + autoRunSearch?: boolean; + initialQuery: string; + initialSearchMode: AppModeId; +}) { + const [modeSearchSubmitted, setModeSearchSubmittedFlag] = useState(() => + Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), + ); + // The query the mode's on-screen results actually belong to, which is NOT `query`: + // editing the bottom composer calls `setQuery` alone and leaves both the results and + // the submitted flag in place. Anything keyed to the submitted search must read this, + // or a paused draft silently replaces it while the primary cards still show the last + // submitted search. Null until a submission records one. + const [submittedModeQuery, setSubmittedModeQuery] = useState(() => + autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools" ? initialQuery.trim() : null, + ); + // Every submission already sets `query` to the text it submitted, so the text is passed + // here too rather than read back from state. Clearing the flag clears the query with it. + const setModeSearchSubmitted = useCallback((submitted: boolean, submittedText?: string) => { + setModeSearchSubmittedFlag(submitted); + if (!submitted) setSubmittedModeQuery(null); + else if (submittedText !== undefined) setSubmittedModeQuery(submittedText.trim()); + }, []); + return { modeSearchSubmitted, submittedModeQuery, setModeSearchSubmitted }; +} diff --git a/src/components/tools/tool-local-search.tsx b/src/components/tools/tool-local-search.tsx new file mode 100644 index 000000000..36a38d9ec --- /dev/null +++ b/src/components/tools/tool-local-search.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Plus, Search } from "lucide-react"; +import type { FormEvent } from "react"; + +import { focusRing } from "@/components/card-recipes"; +import { cn, searchShellInput } from "@/components/ui-primitives"; + +/** + * The tools directory's own filter box, ported from the retired `/?mode=tools` hub. + * + * `/tools` deliberately renders no shared composer — it owns its own filtering — but + * until the hub was consolidated away this was the only place a clinician could type + * a tools search, and the directory had no input at all. So the box came here with the + * shortcut row rather than being lost with the hub. + * + * In-flow, never fixed: the search-chrome contract allows a page to own its filtering, + * but not to stack a second dock-sized bar over the shared chrome. + */ +export function ToolLocalSearch({ + value, + onChange, + onSubmit, + className, +}: { + value: string; + onChange: (query: string) => void; + onSubmit: () => void; + className?: string; +}) { + return ( +
) => { + event.preventDefault(); + onSubmit(); + }} + className={cn( + // Both end tracks hold tap-sized children and the row has no gap, so they read + // the tap knob rather than a copy of its value: a literal here overlaps the + // input the moment `--spacing-tap` moves. + "search-shell grid min-h-13 grid-cols-[var(--spacing-tap)_minmax(0,1fr)_var(--spacing-tap)] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-lux)] text-left shadow-[var(--e2)]", + className, + )} + > + + + + + +
+ ); +} diff --git a/src/components/tools/tool-quick-actions.tsx b/src/components/tools/tool-quick-actions.tsx new file mode 100644 index 000000000..a4b82767f --- /dev/null +++ b/src/components/tools/tool-quick-actions.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { focusRing } from "@/components/card-recipes"; +import { cn } from "@/components/ui-primitives"; +import { toolIdentity } from "@/lib/category-identity"; +import { categoryGlyph } from "@/lib/category-identity-icons"; +import { localSmartExcludedToolIds, type ToolCatalogId, type ToolCatalogRecord } from "@/lib/tools-catalog"; + +/** + * The verb shortcut row that used to live only on the `/?mode=tools` launcher. + * + * Tools had two surfaces: a hub at `/?mode=tools` carrying these shortcuts, and the + * canonical directory at `/tools` carrying the filter ribbon and the full list. Which + * one a clinician saw depended on how they arrived — the mode pill gave them the hub, + * every other link gave them the directory. `/?mode=tools` now redirects to `/tools`, + * so this row moved here rather than being deleted with the hub: it is the one thing + * the hub had that the directory did not. + * + * Deliberately verbs, not mode names. The sidebar already lists every mode by name; + * this row answers "what am I trying to do" (Ask, Compare, Prescribe, Safety), which + * is a different question and the reason it is worth keeping at all. + */ +const quickActionsBase = [ + { label: "Ask", desktopLabel: "Ask evidence", id: "clinical-kb-search" }, + { label: "Compare", desktopLabel: "Compare", id: "differentials" }, + { label: "Prescribe", desktopLabel: "Prescribe", id: "medication-prescribing" }, + { label: "Safety", desktopLabel: "Safety check", id: "risk-safety" }, + { label: "Docs", desktopLabel: "Documents", id: "documents" }, + { label: "Refer", desktopLabel: "Refer", id: "services" }, + { label: "Forms", desktopLabel: "Forms", id: "forms" }, + { label: "Saved", desktopLabel: "Favourites", id: "favourites" }, +] as const satisfies ReadonlyArray<{ label: string; desktopLabel: string; id: ToolCatalogId }>; + +export function quickActionsForSession(canAccessFavourites: boolean, naturalSmartSearch: boolean) { + return quickActionsBase.filter( + (action) => + (canAccessFavourites || action.id !== "favourites") && + (!naturalSmartSearch || !localSmartExcludedToolIds.has(action.id)), + ); +} + +function toolById(id: ToolCatalogId, tools: readonly ToolCatalogRecord[]) { + return tools.find((tool) => tool.id === id) ?? tools[0]; +} + +export function ToolQuickActions({ + onSelect, + mobile, + tools, + canAccessFavourites, + naturalSmartSearch, +}: { + onSelect: (id: ToolCatalogId) => void; + mobile?: boolean; + tools: readonly ToolCatalogRecord[]; + canAccessFavourites: boolean; + naturalSmartSearch: boolean; +}) { + const quickActions = quickActionsForSession(canAccessFavourites, naturalSmartSearch); + // An empty catalogue would make `toolById` return undefined and every tile + // dereference it. The directory renders this row above its own results, so a + // filtered-to-nothing list must not take the shortcuts down with it. + if (tools.length === 0) return null; + return ( +
+ {quickActions.slice(0, mobile ? 8 : 6).map((action) => { + const tool = toolById(action.id, tools); + const identity = toolIdentity(tool.id, tool.area); + return ( + + ); + })} +
+ ); +} diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx index 1e9808930..0fc990632 100644 --- a/src/components/tools/tools-search-results-page.tsx +++ b/src/components/tools/tools-search-results-page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { BadgeCheck, ChevronRight, ClipboardList, Search, ShieldCheck, Waves, type LucideIcon } from "lucide-react"; import { type MutableRefObject, @@ -22,6 +23,8 @@ import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favouri import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { ToolLocalSearch } from "@/components/tools/tool-local-search"; +import { ToolQuickActions } from "@/components/tools/tool-quick-actions"; import { cardPadding, cardSelected, cardSurface, focusRing, stretchedRowLinkClass } from "@/components/card-recipes"; import { CategoryIconTile } from "@/components/category-icon-tile"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; @@ -37,7 +40,9 @@ import { interpretSmartSearch, smartSearchExpansions } from "@/lib/smart-search- import { useAuthSession } from "@/lib/supabase/client"; import { rankToolRecords, + localSmartExcludedToolIds, toolCatalogRecordsForSession, + type ToolCatalogId, type ToolCatalogArea, type ToolCatalogRecord, } from "@/lib/tools-catalog"; @@ -61,8 +66,6 @@ const filterOptions = [ type FilterId = (typeof filterOptions)[number]["id"]; type DetailSectionId = "check-first" | "needed-input" | "output"; -const localSmartExcludedToolIds = new Set(["clinical-kb-search", "documents", "favourites"]); - function subscribeNoop() { return () => undefined; } @@ -341,10 +344,13 @@ export function ToolsSearchResultsPage({ () => true, () => false, ); - // The route passes its submitted query so hard loads server-render the exact - // result set. After hydration the shared composer owns the draft, including - // an intentionally cleared value, until the next submitted navigation. - const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; + const router = useRouter(); + // A draft typed into this page's own filter box wins over everything else, the way + // it did on the retired hub. Below that: the route's submitted query so hard loads + // server-render the exact result set, and after hydration the shared command draft, + // including an intentionally cleared value, until the next submitted navigation. + const [localQuery, setLocalQuery] = useState(null); + const query = localQuery ?? (hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery); const filterPanelId = useId(); const [activeFilter, setActiveFilter] = useState("all"); const [filterOpen, setFilterOpen] = useState(false); @@ -433,6 +439,26 @@ export function ToolsSearchResultsPage({ setOpenSection((current) => (current === section ? null : section)); } + // The quick-action row selects by id; `openTool` wants the record. Resolved against + // `accessibleTools` rather than `filteredTools` so a shortcut still opens its tool + // when a category filter has excluded it from the visible list. + function openToolById(id: ToolCatalogId) { + const tool = accessibleTools.find((candidate) => candidate.id === id); + if (tool) openTool(tool); + } + + // Submitting is a navigation, not a local state change, so the result set is + // shareable and survives reload. An empty box opens the first visible tool + // instead, which is what the hub's submit control did. + function submitLocalSearch() { + const submittedQuery = query.trim(); + if (submittedQuery) { + router.push(`/tools?q=${encodeURIComponent(submittedQuery)}&run=1`); + return; + } + if (filteredTools[0]) openTool(filteredTools[0]); + } + function openTool(tool: ToolCatalogRecord, opener?: HTMLElement | null) { setSelectedId(tool.id); setOpenSection(null); @@ -464,6 +490,38 @@ export function ToolsSearchResultsPage({ )} >
+ {/* The verb shortcut row carried over from the retired `/?mode=tools` hub. + Sourced from `accessibleTools`, not `filteredTools`: the shortcuts are a + fixed way in, so a query or category filter must not empty the row. Hidden + once a query is running, where the ranked results are the answer and a + static row above them is just noise. */} + + {query.trim() ? null : ( +
+
+ +
+
+ +
+
+ )} ; +/** + * The modes a library line can point at. + * + * The first four are resolved from catalogues already in the browser. The rest + * are reachable only through `/api/search/universal`, and only on a surface that + * opts into that lookup — see `buildCrossModeLinksFromUniversalSearch`. + * + * Calculators, Factsheets, Sources, On Call and Favourites are absent on + * purpose: they contribute no cross-entity search domain at all + * (`universal-search-mode-context.ts`), so nothing can resolve a link to them. + */ +export type CrossModeLinkModeId = Extract< + AppModeId, + | "prescribing" + | "services" + | "forms" + | "differentials" + | "dsm" + | "formulation" + | "specifiers" + | "therapy-compass" + | "dictionary" + | "tools" +>; export type CrossModeLinkBadge = { label: string; @@ -61,13 +90,28 @@ const DIFFERENTIAL_TITLE_TERM_SCORE = 8; const RANKER_CANDIDATE_LIMIT = 5; +// Locally resolved modes lead: their links come from name-match thresholds tuned +// against this repo's own catalogues, while the universal-search modes below are +// gated on one title term. Only a tiebreaker for equal scores. const modePriority: Record = { prescribing: 0, services: 1, forms: 2, differentials: 3, + dsm: 4, + formulation: 5, + specifiers: 6, + "therapy-compass": 7, + dictionary: 8, + tools: 9, }; +const crossModeLinkModeIds = new Set(Object.keys(modePriority)); + +function isCrossModeLinkModeId(modeId: string): modeId is CrossModeLinkModeId { + return crossModeLinkModeIds.has(modeId); +} + function crossModeLinkBase(modeId: CrossModeLinkModeId, title: string) { return { modeId, @@ -275,3 +319,126 @@ export function buildCrossModeLinks( return links; } + +/** + * The universal-search domains a library line may consume, and their complement. + * + * Only these six are read here. `documents` is left out because an answer already + * cites its documents in the evidence rail and source drawer, and `medications`, + * `services`, `forms`, `differentials` and `presentations` are left out because + * the catalogue path above already resolves them with tuned name-match + * thresholds. Deriving the exclusion list from this one array rather than writing + * it out twice is what stops a future domain being read by both halves and + * showing the same record on one line. + */ +export const crossModeUniversalDomains = [ + "dsm", + "formulation", + "specifiers", + "therapies", + "dictionary", + "tools", +] as const satisfies readonly UniversalSearchDomain[]; + +export const crossModeUniversalExcludedDomains: readonly UniversalSearchDomain[] = universalSearchDomains.filter( + (domain) => !(crossModeUniversalDomains as readonly UniversalSearchDomain[]).includes(domain), +); + +// Same gate and the same weight as `differentialTitleScore`: a term only counts +// when it names the record at a word boundary, and only when it is long enough +// that question filler cannot carry it. Universal search returns items its own +// adapters already ranked, so this is a second, stricter filter on top of that — +// never a substitute for it. +const UNIVERSAL_TITLE_TERM_SCORE = 8; +const UNIVERSAL_MIN_TERM_LENGTH = 4; + +/** + * Cross-mode links for the domains the local catalogues cannot reach. + * + * Returns only the links to ADD to `options.existing`, already deduplicated + * against it, so a caller concatenates rather than merges. An empty or errored + * group contributes nothing: a failed lookup must leave the line exactly as the + * catalogue path left it, never a guess. + */ +export function buildCrossModeLinksFromUniversalSearch( + query: string, + groups: readonly UniversalSearchGroup[], + options: { existing?: readonly CrossModeLink[]; maxPerMode?: number; maxTotal?: number } = {}, +): CrossModeLink[] { + const maxPerMode = options.maxPerMode ?? 1; + const maxTotal = options.maxTotal ?? 2; + const terms = extractKeywordTerms(query).filter((term) => term.length >= UNIVERSAL_MIN_TERM_LENGTH); + if (terms.length === 0 || maxTotal <= 0) return []; + + const allowedDomains = new Set(crossModeUniversalDomains); + // `rank` is the item's position in the group the server returned it in, kept + // so the domain's own ranking survives this mapper. Within a domain that + // ranking is the only trustworthy order: the term-count score below is a + // filter, not a ranker, and it ties constantly (most matches hit exactly one + // term). Falling through to alphabetical order there let an earlier title + // displace the domain's actual top result, which `maxPerMode` of 1 then made + // the only result. `item.score` is deliberately not used: the type says it is + // comparable within a group only. + const candidates: Array<{ link: CrossModeLink; rank: number }> = []; + + for (const group of groups) { + if (group.error || !allowedDomains.has(group.kind)) continue; + const modeId = universalSearchModeForDomain(group.kind); + if (!isCrossModeLinkModeId(modeId)) continue; + + let rank = 0; + for (const item of group.items) { + rank += 1; + const title = item.title?.trim(); + const detailHref = item.href?.trim(); + if (!title || !detailHref) continue; + const matched = terms.filter((term) => hasWordBoundaryMatch(title, [term])); + if (matched.length === 0) continue; + const badge = item.badge?.trim(); + candidates.push({ + rank, + link: { + ...crossModeLinkBase(modeId, title), + // `id` is the domain's own record id; the href is the fallback so a + // domain that omits one cannot collapse two records onto one React key. + slug: item.id?.trim() || detailHref, + subtitle: item.subtitle?.trim() ?? "", + badges: badge ? [{ label: badge }] : [], + detailHref, + score: matched.length * UNIVERSAL_TITLE_TERM_SCORE, + matchReason: "title", + }, + }); + } + } + + // Equal score and equal mode priority means the same domain, so `rank` here is + // always a within-domain comparison and never an incomparable cross-domain one. + candidates.sort( + (left, right) => + right.link.score - left.link.score || + modePriority[left.link.modeId] - modePriority[right.link.modeId] || + left.rank - right.rank || + left.link.title.localeCompare(right.link.title), + ); + + const existing = options.existing ?? []; + const seenKeys = new Set(existing.map((link) => `${link.modeId}:${link.slug}`)); + const seenHrefs = new Set(existing.map((link) => link.detailHref)); + const perModeCounts: Partial> = {}; + const links: CrossModeLink[] = []; + + for (const { link: candidate } of candidates) { + if (links.length >= maxTotal) break; + const key = `${candidate.modeId}:${candidate.slug}`; + if (seenKeys.has(key) || seenHrefs.has(candidate.detailHref)) continue; + const modeCount = perModeCounts[candidate.modeId] ?? 0; + if (modeCount >= maxPerMode) continue; + seenKeys.add(key); + seenHrefs.add(candidate.detailHref); + perModeCounts[candidate.modeId] = modeCount + 1; + links.push(candidate); + } + + return links; +} diff --git a/src/lib/legacy-home-redirect.ts b/src/lib/legacy-home-redirect.ts index 9fec1a318..b20f4caab 100644 --- a/src/lib/legacy-home-redirect.ts +++ b/src/lib/legacy-home-redirect.ts @@ -4,6 +4,25 @@ const legacyModePaths = { specifiers: "/specifiers", } as const; +/** + * Modes whose `/?mode=…` alias redirects unconditionally, submitted or not. + * + * Tools is the only one, and it differs from the map above because it has no shared + * home to fall back to: `shouldShowSharedHome` excludes `tools` outright, so + * `/?mode=tools` used to render a second, hub-shaped launcher instead. That gave Tools + * two different surfaces depending on how a clinician arrived — the mode pill served + * the hub, every other link served the canonical directory at `/tools`. The hub's one + * unique feature, its verb shortcut row, moved to `/tools` + * (`components/tools/tool-quick-actions.tsx`), so this alias now has nothing of its own + * left to show and forwards. + * + * Unconditional on purpose: unlike the submitted-only map above, there is no state in + * which rendering `/` for `mode=tools` is correct. + */ +const aliasOnlyModePaths = { + tools: "/tools", +} as const; + type LegacyHomeRequestUrl = Pick; /** @@ -17,6 +36,19 @@ export function legacyHomeRedirectUrl(requestUrl: LegacyHomeRequestUrl, method: if ((method !== "GET" && method !== "HEAD") || requestUrl.pathname !== "/") return null; const mode = requestUrl.searchParams.get("mode"); + + const aliasOnlyPath = mode ? aliasOnlyModePaths[mode as keyof typeof aliasOnlyModePaths] : undefined; + if (aliasOnlyPath) { + const aliasDestination = new URL(requestUrl.toString()); + aliasDestination.pathname = aliasOnlyPath; + aliasDestination.hash = ""; + // `mode` is consumed by the destination pathname; everything else (a query, a + // submitted `run=1`, navigation context) rides along, because `/tools` reads the + // same query string the alias carried. + aliasDestination.searchParams.delete("mode"); + return aliasDestination; + } + const destinationPath = mode ? legacyModePaths[mode as keyof typeof legacyModePaths] : undefined; if (!destinationPath) return null; diff --git a/src/lib/smart-search-intent.ts b/src/lib/smart-search-intent.ts index ad7c2a973..de17c7176 100644 --- a/src/lib/smart-search-intent.ts +++ b/src/lib/smart-search-intent.ts @@ -116,11 +116,11 @@ const modeExpansionRules: Record(["clinical-kb-search", "documents", "favourites"]); + export const toolCatalogRecords: ToolCatalogRecord[] = [ { id: "clinical-kb-search", diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 0d1026b78..b344c4c74 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -120,6 +120,25 @@ describe("audit navigation and auth regressions", () => { expect(source("src/proxy.ts")).toContain("legacyHomeRedirectUrl(request.nextUrl, request.method)"); }); + // Tools is the one alias that forwards whether or not the URL is submitted. It has no + // shared home to fall back to (`shouldShowSharedHome` excludes `tools`), so `/?mode=tools` + // used to render a second, hub-shaped launcher and Tools had two surfaces depending on how + // the clinician arrived. The hub's verb shortcut row moved to `/tools`, so the alias has + // nothing of its own left to show. + it("forwards the tools alias to the canonical directory whether or not it is submitted", () => { + expect(legacyHomeRedirectUrl(new URL("https://clinical-kb.test/?mode=tools"), "GET")?.toString()).toBe( + "https://clinical-kb.test/tools", + ); + expect( + legacyHomeRedirectUrl( + new URL("https://clinical-kb.test/?mode=tools&q=medications&focus=1&run=1#detail"), + "GET", + )?.toString(), + ).toBe("https://clinical-kb.test/tools?q=medications&focus=1&run=1"); + // A non-navigation method still falls through, same as every other alias. + expect(legacyHomeRedirectUrl(new URL("https://clinical-kb.test/?mode=tools"), "POST")).toBeNull(); + }); + // This redirect used to rebuild the destination from scratch (`destination.search = ""`, // then only q/focus/run re-added), so `queryMode` and the scope filters were already gone // one hop before `consolidatedModeHomeTarget` — whose own doc promises "every other query @@ -227,16 +246,28 @@ describe("audit navigation and auth regressions", () => { ); }); - it("defers cross-mode search on narrow screens until expansion except for completed answers", () => { + it("runs cross-mode search on submission at every width, so a closed tray can state its count", () => { // `prescribing` was excluded here while the panel mounted ABOVE the medication // results; the mount moved below them, so the mode is no longer suppressed and - // the deferral contract is the plain submission gate. tests/ui-stress.spec.ts - // pins the panel's position under those results. - expect(universalAlsoMatchesSource).toContain("const searchActive = submissionActive &&"); + // the gate is plain submission. tests/ui-stress.spec.ts pins the panel's + // position under those results. + // + // The narrow-screen deferral this contract used to pin is gone deliberately. + // Waiting for the click meant the phone header could only say "Tap to open" + // and the tray was still rendered when nothing was behind it — a blind door. + // The lookup is eager at every width and an empty tray is dropped instead. + expect(universalAlsoMatchesSource).toContain("const searchActive = submissionActive;"); expect(universalAlsoMatchesSource).not.toContain('modeId !== "prescribing"'); - expect(universalAlsoMatchesSource).toContain('(isWide || modeId === "answer" || expanded)'); + expect(universalAlsoMatchesSource).not.toContain('(isWide || modeId === "answer" || expanded)'); + // The header now says pending / a count / nothing found. The "Tap to open" + // arm it replaced survives only in the comment above the searchActive gate, + // which is why this pins the expression rather than searching for the string. + expect(universalAlsoMatchesSource).toContain( + 'const headerMeta = searchPending ? "Searching…" : matchCount > 0 ? matchCountLabel(matchCount) : "No other matches";', + ); expect(universalAlsoMatchesSource).toContain("enabled: trimmedQuery.length >= 2 && searchActive"); expect(universalAlsoMatchesSource).toContain('if (modeId === "answer" && currentGroups.length === 0) return null;'); + expect(universalAlsoMatchesSource).toContain("if (!searchPending && currentGroups.length === 0) return null;"); expect(universalAlsoMatchesSource).toContain("const [viewportReady, setViewportReady] = useState(false);"); expect(universalAlsoMatchesSource).toContain("setViewportReady(true);"); // The panel status is a three-way now — pending / a count / nothing found — diff --git a/tests/cross-mode-links.test.ts b/tests/cross-mode-links.test.ts index c9dc2b15b..81364df24 100644 --- a/tests/cross-mode-links.test.ts +++ b/tests/cross-mode-links.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; import { crossModeDifferentialCatalog } from "@/lib/cross-mode-differentials"; -import { buildCrossModeLinks, buildCrossModeLinksForThread } from "@/lib/cross-mode-links"; +import { + buildCrossModeLinks, + buildCrossModeLinksForThread, + buildCrossModeLinksFromUniversalSearch, + crossModeUniversalDomains, + crossModeUniversalExcludedDomains, + type CrossModeLink, +} from "@/lib/cross-mode-links"; +import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; +import type { UniversalSearchGroup, UniversalSearchItem } from "@/lib/universal-search"; import { extractKeywordTerms, keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; import { defaultMedicationRecords } from "@/lib/medication-fixtures"; import type { ServiceRecord } from "@/lib/services"; @@ -150,3 +159,180 @@ describe("buildCrossModeLinks", () => { expect(buildCrossModeLinks("clozapine dose", {})).toEqual([]); }); }); + +function universalItem(overrides: Partial & { kind: UniversalSearchDomain }): UniversalSearchItem { + return { + id: overrides.id ?? "record", + title: overrides.title ?? "Record", + href: overrides.href ?? "/record", + score: overrides.score ?? 1, + ...overrides, + }; +} + +function universalGroup( + kind: UniversalSearchDomain, + items: Array>, + overrides: Partial = {}, +): UniversalSearchGroup { + const built = items.map((item) => universalItem({ ...item, kind })); + return { kind, total: built.length, items: built, latencyMs: 1, ...overrides }; +} + +describe("crossModeUniversalExcludedDomains", () => { + it("is exactly the complement of the domains the line consumes", () => { + // The two lists are derived from one array on purpose. If they ever drift, a + // domain is read by both the catalogue half and the universal half, and the + // same record is printed twice on one line. + expect([...crossModeUniversalExcludedDomains].sort()).toEqual( + universalSearchDomains + .filter((domain) => !(crossModeUniversalDomains as readonly UniversalSearchDomain[]).includes(domain)) + .sort(), + ); + for (const domain of ["documents", "medications", "services", "forms", "differentials", "presentations"] as const) { + expect(crossModeUniversalExcludedDomains, `${domain} is already resolved elsewhere`).toContain(domain); + } + }); +}); + +describe("buildCrossModeLinksFromUniversalSearch", () => { + it("reaches a mode no local catalogue can resolve", () => { + const links = buildCrossModeLinksFromUniversalSearch("criteria for bipolar disorder", [ + universalGroup("dsm", [ + { id: "bipolar-i-disorder", title: "Bipolar I Disorder", href: "/dsm/bipolar-i-disorder", subtitle: "296.4x" }, + ]), + ]); + + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + modeId: "dsm", + modeLabel: "DSM-5 Diagnosis", + slug: "bipolar-i-disorder", + detailHref: "/dsm/bipolar-i-disorder", + subtitle: "296.4x", + modeSearchQuery: "Bipolar I Disorder", + matchReason: "title", + }); + }); + + it("rejects an item the query does not name at a word boundary", () => { + // "renal" hides inside "adrenaline"; a content-ranked hit is not a named one. + expect( + buildCrossModeLinksFromUniversalSearch("renal impairment", [ + universalGroup("dictionary", [{ id: "adrenaline", title: "Adrenaline", href: "/dictionary/adrenaline" }]), + ]), + ).toEqual([]); + }); + + it("drops query filler shorter than the minimum term length", () => { + expect( + buildCrossModeLinksFromUniversalSearch("who is at risk", [ + universalGroup("tools", [{ id: "who-5", title: "WHO Wellbeing Index", href: "/tools/who-5" }]), + ]), + ).toEqual([]); + }); + + it("contributes nothing from an errored or empty group", () => { + // A failed adapter must leave the line exactly as the catalogue half left it. + expect( + buildCrossModeLinksFromUniversalSearch("bipolar disorder", [ + universalGroup("dsm", [{ id: "bipolar-i", title: "Bipolar I Disorder", href: "/dsm/bipolar-i" }], { + error: true, + }), + universalGroup("dictionary", []), + ]), + ).toEqual([]); + }); + + it("ignores domains the catalogue half already resolves", () => { + expect( + buildCrossModeLinksFromUniversalSearch("clozapine dosing", [ + universalGroup("medications", [{ id: "clozapine", title: "Clozapine", href: "/medications/clozapine" }]), + universalGroup("documents", [{ id: "doc-1", title: "Clozapine Protocol", href: "/documents/doc-1" }]), + ]), + ).toEqual([]); + }); + + it("never repeats a record the catalogue half already listed", () => { + const existing: CrossModeLink[] = [ + { + modeId: "differentials", + modeLabel: "Differentials", + slug: "psychosis", + title: "Psychosis", + subtitle: "", + badges: [], + detailHref: "/dsm/psychosis", + modeSearchHref: "/differentials", + modeSearchQuery: "Psychosis", + score: 8, + matchReason: "title", + }, + ]; + + expect( + buildCrossModeLinksFromUniversalSearch( + "psychosis assessment", + [universalGroup("dsm", [{ id: "psychosis", title: "Psychosis", href: "/dsm/psychosis" }])], + { existing }, + ), + ).toEqual([]); + }); + + it("caps the added links at one per mode and two in total", () => { + const links = buildCrossModeLinksFromUniversalSearch("bipolar disorder assessment", [ + universalGroup("dsm", [ + { id: "bipolar-i", title: "Bipolar I Disorder", href: "/dsm/bipolar-i" }, + { id: "bipolar-ii", title: "Bipolar II Disorder", href: "/dsm/bipolar-ii" }, + ]), + universalGroup("dictionary", [{ id: "bipolar", title: "Bipolar", href: "/dictionary/bipolar" }]), + universalGroup("tools", [{ id: "bipolar-scale", title: "Bipolar Assessment Scale", href: "/tools/bipolar" }]), + ]); + + expect(links).toHaveLength(2); + expect(new Set(links.map((link) => link.modeId)).size).toBe(2); + // Two matched terms beats one, whatever the domain's own (incomparable) score: + // the single-term Dictionary hit loses its place to the two-term Tools hit. + // DSM and Tools both match two terms, and that tie falls to mode priority. + expect(links.map((link) => [link.modeId, link.title])).toEqual([ + ["dsm", "Bipolar I Disorder"], + ["tools", "Bipolar Assessment Scale"], + ]); + }); + + it("keeps the domain's own ranking when two items match the same terms", () => { + // The term-count score is a filter, not a ranker, and it ties constantly. + // With one link per mode, an alphabetical tiebreak would hand the slot to + // the domain's second-best result and silently drop its first. + const links = buildCrossModeLinksFromUniversalSearch("bipolar disorder", [ + universalGroup("dsm", [ + { id: "z-record", title: "Zzz Bipolar Disorder", href: "/dsm/z-record", score: 90 }, + { id: "a-record", title: "Aaa Bipolar Disorder", href: "/dsm/a-record", score: 10 }, + ]), + ]); + + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ slug: "z-record", title: "Zzz Bipolar Disorder" }); + }); + + it("adds nothing once the caller has no room left on the line", () => { + // The section passes `crossModeLineMaxLinks - links.length`, so a thread whose + // catalogue half already filled the line asks for zero. It must not round up + // to the builder's own default of two. + expect( + buildCrossModeLinksFromUniversalSearch( + "bipolar disorder", + [universalGroup("dsm", [{ id: "bipolar-i", title: "Bipolar I Disorder", href: "/dsm/bipolar-i" }])], + { maxTotal: 0 }, + ), + ).toEqual([]); + }); + + it("keeps a distinct key when a domain omits the record id", () => { + const links = buildCrossModeLinksFromUniversalSearch("bipolar disorder", [ + universalGroup("dsm", [{ id: "", title: "Bipolar I Disorder", href: "/dsm/bipolar-i" }]), + ]); + + expect(links[0]!.slug).toBe("/dsm/bipolar-i"); + }); +}); diff --git a/tests/master-search-header.dom.test.tsx b/tests/master-search-header.dom.test.tsx index 6c51b475a..0c535f315 100644 --- a/tests/master-search-header.dom.test.tsx +++ b/tests/master-search-header.dom.test.tsx @@ -2,10 +2,15 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { factsheetsTopicsHref } from "@/lib/app-modes"; +import { + modeHomeComposerReserveAttr, + modeHomeComposerReservePendingValue, + modeHomeDesktopComposerSlotId, +} from "@/lib/mode-home-composer"; import { installMatchMediaStub } from "./setup/jsdom.setup"; const router = vi.hoisted(() => ({ @@ -244,4 +249,75 @@ describe("MasterSearchHeader DOM", () => { expect(screen.queryByTestId("answer-composer-privacy-warning")).toBeNull(); }); }); + describe("mode-home composer reserve marker", () => { + // The slot is appended outside React, so testing-library's cleanup does not + // remove it. Left behind, a stale slot keeps the id and getElementById in the + // next test resolves to it instead of that test's own slot. + afterEach(() => { + document.getElementById(modeHomeDesktopComposerSlotId)?.remove(); + }); + + function mountSlot() { + const slot = document.createElement("div"); + slot.id = modeHomeDesktopComposerSlotId; + // Exactly what the page SSRs: the reserve marker, and no ready flag — + // the owning page segment has not hydrated yet. + slot.setAttribute(modeHomeComposerReserveAttr, modeHomeComposerReservePendingValue); + document.body.appendChild(slot); + return slot; + } + + function spyOnReserveRemoval() { + const removals: string[] = []; + const original = Element.prototype.removeAttribute; + vi.spyOn(Element.prototype, "removeAttribute").mockImplementation(function (this: Element, name: string) { + if (name === modeHomeComposerReserveAttr && this.id === modeHomeDesktopComposerSlotId) { + removals.push(this.getAttribute(modeHomeComposerReserveAttr) ?? "(absent)"); + } + return original.call(this, name); + }); + return removals; + } + + it("does not strip the SSR reserve marker while the owning segment is still unhydrated", async () => { + // Desktop hero width, so the header wants to adopt the slot and keeps the + // pending reserve while it retries adoption. + installMatchMediaStub(true); + mountSlot(); + const removals = spyOnReserveRemoval(); + + const props = { + ...defaultHeaderProps(), + desktopHomeComposerSlotId: modeHomeDesktopComposerSlotId, + }; + const { rerender } = render(); + + // A dependency change re-runs the composer effect: React fires the cleanup + // and then the effect body. The cleanup used to removeAttribute() on a slot + // whose React segment had not hydrated, leaving a window in which the DOM + // lacked an attribute the page's own client render still produces. A page + // segment hydrating inside that window reports a hydration mismatch + // (client "pending" vs server null) on data-composer-reserve. + rerender(); + await Promise.resolve(); + + expect(removals).toEqual([]); + }); + + it("still clears the reserve marker when the composer is suppressed (invariant 15)", async () => { + installMatchMediaStub(true); + const slot = mountSlot(); + + const props = { + ...defaultHeaderProps(), + desktopHomeComposerSlotId: modeHomeDesktopComposerSlotId, + searchComposerVisible: false, + }; + render(); + // The suppression path clears in a queued microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(slot.hasAttribute(modeHomeComposerReserveAttr)).toBe(false); + }); + }); }); diff --git a/tests/privacy-readiness-contract.test.ts b/tests/privacy-readiness-contract.test.ts index cdb790011..bc5c0fdee 100644 --- a/tests/privacy-readiness-contract.test.ts +++ b/tests/privacy-readiness-contract.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { validatePrivacyReadiness } from "../scripts/check-privacy-readiness.mjs"; @@ -11,9 +12,50 @@ const retentionParityMigration = readFileSync( "utf8", ); +/* + * Web-container sessions start on a shallow clone, so `reviewedCommit` is often + * absent from local history. Without this the validator reports + * `reviewedCommit does not exist: `, which reads as a governance breach + * rather than a missing object, and every cloud session sees a spurious + * regression on an untouched file. Mirrors the guards already carried by + * tests/clinical-hazard-controls.test.ts and tests/rag-plan-package-parity.test.ts + * (`#1M0J6D`); this suite was the third with the same failure and the only one + * left without the guard. + */ +function isShallowClone(): boolean { + try { + return ( + execFileSync("git", ["rev-parse", "--is-shallow-repository"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() === "true" + ); + } catch { + return false; + } +} + +function isCommitAvailable(commit: string): boolean { + try { + execFileSync("git", ["cat-file", "-e", `${commit}^{commit}`], { + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + describe("privacy readiness contract", () => { it("accepts the honest structural register", () => { - expect(validatePrivacyReadiness(manifest)).toEqual([]); + let checkGit = true; + if (!isCommitAvailable(manifest.reviewedCommit) && isShallowClone()) { + console.warn( + `PRIVACY_READINESS_SHALLOW_CLONE: reviewedCommit ${manifest.reviewedCommit} is unavailable in shallow clone; skipping commit ancestry check.`, + ); + checkGit = false; + } + expect(validatePrivacyReadiness(manifest, { checkGit })).toEqual([]); }); it("keeps Railway processor evidence linked to the privacy impact assessment", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b44074d60..e154bc15b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1525,6 +1525,28 @@ test.describe("PsychSift UI smoke coverage", () => { expect(csp).toContain("https://*.supabase.co"); }); + /* + * The Documents half of the same defect. `/documents` redirects to the shared + * home, and `documents/page.tsx` records the idle Documents view as deliberately + * retired — but clearing the composer on a submitted search left `run=1` in the + * URL, so the shared home stayed suppressed and DocumentSearchResultsPanel fell + * back to that retired "Start here" home (`document-search-empty-state`). + */ + test("clearing a documents search returns the shared home, never the retired Start here view", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + await gotoApp(page, "/documents/search?q=lithium+monitoring&run=1"); + + await page + .getByRole("button", { name: /clear search question|clear search/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/\?mode=documents&focus=1$/); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); + await expect(page.getByTestId("document-search-empty-state")).toHaveCount(0); + }); + test("static agent guidance is available and documents mode avoids the app error boundary", async ({ page }) => { const llms = await page.request.get("/llms.txt"); expect(llms.status()).toBe(200); @@ -2947,18 +2969,19 @@ test.describe("PsychSift UI smoke coverage", () => { await visibleAnswerSubmitButton(page).click(); await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("answer-streaming")).toHaveCount(0); - // The library matches are one collapsed line in the answer's evidence stack - // and open on demand. Still asserted end to end rather than dropped — - // open it and the same two links are there, at full tap size. + // The library matches sit in one line in the answer's evidence stack, open on + // arrival and collapsible (owner decision, 2026-09-07). The links are + // asserted where they rest, at full tap size, and the line is then collapsed + // for the geometry below. const relatedRegion = page.getByRole("region", { name: "Related pages in other modes" }); const relatedTrigger = relatedRegion.getByTestId("cross-mode-links-line-trigger"); - await relatedTrigger.click(); + await expect(relatedTrigger).toHaveAttribute("aria-expanded", "true"); const relatedItems = relatedRegion.getByRole("listitem"); await expect(relatedItems).toHaveCount(2); await expect(relatedItems.last()).toBeVisible(); - // Collapse it again before the geometry below. The rest of this test - // measures the answer's scroll runway in its resting state, and an expanded - // panel adds ~88px of content that the page does not carry by default. + // Collapse before the geometry below. The rest of this test measures the + // answer's scroll runway with the line put away, which is the state the + // collapse exists to give the reader. await relatedTrigger.click(); await expect(relatedItems.last()).toBeHidden(); @@ -3112,7 +3135,7 @@ test.describe("PsychSift UI smoke coverage", () => { await page.setViewportSize({ width: 320, height: 844 }); // Re-open the library line: the tap-target sweep below is about the links - // inside it, and the geometry block above needed it resting closed. + // inside it, and the geometry block above deliberately collapsed it. await relatedTrigger.click(); const compactCrossModeRail = page.getByTestId("cross-mode-links-rail"); await expect(compactCrossModeRail).toBeVisible(); @@ -3273,19 +3296,21 @@ test.describe("PsychSift UI smoke coverage", () => { // shipped broken on desktop precisely because the one test that checked the // collapse ran at 390px: `hidden` beside a `md:flex` in the same class list // loses to the media-query rule from 768px up, so the rail stayed open while - // its trigger reported `aria-expanded="false"`. + // its trigger reported `aria-expanded="false"`. Now that the line rests OPEN + // the same mechanic would hide the failure the other way round, which is why + // the closed state below is still asserted on the rail's computed display + // rather than on the trigger's word for it. const trigger = strip.getByTestId("cross-mode-links-line-trigger"); const rail = strip.getByTestId("cross-mode-links-rail"); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(rail).toBeHidden(); - await trigger.click(); await expect(trigger).toHaveAttribute("aria-expanded", "true"); await expect(rail).toBeVisible(); await expect(rail).toHaveCSS("display", "flex"); - // Close and re-open: the collapse is the half that regressed. + // Close and re-open: the collapse is the half that regressed before. await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); await expect(rail).toBeHidden(); await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); await expect(rail).toBeVisible(); await page.keyboard.press("Escape"); await expect(strip.getByText("Medication", { exact: true }).filter({ visible: true })).toBeVisible(); @@ -3990,22 +4015,29 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(page).toHaveURL(/\/dsm\/search\?q=major\+depressive&focus=1&run=1$/, { timeout: 30_000, }); - await expect(page.getByTestId("dsm-search-page")).toBeVisible(); - const queryRibbon = page.getByTestId("search-query-ribbon"); + // Own the visible page root and derive every in-page locator from it (#093). + // Scoping only the root assertion still leaves the ribbon and result rows + // resolving across both copies once a hidden streaming twin exists. + const dsmPage = visibleByTestId(page, "dsm-search-page"); + await expect(dsmPage).toBeVisible(); + const queryRibbon = dsmPage.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { name: "major depressive" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter diagnoses by category" })).toBeVisible(); - const result = page.getByTestId("dsm-search-result").filter({ hasText: "Major depressive disorder" }); + const result = dsmPage.getByTestId("dsm-search-result").filter({ hasText: "Major depressive disorder" }); await expect(result).toBeVisible(); await expectMinTouchTarget(result.getByRole("button", { name: "Add Major depressive disorder to comparison" })); await expectMinTouchTarget(result.getByRole("link", { name: "Open Major depressive disorder" })); await result.getByRole("link", { name: "Open Major depressive disorder" }).click(); await expect(page).toHaveURL(/\/dsm\/diagnoses\/major-depressive-disorder$/, { timeout: 30_000 }); - await expect(page.getByTestId("dsm-diagnosis-page")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("heading", { level: 1, name: "Major depressive disorder" })).toBeVisible(); + const diagnosisPage = visibleByTestId(page, "dsm-diagnosis-page"); + await expect(diagnosisPage).toBeVisible({ timeout: 30_000 }); + await expect(diagnosisPage.getByRole("heading", { level: 1, name: "Major depressive disorder" })).toBeVisible(); // The breadcrumb row went with the in-page header: its back control is the // one route out to the DSM search catalogue, not the shared home composer. + // It stays page-scoped because that header portals out of the page root, + // the same reason the filter panel below is not scoped either. await expect(page.getByRole("link", { name: "Back to dsm-5" })).toHaveAttribute("href", "/dsm/search"); await expectNoPageHorizontalOverflow(page); }); @@ -4051,8 +4083,12 @@ test.describe("PsychSift UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/dsm/search?q=depression"); - await expect(page.getByTestId("dsm-search-page")).toBeVisible(); - const trigger = page.getByTestId("dsm-category-filter-desktop"); + const dsmPage = visibleByTestId(page, "dsm-search-page"); + await expect(dsmPage).toBeVisible(); + // The trigger is rendered inside the page root, so it needs the same owner. + // The panel below deliberately stays page-scoped: `ResultFilterSheet` + // renders through `OverlayPortal`, so it lives outside this subtree. + const trigger = dsmPage.getByTestId("dsm-category-filter-desktop"); await trigger.focus(); await page.keyboard.press("Enter"); const panel = page.getByTestId("dsm-category-filter-panel"); @@ -5106,28 +5142,27 @@ test.describe("PsychSift UI smoke coverage", () => { expect(requestCounts.quality).toBe(0); }); - test("tools mode searches the existing applications registry inside the dashboard", async ({ page }) => { + // The legacy `/?mode=tools` URL used to render a second, hub-shaped launcher. It now + // redirects to `/tools`, so the query it carries has to survive the hop and land on + // the one tools directory with the same registry search behind it. + test("the legacy tools URL carries its query to the tools directory", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockPrivateUnauthenticatedApi(page); await gotoApp(page, "/?mode=tools&q=medications&focus=1&run=1"); + await page.waitForURL(/\/tools\?/); await expect(page.getByRole("button", { name: "Mode Tools" })).toBeVisible(); - await expect(page.locator('input[placeholder="Search tools..."]:visible').first()).toHaveValue("medications"); - await expect(page.getByTestId("tools-hub")).toBeVisible(); - const queryRibbon = page.getByTestId("tools-hub").getByTestId("search-query-ribbon"); + const results = page.getByTestId("tools-search-results-page"); + await expect(results).toBeVisible(); + const queryRibbon = results.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { name: "medications" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter tools by category" })).toBeVisible(); - await expect(page.getByTestId("tools-hub").getByTestId("application-row-medication-prescribing")).toContainText( - "Medication Prescribing", - ); - await expect(page.getByTestId("tools-hub").getByText("Selected tool")).toHaveCount(0); - const detailsButton = page - .getByTestId("tools-hub") - .getByRole("button", { name: "View details for Medication Prescribing" }); - await expect(detailsButton).toHaveAttribute("aria-haspopup", "dialog"); - await detailsButton.click(); + await expect(results.getByRole("heading", { level: 2, name: "Medication Prescribing" }).first()).toBeVisible(); + // The verb shortcut row is for an unqueried catalogue, so a running query hides it. + await expect(page.getByTestId("tools-shortcuts")).toHaveCount(0); + await results.getByRole("button", { name: "View details for Medication Prescribing" }).click(); await expect( - page.getByRole("dialog", { name: "Medication Prescribing" }).locator('a[href="/medications"]').first(), + results.getByRole("complementary", { name: "Medication Prescribing" }).locator('a[href="/medications"]').first(), ).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 666f8d5e0..caf6b0832 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -449,6 +449,47 @@ test.describe("Medication responsive stress coverage", () => { }, { storageKey: PATIENT_PROFILE_STORAGE_KEY }, ); + // The cross-mode lookup is eager at phone width, and a tray with nothing + // behind it is now dropped rather than shown as a header that opens onto + // nothing. So the panel this test is about only exists if the endpoint + // returns a match outside Medication — mocked here rather than left to + // whatever the demo corpus happens to hold for this query. + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + const group = { + kind: "documents", + total: 1, + latencyMs: 2, + items: [ + { + id: "acamprosate-guideline", + kind: "documents", + title: "Acamprosate prescribing guideline", + href: "/documents/acamprosate-guideline", + score: 0.86, + }, + ], + }; + const response = { + query, + tookMs: 8, + demoMode: true, + groups: [group], + contextMode: "prescribing", + preferredDomains: ["medications"], + domainOrder: ["medications", "documents"], + }; + // The endpoint streams NDJSON, one event per line. A single JSON object + // parses to nothing and the panel then correctly drops itself. + const events = [ + { type: "group", query, group }, + { type: "complete", response }, + ]; + await route.fulfill({ + body: `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, + contentType: "application/x-ndjson; charset=utf-8", + }); + }); await page.setViewportSize({ width: 320, height: 720 }); await page.goto("/?mode=prescribing&q=acamprosate%20renal%20dose&run=1", { waitUntil: "domcontentloaded" }); diff --git a/tests/ui-tools-show-all.spec.ts b/tests/ui-tools-show-all.spec.ts index dd78924cf..0a7536330 100644 --- a/tests/ui-tools-show-all.spec.ts +++ b/tests/ui-tools-show-all.spec.ts @@ -6,26 +6,23 @@ async function gotoLauncher(page: Page, path = "/tools") { await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); } -test("Show all opens the unfiltered tools search mode", async ({ page }) => { +/** + * Tools used to have two surfaces: a hub at `/?mode=tools` whose "Show all" chip + * linked across to the directory at `/tools`. Which one a clinician landed on + * depended only on how they arrived. `/?mode=tools` now redirects, so the chip has + * nothing left to link to and the assertion worth keeping is that the alias lands on + * the one directory with its verb shortcuts intact. + */ +test("the legacy tools alias lands on the single tools directory", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/?mode=tools"); - const showAll = page.getByRole("link", { name: "Show all tools" }); - await expect(showAll).toBeVisible(); - await expect(showAll).toHaveText("Show all"); - await expect(showAll).toHaveAttribute("href", "/tools"); - - const showAllBox = await showAll.boundingBox(); - expect(showAllBox?.height).toBeGreaterThanOrEqual(48); - - await showAll.focus(); - await expect(showAll).toBeFocused(); - - await Promise.all([page.waitForURL(/\/tools$/), page.keyboard.press("Enter")]); + await page.waitForURL(/\/tools$/); const results = page.getByTestId("tools-search-results-page"); await expect(results).toBeVisible(); await expect(results.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); + await expect(page.getByTestId("tools-shortcuts")).toBeVisible(); await expect(results.getByRole("link", { name: "Open PsychSift Search" })).toHaveAttribute("href", "/?mode=answer"); await expect(results.getByRole("button", { name: "View details for PsychSift Search" })).toBeVisible(); await expect(page.getByTestId("global-search-input")).toHaveCount(0); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 22ceaa854..d6df76f2c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -475,42 +475,47 @@ test.describe("PsychSift tools directory and legacy launcher", () => { { name: "mobile", width: 390, height: 820 }, { name: "desktop", width: 1280, height: 900 }, ] as const) { - test(`tools launcher is usable at ${viewport.name}`, async ({ page }) => { + /** + * This case used to load `/?mode=tools`, the hub that was one of two Tools + * surfaces. That alias now redirects to the directory, so the same contract is + * asserted here on the single surface: the verb shortcut row ported over from + * the hub, the category filter, the detail sheet with a real launch link, and no + * shared search chrome on a route that owns its own filtering. + */ + test(`the tools directory is usable at ${viewport.name}`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await gotoLauncher(page, "/?mode=tools"); + await gotoLauncher(page, "/tools"); - await expect(page.getByRole("heading", { level: 1, name: "Tools" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Quick tool shortcuts" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "All tools" })).toBeVisible(); - await expect(page.locator("#launcher-results-panel")).toHaveAttribute("role", "group"); - await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "All tools"); + const results = visibleByTestId(page, "tools-search-results-page"); + await expect(results.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Quick tool shortcuts" }).first()).toBeVisible(); + await expect(results.getByRole("region", { name: "Tool results" })).toBeVisible(); if (viewport.name === "mobile") { - const categoryTrigger = page.getByTestId("tool-filter-trigger-phone"); + const categoryTrigger = results.getByTestId("tools-search-filter-trigger-phone"); await expect(categoryTrigger).toBeVisible(); - await expect(categoryTrigger).toHaveAccessibleName(/No filters active/); await categoryTrigger.click(); - await page.getByRole("radiogroup", { name: "Category" }).getByRole("radio", { name: "Assess" }).click(); - await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "Assess tools"); - await expect(categoryTrigger).toHaveAccessibleName(/1 filter active/); + const filterSheet = page.locator('[data-testid="tools-search-filter-sheet"]:visible'); + await filterSheet.getByRole("radio", { name: /Assess/ }).click(); + await filterSheet.getByTestId("tools-search-filter-sheet-done").click(); + await expect(results.getByRole("heading", { level: 2, name: "Medication Prescribing" })).toHaveCount(0); await categoryTrigger.click(); - await page.getByRole("radiogroup", { name: "Category" }).getByRole("radio", { name: "All tools" }).click(); - await page.getByTestId("application-row-medication-prescribing").click(); - const selectedSheet = page.getByRole("dialog", { name: "Medication Prescribing" }); - await expect(selectedSheet).toBeVisible(); - await expect(selectedSheet.getByRole("heading", { name: "Medication Prescribing" })).toBeVisible(); - const mobileLaunchLink = selectedSheet.locator('a[href="/medications"]').first(); + await filterSheet.getByRole("radio", { name: /All tools/ }).click(); + await filterSheet.getByTestId("tools-search-filter-sheet-done").click(); + + await results.getByRole("button", { name: "View details for Medication Prescribing" }).click(); + const detailSheet = page.locator('[data-testid="tools-search-detail-sheet"]:visible'); + await expect(detailSheet.getByRole("heading", { name: "Medication Prescribing" })).toBeVisible(); + const mobileLaunchLink = detailSheet.locator('a[href="/medications"]').first(); await expect(mobileLaunchLink).toBeVisible(); - await expect(mobileLaunchLink).toHaveAttribute("href", "/medications"); await expect(mobileLaunchLink).not.toHaveAttribute("target", "_blank"); - await page.getByRole("button", { name: "Close Medication Prescribing" }).click(); - await expect(selectedSheet).toBeHidden(); + await detailSheet.getByRole("button", { name: "Close Medication Prescribing" }).click(); + await expect(detailSheet).toHaveCount(0); } else { - await expect(page.getByRole("button", { name: "View details for PsychSift Search" })).toBeVisible(); + await expect(results.getByRole("button", { name: "View details for PsychSift Search" })).toBeVisible(); } - await expect(page.getByLabel("Mode Tools")).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Tools" })).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(0); await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); - await expect(page.getByTestId("tools-local-search-input")).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); } @@ -531,7 +536,10 @@ test.describe("PsychSift tools directory and legacy launcher", () => { ); await expect(visibleGlobalSearchInput(page)).toHaveCount(0); await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); - await expect(page.getByTestId("tools-local-search-input")).toHaveCount(0); + // The route owns its filtering, so it has its own in-flow box and no shared + // composer. This box came here from the retired `/?mode=tools` hub, which was + // the only place a tools search could be typed until that alias redirected. + await expect(page.getByTestId("tools-local-search-input")).toBeVisible(); const categories = results.getByRole("radiogroup", { name: "Tool category" }); await categories.getByRole("radio", { name: /Treat/ }).click(); @@ -668,10 +676,14 @@ test.describe("PsychSift tools directory and legacy launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("launcher links point to the expected in-app modes", async ({ page }) => { + // Moved off `/?mode=tools` when that alias started redirecting here. The hrefs are + // the point of the case and are unchanged: both surfaces read them from the same + // tools catalogue, so the directory proves the same contract the hub used to. + test("tool links point to the expected in-app modes", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await gotoLauncher(page, "/?mode=tools"); + await gotoLauncher(page, "/tools"); + const results = visibleByTestId(page, "tools-search-results-page"); for (const [title, href] of [ ["Medication Prescribing", "/medications"], ["Documents", "/documents"], @@ -680,12 +692,10 @@ test.describe("PsychSift tools directory and legacy launcher", () => { ["Saved workflows", "/favourites"], ["PsychSift Search", "/?mode=answer"], ] as const) { - const detailsButton = page.getByRole("button", { name: `View details for ${title}` }); - await expect(detailsButton).toHaveAttribute("aria-haspopup", "dialog"); - await detailsButton.click(); - const dialog = page.getByRole("dialog", { name: title }); - await expect(dialog.locator(`a[href="${href}"]`).first()).toBeVisible(); - await page.getByRole("button", { name: `Close ${title}` }).click(); + await expect(results.getByRole("link", { name: `Open ${title}` })).toHaveAttribute("href", href); + await results.getByRole("button", { name: `View details for ${title}` }).click(); + const detail = results.getByRole("complementary", { name: title }); + await expect(detail.locator(`a[href="${href}"]`).first()).toBeVisible(); } // External companion-app launchers were removed; no localhost links should remain. await expect(page.locator('a[href^="http://localhost"], a[href^="http://127.0.0.1"]')).toHaveCount(0); @@ -1317,11 +1327,12 @@ test.describe("PsychSift tools directory and legacy launcher", () => { } }); - test("tablet legacy Tools alias uses its local filter without shared search chrome", async ({ page }) => { + test("tablet legacy Tools alias redirects to the directory without shared search chrome", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await gotoLauncher(page, "/?mode=tools"); - await expect(page.getByRole("heading", { level: 1, name: "Tools" })).toBeVisible(); + await page.waitForURL(/\/tools$/); + await expect(page.getByRole("heading", { level: 1, name: "All tools" })).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveCount(0); await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); await expect(page.getByTestId("tools-local-search-input")).toBeVisible(); @@ -3293,6 +3304,28 @@ test.describe("Responsive layout guards", () => { // click/query assertions have nothing left to click. The still-live half of // the second test — the submitted-search results view — survives below, // reached directly instead of via a pill click. + /* + * Clearing the composer used to empty the React query while leaving `q` and + * `run=1` in the URL. `showSharedHome` reads `run=1` off the URL, so it stayed + * suppressed while MedicationPrescribingWorkspace, now seeing no query, fell back + * to `medication-home` — the Dose/Safety/Monitoring tiles `/medications` was + * consolidated away from, and which this file's own note above calls "retired and + * no longer reachable from any route". It was reachable, by this exact click. + */ + test("clearing a prescribing search returns the shared home, never the retired medication home", async ({ page }) => { + await mockAnswerDashboardApi(page); + await gotoLauncher(page, "/?mode=prescribing&q=acamprosate%20renal%20dose&run=1"); + + await page + .getByRole("button", { name: /clear search question|clear search/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/\?mode=prescribing&focus=1$/); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); + await expect(page.getByTestId("medication-home")).toHaveCount(0); + }); + test("prescribing submitted search keeps results above the phone bottom dock", async ({ page }) => { await page.setViewportSize({ width: 320, height: 760 }); await mockAnswerDashboardApi(page); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index f783c4252..7de6ebca2 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -389,7 +389,7 @@ test.describe("universal search typeahead", () => { ).toBe(true); }); - test("loads submitted cross-mode matches on phones only after expansion", async ({ page }) => { + test("states the phone cross-mode count on the closed header, before any expansion", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const universalRequests: string[] = []; page.on("request", (request) => { @@ -401,10 +401,18 @@ test.describe("universal search typeahead", () => { const alsoMatches = page.getByTestId("universal-also-matches"); await expect(alsoMatches).toBeVisible(); await expect(alsoMatches).toHaveCount(1); - expect(universalRequests).toHaveLength(0); - - await alsoMatches.getByRole("button", { name: /Also matches in other modes/ }).click(); + // Eager at phone width too. A closed row that says "Tap to open" is a blind + // door: it cannot promise the tray holds anything, and the empty tray was + // still rendered. The lookup runs on submit so the header states a count. await expect.poll(() => universalRequests.length).toBe(1); + + const trigger = alsoMatches.getByRole("button", { name: /Also matches in other modes/ }); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(alsoMatches).not.toContainText("Tap to open"); + await expect(alsoMatches.getByRole("link", { name: "Acamprosate", exact: true })).toBeHidden(); + + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); await expect(alsoMatches.getByRole("link", { name: "Acamprosate", exact: true })).toBeVisible(); }); @@ -496,6 +504,90 @@ test.describe("universal search smart affordances", () => { await expect(page).toHaveURL(/mode=answer/); }); + test("reaches a mode no local catalogue can resolve from the answer's library line", async ({ page }) => { + // The coverage this change exists for. The line used to resolve links from + // four catalogues in the browser (medications, services, forms, + // differentials), so an answer could never point at a DSM diagnosis however + // squarely the question named one. Everything below the mock is real: the + // request the surface makes, the title gate it applies, and the card. + const universalRequests: string[] = []; + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + const requestUrl = new URL(route.request().url()); + universalRequests.push(requestUrl.searchParams.get("domains") ?? ""); + await fulfillUniversalSearch(route, { + ...universalPayload, + query: requestUrl.searchParams.get("q") ?? "", + contextMode: "answer", + preferredDomains: ["documents"], + domainOrder: ["dsm"], + groups: [ + { + kind: "dsm", + total: 1, + latencyMs: 3, + items: [ + { + id: "bipolar-i-disorder", + kind: "dsm", + title: "Bipolar I Disorder", + subtitle: "Manic episode required", + href: "/dsm/bipolar-i-disorder", + score: 18, + }, + ], + }, + ], + }); + }); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + if (new URL(route.request().url()).pathname.endsWith("/stream")) { + await route.fulfill({ + body: [ + `event: progress\ndata: ${JSON.stringify({ stage: "complete", message: "Answer ready.", elapsedMs: 40 })}`, + `event: final\ndata: ${JSON.stringify(syntheticAnswer)}`, + "", + ].join("\n\n"), + contentType: "text/event-stream; charset=utf-8", + }); + return; + } + await route.fulfill({ json: syntheticAnswer }); + }); + + const input = await openComposer(page, "/?mode=answer&focus=1"); + await input.fill("bipolar disorder criteria"); + await page.getByRole("button", { name: "Generate source-backed answer" }).click(); + + const line = page.getByTestId("cross-mode-links"); + await expect(line).toBeVisible({ timeout: 15_000 }); + // Still one panel on this surface, never both. The mode-level tray staying + // out is the other half of that contract and is asserted beside it. + await expect(line).toHaveCount(1); + await expect(page.getByTestId("universal-also-matches")).toHaveCount(0); + + // Open on arrival: no tap needed to reach the widened half. + await expect(page.getByTestId("cross-mode-links-line-trigger")).toHaveAttribute("aria-expanded", "true"); + const rail = page.getByTestId("cross-mode-links-rail"); + await expect(rail.getByRole("link", { name: "Open Bipolar I Disorder" })).toBeVisible(); + await expect(rail.getByRole("button", { name: /Search Bipolar I Disorder in DSM-5 Diagnosis/ })).toBeVisible(); + + // The domains the catalogue half already resolves are absent from the request + // itself, so one record can never arrive down both paths and print twice on + // one line, and the answer's own documents are not re-listed under an answer + // that already cites them. + expect(universalRequests.length).toBeGreaterThan(0); + for (const domains of universalRequests) { + expect(domains.split(",").sort()).toEqual([ + "dictionary", + "dsm", + "formulation", + "specifiers", + "therapies", + "tools", + ]); + } + }); + test("keeps a completed Answer query eligible for submitted cross-mode matches", async ({ page }) => { await mockSmartSearch(page); const input = await openComposer(page, "/?mode=answer&focus=1"); @@ -578,6 +670,34 @@ test.describe("universal search smart affordances", () => { await expect(page.getByTestId("universal-also-matches")).toHaveCount(0); }); + test("clearing a pending Answer request stays on the shared home after the response settles", async ({ page }) => { + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + try { + await route.fulfill({ json: syntheticAnswer }); + } catch { + // Aborting the request is the expected clear-path outcome. + } + }); + + const input = await openComposer(page, "/?mode=answer&focus=1"); + await input.fill("acamprosat"); + await page.getByRole("button", { name: "Generate source-backed answer" }).click(); + await expect(page.getByTestId("answer-progress")).toBeVisible(); + + await page + .getByRole("button", { name: /clear search question|clear search/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/\?mode=answer&focus=1$/); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); + await page.waitForTimeout(1_250); + await expect(page).toHaveURL(/\/\?mode=answer&focus=1$/); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); + await expect(page.locator('[data-dashboard-stage="answer-surface"]')).toHaveCount(0); + }); + test("keeps a saved exact match first in Favourites", async ({ page }) => { await mockSmartSearch(page); const input = await openComposer(page, "/favourites?focus=1"); diff --git a/tests/universal-also-matches-mode-coverage.test.ts b/tests/universal-also-matches-mode-coverage.test.ts index 0d396bfef..182dc9a2f 100644 --- a/tests/universal-also-matches-mode-coverage.test.ts +++ b/tests/universal-also-matches-mode-coverage.test.ts @@ -23,9 +23,13 @@ const MOUNTS: Record { expect(entry.because.length, "an exemption must carry its reason").toBeGreaterThan(80); expect(read(entry.file)).toContain("CrossModeLinksSection"); }); + + it(`keeps ${modeId}'s own line reaching the modes no catalogue can resolve`, () => { + // The exemption above is only honest while the line actually reaches the + // other modes. Without this opt-in it falls back to four client-side + // catalogues, and an answer can never point at a DSM diagnosis, a + // dictionary term, a formulation, a specifier, a therapy or a tool — + // which is the coverage gap the exemption now claims is closed. + expect(read(entry.file), `${entry.file} must pass universalMode to CrossModeLinksSection`).toMatch( + / { diff --git a/tests/universal-also-matches-submitted-query.test.ts b/tests/universal-also-matches-submitted-query.test.ts new file mode 100644 index 000000000..0e674a3fd --- /dev/null +++ b/tests/universal-also-matches-submitted-query.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * THE DEFECT. On a phone dashboard result view, editing the bottom composer calls `setQuery` + * alone: the results and `modeSearchSubmitted` both stay as they were. The cross-mode + * also-matches lookup was keyed on `query`, so a paused draft fetched matches for a search the + * reader never ran, replaced the tray and its "N related modes" count, and left those matches + * disagreeing with the primary cards still on screen for the last submitted query. + * + * The fix keys the lookup on the submitted query instead. These assertions read the shipped + * source, because the behaviour lives in how ClinicalDashboard derives one value and cannot be + * reached without mounting the whole dashboard. + */ +const DASHBOARD = readFileSync(join(__dirname, "..", "src/components/ClinicalDashboard.tsx"), "utf8"); + +const SUBMITTED_SEARCH = readFileSync( + join(__dirname, "..", "src/components/clinical-dashboard/use-submitted-mode-search.ts"), + "utf8", +); + +function sourceOf(pattern: RegExp, source = DASHBOARD): string { + const match = source.match(pattern); + expect(match, `expected to find ${pattern}`).toBeTruthy(); + return match![0]; +} + +describe("cross-mode also-matches follows the submitted query, not the draft", () => { + it("derives the lookup query from submittedModeQuery rather than the live composer query", () => { + const derivation = sourceOf(/const universalAlsoMatchesQuery =[\s\S]*?;\n/); + expect(derivation).toContain("submittedModeQuery ?? query"); + // The regression this guards: the whole non-answer arm used to be the bare draft. + expect(derivation).not.toMatch(/:\s*query;\s*$/); + }); + + it("keeps answer mode on the generated answer's query", () => { + const derivation = sourceOf(/const universalAlsoMatchesQuery =[\s\S]*?;\n/); + expect(derivation).toContain('activeModeResultKind === "answer" ? (latestAnswerQuery ?? query)'); + }); + + it("holds submittedModeQuery separately from query, so typing cannot move it", () => { + expect(DASHBOARD).toContain( + "const { modeSearchSubmitted, submittedModeQuery, setModeSearchSubmitted } = useSubmittedModeSearch({", + ); + expect(SUBMITTED_SEARCH).toMatch(/const \[submittedModeQuery, setSubmittedModeQuery\] = useState/); + // Nothing may write the submitted query except the submission wrapper, or a composer edit + // would reach it after all. + const writes = [...SUBMITTED_SEARCH.matchAll(/setSubmittedModeQuery\(/g)]; + expect(writes.length).toBe(2); + const wrapper = sourceOf(/const setModeSearchSubmitted = useCallback\([\s\S]*?\n \}, \[\]\);/, SUBMITTED_SEARCH); + expect((wrapper.match(/setSubmittedModeQuery\(/g) ?? []).length).toBe(2); + }); + + it("records the submitted text at every submission, and clears it when submission is cleared", () => { + const submissions = [...DASHBOARD.matchAll(/setModeSearchSubmitted\(true[^)]*\)/g)].map((m) => m[0]); + expect(submissions.length).toBeGreaterThan(0); + // Every submission passes the text it submitted; a bare `true` would leave the previous + // query in place and reintroduce the mismatch. + for (const call of submissions) { + expect(call, `submission without its query: ${call}`).toMatch(/setModeSearchSubmitted\(true, \S+\)/); + } + const wrapper = sourceOf(/const setModeSearchSubmitted = useCallback\([\s\S]*?\n \}, \[\]\);/, SUBMITTED_SEARCH); + expect(wrapper).toContain("if (!submitted) setSubmittedModeQuery(null);"); + }); + + it("seeds the submitted query from an auto-run URL, so a restored result view is not blank", () => { + const seed = sourceOf(/const \[submittedModeQuery, setSubmittedModeQuery\][\s\S]*?\);\n/, SUBMITTED_SEARCH); + expect(seed).toContain("autoRunSearch"); + expect(seed).toContain("initialQuery.trim()"); + expect(seed).toContain('initialSearchMode !== "tools"'); + }); +}); + +/** + * The lookup itself is gated on this query, so with the draft no longer reaching it, typing + * without pressing Enter changes neither the request nor the count. + */ +describe("the also-matches lookup is gated on that query", () => { + const PANEL = readFileSync( + join(__dirname, "..", "src/components/clinical-dashboard/universal-search-also-matches.tsx"), + "utf8", + ); + + it("enables the fetch only from the query it was given", () => { + expect(PANEL).toMatch(/enabled: trimmedQuery\.length >= 2 && searchActive/); + expect(PANEL).toMatch(/query: trimmedQuery,/); + }); + + it("renders its count from the same query, so the closed row cannot describe a draft", () => { + expect(PANEL).toContain("universal.query === trimmedQuery"); + }); +}); diff --git a/tests/use-submitted-mode-search.dom.test.tsx b/tests/use-submitted-mode-search.dom.test.tsx new file mode 100644 index 000000000..5568ba1a8 --- /dev/null +++ b/tests/use-submitted-mode-search.dom.test.tsx @@ -0,0 +1,30 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useSubmittedModeSearch } from "@/components/clinical-dashboard/use-submitted-mode-search"; + +describe("submitted mode search state", () => { + it("retains the submitted query when initial composer props change and clears it explicitly", () => { + const { result, rerender } = renderHook( + ({ initialQuery }) => + useSubmittedModeSearch({ autoRunSearch: true, initialQuery, initialSearchMode: "documents" }), + { initialProps: { initialQuery: " original search " } }, + ); + expect(result.current.submittedModeQuery).toBe("original search"); + rerender({ initialQuery: "an unsubmitted draft" }); + expect(result.current.submittedModeQuery).toBe("original search"); + act(() => result.current.setModeSearchSubmitted(true, " next submission ")); + expect(result.current.submittedModeQuery).toBe("next submission"); + expect(result.current.modeSearchSubmitted).toBe(true); + act(() => result.current.setModeSearchSubmitted(false)); + expect(result.current.submittedModeQuery).toBeNull(); + expect(result.current.modeSearchSubmitted).toBe(false); + }); + + it("does not seed a submitted result for tools", () => { + const { result } = renderHook(() => + useSubmittedModeSearch({ autoRunSearch: true, initialQuery: "query", initialSearchMode: "tools" }), + ); + expect(result.current.modeSearchSubmitted).toBe(false); + expect(result.current.submittedModeQuery).toBeNull(); + }); +});