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 0000000000..bbd16da767 --- /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 0000000000..24a35d825a --- /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 8d305892ba..94fc90be52 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. | @@ -1436,27 +1436,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 fdde9909c9..a1720f4a2e 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 44f39da693..02e039f8f9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3014,11 +3014,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 +3316,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} diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 619224498a..bf9d2ceb2e 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -88,7 +88,7 @@ const sidebarToolItems = [ // points straight at the shared home too, matching Documents/Services above. { id: "prescribing", label: appModeDefinition("prescribing").label, icon: Pill, href: "/?mode=prescribing" }, { id: "factsheets", label: "Factsheets", icon: appModeIcons.factsheets, href: "/?mode=factsheets" }, - // PT-11: standalone /tools is the canonical entry; /?mode=tools remains a dashboard-mode alias. + // PT-11: standalone /tools is the canonical entry; /?mode=tools redirects to it. { id: "tools", label: "Tools", icon: Wrench, href: "/tools" }, ] as const; diff --git a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts index 0779554450..3d3b69af2f 100644 --- a/src/components/clinical-dashboard/clinical-dashboard-helpers.ts +++ b/src/components/clinical-dashboard/clinical-dashboard-helpers.ts @@ -15,7 +15,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, diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index 58202f710b..e89573eea1 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -11,8 +11,9 @@ import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; const ApplicationsLauncherWorkspace = dynamic( () => 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/tools/tool-local-search.tsx b/src/components/tools/tool-local-search.tsx new file mode 100644 index 0000000000..36a38d9ecb --- /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 0000000000..a4b82767fb --- /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 1e98089303..0fc9906321 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 : ( +
+
+ +
+
+ +
+
+ )} ; /** @@ -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/tools-catalog.ts b/src/lib/tools-catalog.ts index bd7b5e90d9..1bac15ffad 100644 --- a/src/lib/tools-catalog.ts +++ b/src/lib/tools-catalog.ts @@ -59,6 +59,17 @@ export type ToolCatalogRecord = { output: string; }; +/** + * Tools the composer's smart search already answers directly, so surfacing them again + * as local results or shortcuts would send a clinician the long way round to something + * they have just been handed. + * + * One definition, three consumers. It was copied into the launcher and the tools + * directory separately, and a third copy was nearly added when the quick-action row + * moved out of the launcher. + */ +export const localSmartExcludedToolIds = new Set(["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 0d1026b78d..36e705fc83 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 diff --git a/tests/privacy-readiness-contract.test.ts b/tests/privacy-readiness-contract.test.ts index cdb7900116..bc5c0fdeef 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 b44074d60c..211349d9d0 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); @@ -5106,28 +5128,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-tools-show-all.spec.ts b/tests/ui-tools-show-all.spec.ts index dd78924cfa..0a75363300 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 22ceaa8543..d6df76f2cf 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 f783c42524..d563df3129 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -578,6 +578,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");