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 (
+
+ );
+}
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");