diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 4b1e305367..bdca9b0c45 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -360,8 +360,9 @@ export function ClinicalDashboard({ const submittedUrlModeMatchesActive = !submittedUrlMode || (isAppModeId(submittedUrlMode) && isAppModeVisible(submittedUrlMode) && submittedUrlMode === searchMode); + const submittedUrlRunRequested = searchParams.get("run") === "1"; const submittedUrlQuery = - autoRunSearch && searchParams.get("run") === "1" && submittedUrlModeMatchesActive + autoRunSearch && submittedUrlRunRequested && submittedUrlModeMatchesActive ? (searchParams.get("q") ?? searchParams.get("query") ?? "").trim() : ""; @@ -423,7 +424,7 @@ export function ClinicalDashboard({ // differentials results view can tell live-edited catalogue results apart // from evidence that belongs to a previously submitted search. const [differentialEvidenceQuery, setDifferentialEvidenceQuery] = useState(null); - const clearDifferentialModeResultState = useCallback(() => { + const clearModeResultState = useCallback(() => { resetAnswerThread(); setAnswer(null); setSources([]); @@ -1496,7 +1497,7 @@ export function ClinicalDashboard({ const shouldFocusComposer = searchParams.get("focus") === "1"; const hasUrlQuery = searchParams.has("q") || searchParams.has("query"); const frame = window.requestAnimationFrame(() => { - if (mode === "differentials") clearDifferentialModeResultState(); + if (mode === "differentials") clearModeResultState(); setSearchMode(mode); if (hasUrlQuery) setQuery(nextQuery); setModeSearchSubmitted(false); @@ -1506,7 +1507,7 @@ export function ClinicalDashboard({ if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); - }, [searchParams, clearDifferentialModeResultState, focusComposerInput]); + }, [searchParams, clearModeResultState, focusComposerInput]); useHomeModeSeed({ pathname, searchParams, lastAppMode }); @@ -1520,7 +1521,7 @@ export function ClinicalDashboard({ urlSearchBootstrappedRef.current = true; const targetMode = mode; const frame = window.requestAnimationFrame(() => { - if (targetMode === "differentials") clearDifferentialModeResultState(); + if (targetMode === "differentials") clearModeResultState(); setSearchMode(targetMode); // run=1 URLs name the latest answered question; the composer stays empty // while an answer thread is active (including after localStorage restore). @@ -1529,7 +1530,7 @@ export function ClinicalDashboard({ if (shouldFocusComposer && params.get("run") !== "1") focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); - }, [clearDifferentialModeResultState, focusComposerInput]); + }, [clearModeResultState, focusComposerInput]); const executeSearchRef = useRef(executeSearch); executeSearchRef.current = executeSearch; @@ -1859,7 +1860,7 @@ export function ClinicalDashboard({ setQuery(trimmedQuery); } if (modeSearch.kind !== "tools") setModeSearchSubmitted(true); - if (isDifferentialsMode) clearDifferentialModeResultState(); + if (isDifferentialsMode) clearModeResultState(); if (modeSearch.kind === "tools") { setLoading(false); @@ -2232,6 +2233,13 @@ export function ClinicalDashboard({ const trimmedQuery = query.trim(); const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery; const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch; + // Draft shared-home URLs must never auto-submit. A mode pick can update local + // mode/query one frame before the router drops the previous run=1 URL — suppress + // that stale frame only while the URL mode no longer matches local state. + // Intentional run=1 arrivals (Ask-this / crossModeSearch) keep mode+run aligned, + // so they must still submit even if modeChangeFromUiRef is still set. + if (pathname === "/" && !submittedUrlRunRequested) return; + if (modeChangeFromUiRef.current && !submittedUrlModeMatchesActive) return; if (!autoRunSearch || !submittedSearchText || !canAutoRunMode || loading) return; if (authStatus === "loading") return; if (!privateScopeReadyForRoute(routedSearchContext.scopeRef, privateScopeStatus, restoredPrivateScopeRef)) return; @@ -2270,6 +2278,9 @@ export function ClinicalDashboard({ void askRef.current(submittedSearchText, routedSearchContext, routedContextChanged); }, [ autoRunSearch, + pathname, + submittedUrlRunRequested, + submittedUrlModeMatchesActive, authStatus, canRunSearch, loading, @@ -2313,7 +2324,7 @@ export function ClinicalDashboard({ return; } modeChangeFromUiRef.current = true; - if (mode === "differentials") clearDifferentialModeResultState(); + if (mode === "differentials") clearModeResultState(); setQuery(crossQuery); setModeSearchSubmitted(false); setLoading(false); @@ -2334,6 +2345,15 @@ export function ClinicalDashboard({ } setSearchMode(mode); router.push(href); + // Submit immediately for dashboard-owned modes. Auto-run alone is racy here: + // modeChangeFromUiRef stays set until the URL-sync effect runs, and a late or + // suppressed auto-run leaves the run=1 pending shell with no /api/answer call + // (Ask-this bridge). Seed the signature so a later auto-run does not double-fire. + if (mode === "answer" || mode === "documents") { + const navigationContext = { queryMode, scopeFilters } as const; + autoRunSearchSignatureRef.current = searchSubmissionSignature(mode, crossQuery.trim(), navigationContext); + void executeSearch(crossQuery, mode, scopeFilters, queryMode, false); + } window.requestAnimationFrame(() => { scrollSurface(mainRef.current, 0, resolveScrollBehavior()); }); @@ -2621,35 +2641,24 @@ export function ClinicalDashboard({ return; } - // Results are on screen: carry the query into the newly picked mode rather - // than dropping it. crossModeSearch already owns that transition. + // Outside the shared home, every mode pick returns to `/`. Preserve the + // current question as an unsubmitted draft, but never carry `run=1` into the + // newly selected mode — only an explicit submit may open its result route. const carriedQuery = query.trim() || submittedUrlQuery.trim(); - if (carriedQuery) { - crossModeSearch(mode, carriedQuery); - return; - } - - // Nothing to carry: return to the shared home with the mode preselected. This - // always stays on `/`, so the transition is dashboard-internal — no unmount, - // and no chrome flip from an eager mode set before a route landed. - const href = appModeSelectionHref(mode, { queryMode, scopeFilters }); + const href = appModeSelectionHref(mode, { + query: carriedQuery || undefined, + queryMode, + scopeFilters, + }); modeChangeFromUiRef.current = true; - if (mode === "differentials") clearDifferentialModeResultState(); - setQuery(""); - if (mode === "answer") { - resetAnswerThread(); - setAnswer(null); - setSources([]); - } + // Dashboard stays mounted on `/`, so an in-flight Answer/documents request + // would still look current after this navigation. Abort and bump the seq + // before clearing UI; otherwise a late applySearchResult can repaint the + // old answer and replaceState a run=1 URL over the shared-home draft. + stopSearch(); + clearModeResultState(); + setQuery(carriedQuery); setModeSearchSubmitted(false); - setLoading(false); - setError(null); - setAnswerProgress(null); - setSearchRelevance(null); - setSearchFacets(null); - setSearchScope(null); - setSourceGovernanceWarnings([]); - setDocumentMatches([]); setSearchMode(mode); router.push(href); // Dashboard-internal mode flips keep the same scroller; jump to top so @@ -3010,13 +3019,7 @@ export function ClinicalDashboard({ // docs/search-chrome-behaviour.md — a mode pick must not flip composer reserve. const isHomeRoute = pathname === "/"; const showSharedHome = - isHomeRoute && - !error && - !answer && - !loading && - !modeSearchSubmitted && - !submittedUrlQuery && - !submittedAnswerSearchActive; + isHomeRoute && !submittedUrlRunRequested && !error && !answer && !loading && !submittedAnswerSearchActive; const showAnswerPending = activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error)); const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete"; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index d5e68b4d53..651a4cb0c3 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -653,19 +653,15 @@ function GlobalStandaloneSearchShellBody({ } setLastAppMode(mode); - // The mode pill retargets the composer; it no longer navigates to a mode home. - // From anywhere with a query in play, carry that query into the newly picked - // mode's search page rather than dropping it (this is the same transition the - // cross-mode chips make). With nothing to carry, return to the shared home at - // `/` with the mode preselected — that is now the single starting point. + // The mode pill always returns to the shared home. Preserve any current query + // as an unsubmitted draft, but omit `run=1`; only an explicit submit may open + // the selected mode's dedicated search/results surface. const carriedQuery = query.trim() || requestedQuery.trim(); - if (carriedQuery) { - setMobileMenuOpen(false); - router.push(appModeHomeHref(mode, { query: carriedQuery, run: true, queryMode, scopeFilters })); - return; - } - - const href = appModeSelectionHref(mode, { queryMode, scopeFilters }); + const href = appModeSelectionHref(mode, { + query: carriedQuery || undefined, + queryMode, + scopeFilters, + }); const destination = new URL(href, window.location.origin); const destinationSearch = destination.search.startsWith("?") ? destination.search.slice(1) : destination.search; const alreadyOnDestination = pathname === destination.pathname && searchParamString === destinationSearch; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 4921bf8ab8..71adb711e4 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -66,7 +66,7 @@ import { Sheet } from "@/components/ui/sheet"; import { appModeDefinition, appModeDefinitions, - appModeHomeHref, + appModeSelectionHref, appModeSearchConfig, isSearchableAppMode, visibleAppModeDefinitionsForSession, @@ -924,15 +924,11 @@ export function MasterSearchHeader({ // Prefetch only the mode the user is about to choose — the highlighted option // on open, then whichever option receives focus/pointer while scanning. // - // Picking a mode no longer navigates; submitting does. So warm the route the - // composer will push to, not the mode home nobody lands on from here any more. - // The destination path is query-independent, so a placeholder query resolves - // the right route (/dsm/search, /factsheets/search, /tools, …) without pinning - // the payload for one specific query. - function prefetchModeDestination(modeId: AppModeId) { + // A pick always returns to the shared home; warm that exact URL rather than a + // mode-owned home or search route the user has not asked to open. + function prefetchModeSelection(modeId: AppModeId) { if (modeId === searchMode) return; - const destination = appModeHomeHref(modeId, { query: "_", run: true }); - const href = destination.split(/[?#]/, 1)[0] || "/"; + const href = appModeSelectionHref(modeId); if (prefetchedModeHrefsRef.current.has(href)) return; prefetchedModeHrefsRef.current.add(href); router.prefetch(href, { @@ -954,7 +950,7 @@ export function MasterSearchHeader({ closeModeSurfaces(); const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; const highlighted = visibleAppModeOptions[nextIndex]; - if (highlighted) prefetchModeDestination(highlighted.id); + if (highlighted) prefetchModeSelection(highlighted.id); const phoneLayout = currentUsesPhoneSearchLayout(); setUsesPhoneSearchLayout(phoneLayout); setModeMenuFocusIndex(nextIndex); @@ -973,7 +969,7 @@ export function MasterSearchHeader({ return; } const highlighted = visibleAppModeOptions[selectedModeIndex]; - if (highlighted) prefetchModeDestination(highlighted.id); + if (highlighted) prefetchModeSelection(highlighted.id); setUsesPhoneSearchLayout(currentUsesPhoneSearchLayout()); setModeMenuFocusIndex(selectedModeIndex); setModeMenuOpen(true); @@ -1043,8 +1039,8 @@ export function MasterSearchHeader({ aria-label={`${mode.label}. ${mode.description}`} tabIndex={active ? 0 : -1} data-sheet-autofocus={usesPhoneSearchLayout && index === modeMenuFocusIndex ? "true" : undefined} - onFocus={() => prefetchModeDestination(mode.id)} - onPointerEnter={() => prefetchModeDestination(mode.id)} + onFocus={() => prefetchModeSelection(mode.id)} + onPointerEnter={() => prefetchModeSelection(mode.id)} onKeyDown={(event) => handleModeOptionKeyDown(event, index)} onClick={() => selectAppMode(mode)} className={cn( diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index ae304bf214..bf2e2bf2ee 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -135,15 +135,16 @@ describe("audit navigation and auth regressions", () => { "function handleModeTriggerKeyDown(", ); - expect(masterSearchHeaderSource).toContain("function prefetchModeDestination(modeId: AppModeId)"); + expect(masterSearchHeaderSource).toContain("function prefetchModeSelection(modeId: AppModeId)"); + expect(masterSearchHeaderSource).toContain("const href = appModeSelectionHref(modeId)"); expect(masterSearchHeaderSource).toContain("router.prefetch(href,"); expect(masterSearchHeaderSource).toContain("onInvalidate:"); - expect(modeOptions).toContain("onFocus={() => prefetchModeDestination(mode.id)}"); - expect(modeOptions).toContain("onPointerEnter={() => prefetchModeDestination(mode.id)}"); + expect(modeOptions).toContain("onFocus={() => prefetchModeSelection(mode.id)}"); + expect(modeOptions).toContain("onPointerEnter={() => prefetchModeSelection(mode.id)}"); // Menu-open paths warm only the highlighted option — never every visible home. - expect(openModeMenuWithFocus).toContain("prefetchModeDestination(highlighted.id)"); - expect(toggleModeMenu).toContain("prefetchModeDestination(highlighted.id)"); - expect(masterSearchHeaderSource).not.toContain("function prefetchModeDestinations("); + expect(openModeMenuWithFocus).toContain("prefetchModeSelection(highlighted.id)"); + expect(toggleModeMenu).toContain("prefetchModeSelection(highlighted.id)"); + expect(masterSearchHeaderSource).not.toContain("function prefetchModeSelections("); expect(masterSearchHeaderSource).not.toContain("visibleAppModeOptions.forEach((mode) => router.prefetch"); expect(masterSearchHeaderSource).not.toContain( "new Set(visibleAppModeOptions.map((mode) => appModeHomeHref(mode.id)))", diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index 44cd923e53..0efbf9a962 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -5,16 +5,13 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; -import { appModeHomeHref, visibleAppModeDefinitionsForSession, type AppModeId } from "@/lib/app-modes"; +import { appModeSelectionHref, visibleAppModeDefinitionsForSession, type AppModeId } from "@/lib/app-modes"; /** - * The route the composer will push to for `modeId`. Picking a mode no longer - * navigates — submitting does — so the menu warms the mode's *search* destination - * rather than its home. The path is query-independent, so a placeholder query - * resolves the right route without pinning one query's payload. + * The shared-home URL the mode picker itself will open for `modeId`. */ -function modeDestinationPath(modeId: AppModeId) { - return appModeHomeHref(modeId, { query: "_", run: true }).split(/[?#]/, 1)[0] || "/"; +function modeSelectionHref(modeId: AppModeId) { + return appModeSelectionHref(modeId); } const router = vi.hoisted(() => ({ @@ -90,11 +87,11 @@ describe("mode menu destination prefetch", () => { router.prefetch.mockReset(); }); - it("prefetches a mode search destination when the user points at that option", async () => { + it("prefetches the shared-home selection URL when the user points at a mode", async () => { const user = userEvent.setup(); const documents = guestModeHomes().find((mode) => mode.id === "documents"); expect(documents).toBeTruthy(); - const documentsHref = modeDestinationPath("documents"); + const documentsHref = modeSelectionHref("documents"); render(); await user.click(screen.getByRole("button", { name: /Mode Answer/i })); @@ -111,7 +108,7 @@ describe("mode menu destination prefetch", () => { it("warms a mode again after Next invalidates its cached payload", async () => { const user = userEvent.setup(); - const documentsHref = modeDestinationPath("documents"); + const documentsHref = modeSelectionHref("documents"); render(); await user.click(screen.getByRole("button", { name: /Mode Answer/i })); @@ -137,7 +134,7 @@ describe("mode menu destination prefetch", () => { expect(answerIndex).toBeGreaterThanOrEqual(0); const previous = modes[(answerIndex - 1 + modes.length) % modes.length]; expect(previous.id).not.toBe("answer"); - const previousHref = modeDestinationPath(previous.id); + const previousHref = modeSelectionHref(previous.id); render(); const trigger = screen.getByRole("button", { name: /Mode Answer/i }); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index be5cc133c4..b85257c2f1 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -104,15 +104,16 @@ describe("shared-search route ownership", () => { expect(shellSource).toContain("isStandaloneModeHomePath(pathname)"); expect(shellSource).not.toMatch(/searchMode === "services" && pathname === "\/services"/); // changeMode must not optimistic-set searchMode before navigation. - // The pill no longer opens a mode home: with a query in play it carries that - // query into the new mode's search page, and otherwise returns to the shared - // home at `/` with the mode preselected. Pending state still guards the push. + // The pill always returns to the shared home. A current query is carried only + // as a draft, never as `run=1`; pending state still guards the push. expect(shellSource).toMatch( - /function changeMode\(mode: AppModeId\) \{[\s\S]*?const carriedQuery = query\.trim\(\) \|\| requestedQuery\.trim\(\);\n if \(carriedQuery\) \{[\s\S]*?router\.push\(appModeHomeHref\(mode, \{ query: carriedQuery, run: true[\s\S]*?\n return;/, + /function changeMode\(mode: AppModeId\) \{[\s\S]*?const carriedQuery = query\.trim\(\) \|\| requestedQuery\.trim\(\);[\s\S]*?const href = appModeSelectionHref\(mode, \{[\s\S]*?query: carriedQuery \|\| undefined,[\s\S]*?router\.push\(href\);\n \}/, ); - expect(shellSource).toMatch( - /function changeMode\(mode: AppModeId\) \{[\s\S]*?const href = appModeSelectionHref\(mode[\s\S]*?router\.push\(href\);\n \}/, + const changeMode = shellSource.slice( + shellSource.indexOf("function changeMode("), + shellSource.indexOf("function startNewAnswerChat("), ); + expect(changeMode).not.toContain("run: true"); expect(shellSource).not.toMatch( /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?router\.push/, ); @@ -243,8 +244,32 @@ describe("shared-search route ownership", () => { // URL-sync effect skip, and the pill would never update. expect(sharedHomeBranch).not.toContain("modeChangeFromUiRef.current = true"); expect(sharedHomeBranch).not.toContain("setSearchMode("); - // Off the shared home a query in play is carried into the new mode, not dropped. - expect(selectSearchMode).toMatch(/crossModeSearch\(mode, carriedQuery\);/); + // Off the shared home, the current query becomes a draft on `/`; it is not + // submitted into the selected mode until the user explicitly asks. + expect(selectSearchMode).toMatch( + /const href = appModeSelectionHref\(mode, \{[\s\S]*?query: carriedQuery \|\| undefined/, + ); + // Returning home must invalidate the in-flight search before clearing UI — + // the dashboard stays mounted, so a late applySearchResult would otherwise + // restore the old answer and rewrite run=1 over the draft home. + const leaveResultsBranch = selectSearchMode.slice( + selectSearchMode.indexOf("// Outside the shared home"), + selectSearchMode.indexOf("function stageAnswerFollowUpDraft"), + ); + expect(leaveResultsBranch).toMatch(/stopSearch\(\);\s*clearModeResultState\(\);/); + expect(selectSearchMode.slice(0, selectSearchMode.indexOf("function stageAnswerFollowUpDraft"))).not.toContain( + "crossModeSearch(mode, carriedQuery)", + ); + expect(dashboardSource).toContain('if (pathname === "/" && !submittedUrlRunRequested) return;'); + expect(dashboardSource).toContain("if (modeChangeFromUiRef.current && !submittedUrlModeMatchesActive) return;"); + // Ask-this / cross-mode into Answer must not depend solely on auto-run: the + // dashboard stays mounted, so submit explicitly after pushing run=1. + expect(dashboardSource).toMatch( + /if \(mode === "answer" \|\| mode === "documents"\) \{[\s\S]*?void executeSearch\(crossQuery, mode/, + ); + expect(dashboardSource).toMatch( + /const showSharedHome =\s*isHomeRoute &&\s*!submittedUrlRunRequested &&[\s\S]*?!submittedAnswerSearchActive;/, + ); }); it("routes a submitted shared-composer search to the selected mode's own surface", () => { diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 27bd492944..82657f831a 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -202,12 +202,10 @@ test("moves a selected mechanism through framework, quality review, and an edita await expect(page.getByTestId("formulation-builder-structure")).toBeVisible(); const frameworkGroup = page.getByRole("radiogroup", { name: "Formulation framework" }); const cbtCycle = frameworkGroup.getByRole("radio", { name: /CBT cycle/ }); - // Input is `sr-only` (not actionable for Playwright hit-testing). Prefer - // role-based check with force so activation does not depend on scroll-into-view - // of a clipped control (Production UI shard 1 failure on PR #1788). Keep the - // radiogroup scope so shard contention with ui-specifiers cannot hit a stray - // "CBT cycle" text node (#257). - await cbtCycle.check({ force: true }); + // Controlled `sr-only` radios still flake under `locator.check({ force })` on + // Production UI shard 1 (state does not flip). Drive selection through the + // visible label click handler instead, then assert the radio role. + await frameworkGroup.getByText("CBT cycle", { exact: true }).click(); await expect(cbtCycle).toBeChecked(); await page .getByRole("textbox", { name: "Presenting problem" }) diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 33ef730bbc..3aae771108 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -398,7 +398,7 @@ test.describe("previously uncovered production routes", () => { await expect(remove).toBeEnabled(); await Promise.all([ currentPage.waitForURL(/\/dsm\/compare\?ids=bipolar-ii-disorder$/, { - timeout: 15_000, + timeout: 30_000, waitUntil: "domcontentloaded", }), remove.click(), diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 6779b99b79..cb141dfe4d 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -649,7 +649,36 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("header mode switches carry a submitted query into the new mode", async ({ page }) => { + test("dashboard mode switches return answer results to the shared home as a draft", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockAnswerDashboardApi(page); + await gotoLauncher(page, "/?mode=answer&q=lithium+dosing&run=1"); + await expect(page.getByTestId("plain-answer-response")).toHaveCount(1, { timeout: 30_000 }); + + const menu = await openAppModeMenu(page, "Answer"); + const formsMode = menu.getByRole("menuitemradio", { name: /^Forms\b/ }); + await waitForReactEventHandler(formsMode); + await formsMode.click(); + + await expect + .poll(() => { + const url = new URL(page.url()); + return { + pathname: url.pathname, + mode: url.searchParams.get("mode"), + query: url.searchParams.get("q"), + run: url.searchParams.get("run"), + }; + }) + .toEqual({ pathname: "/", mode: "forms", query: "lithium dosing", run: null }); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(visibleByTestId(page, "shared-home-empty-state")).toBeVisible(); + await expect(page.getByTestId("plain-answer-response")).toHaveCount(0); + await expect(visibleGlobalSearchInput(page)).toHaveValue("lithium dosing"); + await expectNoPageHorizontalOverflow(page); + }); + + test("header mode switches return results and mode homes to the shared home", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); @@ -665,28 +694,32 @@ test.describe("Clinical KB tools launcher", () => { await waitForReactEventHandler(formsMode); await formsMode.click(); - // Results are on screen, so the pick re-runs that query in the new mode - // rather than dropping it and returning to a blank home. - await expect(page).toHaveURL(/\/forms\?.*q=13YARN/, { timeout: 20_000 }); + // Results are cleared and the query becomes an unsubmitted draft on the + // universal home. The picker must not open or pre-run the Forms route. + await expect(page).toHaveURL(/\/\?mode=forms&q=13YARN$/, { timeout: 20_000 }); await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(visibleByTestId(page, "shared-home-empty-state")).toBeVisible(); + await expect(page.getByTestId("forms-home")).toHaveCount(0); + await expect(page.getByTestId("service-search-results")).toHaveCount(0); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); + await expect(visibleGlobalSearchInput(page)).toHaveValue("13YARN"); - // From a mode home with nothing submitted there is no query to carry, so the - // pick returns to the shared home with the new mode preselected. + // Re-selecting the current mode from its old home also returns to the shared + // home; same-mode picks must not be mistaken for no-ops on deeper routes. await gotoLauncher(page, "/forms"); await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); await expect(visibleByTestId(page, "forms-home")).toBeVisible(); menu = await openAppModeMenu(page, "Forms"); - const servicesMode = menu.getByRole("menuitemradio", { name: /^Services\b/ }); - await expect(servicesMode).toBeVisible(); - await waitForReactEventHandler(servicesMode); - await servicesMode.click(); + const currentFormsMode = menu.getByRole("menuitemradio", { name: /^Forms\b/ }); + await expect(currentFormsMode).toBeVisible(); + await waitForReactEventHandler(currentFormsMode); + await currentFormsMode.click(); - await expect(page).toHaveURL(/\/\?mode=services\b/, { timeout: 20_000 }); - await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); + await expect(page).toHaveURL(/\/\?mode=forms\b/, { timeout: 20_000 }); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); await expect(visibleByTestId(page, "shared-home-empty-state")).toBeVisible(); - await expect(page.getByTestId("services-home")).toHaveCount(0); + await expect(page.getByTestId("forms-home")).toHaveCount(0); await expect(visibleGlobalSearchInput(page)).toHaveCount(1); await expect(visibleGlobalSearchInput(page)).toHaveValue(""); await expectNoPageHorizontalOverflow(page);