diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a6d1fd98eb..707ed02bad 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -72,10 +72,10 @@ import { SystemNotice, DegradedNotice } from "@/components/clinical-dashboard/da import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content"; import { - AnswerEmptyState, AnswerProgressStepper, AnswerSkeleton, SearchProgressBanner, + SharedHomeEmptyState, } from "@/components/clinical-dashboard/answer-status"; import { type AnswerProgressUpdate, @@ -3383,6 +3383,7 @@ export function ClinicalDashboard({ composerFollowUpSuggestions={searchMode === "answer" ? answerFollowUpSuggestions : undefined} onPickComposerFollowUpSuggestion={handlePickFollowUpSuggestion} composerFollowUpSuggestionsDisabled={loading} + sharedHomeIdentity={showSharedHome} composerPlaceholder={searchMode === "answer" && latestAnswerQuery ? "Ask a follow-up..." : undefined} mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"} // Every phone dock is the compact single-row pill so content keeps @@ -3661,10 +3662,11 @@ export function ClinicalDashboard({ {showSharedHome ? ( // The one home surface, shared by all 13 modes. It sits above every - // mode-specific branch so picking a mode on `/` changes only the pill - // and the composer placeholder; the mode's own content stays behind + // mode-specific branch so picking a mode on `/` changes only its + // presentation and composer target; mode-owned content stays behind // its own route (/tools, /favourites, /dsm, …). - { diff --git a/src/components/answer-home-proposal-mockups.tsx b/src/components/answer-home-proposal-mockups.tsx index 3777932338..eda7ead82b 100644 --- a/src/components/answer-home-proposal-mockups.tsx +++ b/src/components/answer-home-proposal-mockups.tsx @@ -13,7 +13,7 @@ import { cn } from "@/components/ui-primitives"; // Pinned by tests/privacy-ui.test.ts and reproduced verbatim on both sides: // "Do not enter patient-identifiable information." and the "Privacy and data // processing" link to /privacy. The subtitle is ordinary product copy -// (src/lib/ui-copy.ts, answerEmptyState.subheading) and is not pinned. +// (src/lib/ui-copy.ts, sharedHomePresentation.answer.subtitle) and is not pinned. const OBLIGATION = "Do not enter patient-identifiable information."; const PRIVACY_LINK = "Privacy and data processing"; diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index d86ab1b616..dbb8c5b506 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -1,7 +1,7 @@ "use client"; import { useLayoutEffect, useRef, type CSSProperties } from "react"; -import { Check, Circle, Clipboard, ClipboardCheck, History, Loader2, MessageSquareText, Square } from "lucide-react"; +import { Check, Circle, Clipboard, ClipboardCheck, History, Loader2, Square } from "lucide-react"; import { answerProgressDisplayMessage, @@ -14,7 +14,9 @@ import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-su import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { cn, floatingControl, sourceCard } from "@/components/ui-primitives"; -import { answerEmptyState, answerLoading, copyButton } from "@/lib/ui-copy"; +import { appModeIcons } from "@/lib/app-mode-icons"; +import type { AppModeId } from "@/lib/app-modes"; +import { answerLoading, copyButton, sharedHomeEmptyState, sharedHomePresentation } from "@/lib/ui-copy"; export function CopyButton({ label, @@ -47,11 +49,13 @@ export function CopyButton({ ); } -export function AnswerEmptyState({ +export function SharedHomeEmptyState({ + modeId, desktopComposerSlotId, recentQueries = [], onSelectRecent, }: { + modeId: AppModeId; desktopComposerSlotId?: string; recentQueries?: string[]; onSelectRecent?: (query: string) => void; @@ -65,25 +69,27 @@ export function AnswerEmptyState({ onSelectRecent && preferences.showRecentOnHome ? recentQueries.filter((entry) => entry.trim().length > 0).slice(0, 5) : []; + const presentation = sharedHomePresentation[modeId]; return ( {recents.length > 0 && ( onSelectRecent?.(entry)} - label={answerEmptyState.recentLabel} + label={sharedHomeEmptyState.recentLabel} layout="wrap" className="justify-center" icon={History} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 69f223a36f..d76266e2d2 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -37,6 +37,7 @@ import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer"; import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; +import { BrandMark } from "@/components/clinical-dashboard/brand"; import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { SearchPinsMenu } from "@/components/clinical-dashboard/search-pins-menu"; @@ -189,6 +190,7 @@ export function MasterSearchHeader({ onPickComposerFollowUpSuggestion, composerFollowUpSuggestionsDisabled = false, headerVariant = "default", + sharedHomeIdentity = false, mobileSearchPlacement = "default", mobileBottomSearchVariant = "default", desktopSearchPlacement = "default", @@ -241,6 +243,8 @@ export function MasterSearchHeader({ onPickComposerFollowUpSuggestion?: (suggestion: string) => void; composerFollowUpSuggestionsDisabled?: boolean; headerVariant?: "default" | "workflow"; + /** Keep the product identity stable while `/` retargets between modes. */ + sharedHomeIdentity?: boolean; mobileSearchPlacement?: "default" | "bottom"; /** "compact" drops the phone footer chip row and hugs the bottom edge so * content keeps maximum screen space. Every phone dock uses it now; the @@ -486,6 +490,7 @@ export function MasterSearchHeader({ const modeMenuRef = useRef(null); const modeButtonRef = useRef(null); const modeOptionRefs = useRef>([]); + const pendingModeSelectionFocusRef = useRef(null); const prefetchedModeHrefsRef = useRef(new Set()); const scopePopoverRef = useRef(null); const actionMenuTriggerRef = useRef(null); @@ -501,6 +506,29 @@ export function MasterSearchHeader({ .filter((document): document is ClinicalDocument => Boolean(document)), [documentById, selectedDocumentIds], ); + + useEffect(() => { + const pendingMode = pendingModeSelectionFocusRef.current; + if (modeMenuOpen || pendingMode === null || pendingMode !== searchMode) return undefined; + + let settledFrame: number | null = null; + const frame = window.requestAnimationFrame(() => { + settledFrame = window.requestAnimationFrame(() => { + if (pendingModeSelectionFocusRef.current !== searchMode) return; + // Phone Sheet may still mount #app-mode-menu during exit animation. + // Leave pending armed so a later searchMode tick (or same-mode retry) + // can finish restore instead of giving up on a no-op. + if (document.getElementById("app-mode-menu")) return; + restoreFocusUnlessMoved(modeButtonRef.current); + pendingModeSelectionFocusRef.current = null; + }); + }); + + return () => { + window.cancelAnimationFrame(frame); + if (settledFrame !== null) window.cancelAnimationFrame(settledFrame); + }; + }, [modeMenuOpen, searchMode]); const scopeSummary = selectedDocumentIds.length === 0 ? "All documents" : `${selectedDocumentIds.length} scoped`; const scopePreview = useMemo( () => @@ -822,6 +850,33 @@ export function MasterSearchHeader({ function selectAppMode(mode: (typeof appModeDefinitions)[number]) { setModeMenuOpen(false); if (isSearchableAppMode(mode.id)) { + // Wait until the URL-owned mode prop settles before returning focus. The + // trigger's accessible name changes with that prop; focusing in the click + // frame races the shared-home URL sync and can fall through to . + // + // Same-mode reselect is different: shared-home replaceState keeps an + // identical URL, so searchMode never changes and the pending-focus effect + // gets only the menu-close tick — which can race phone Sheet teardown. + if (mode.id === searchMode) { + pendingModeSelectionFocusRef.current = mode.id; + onSearchModeChange(mode.id); + const restoreSameModeFocus = () => { + if (pendingModeSelectionFocusRef.current !== mode.id) return; + if (document.getElementById("app-mode-menu")) { + window.setTimeout(restoreSameModeFocus, 50); + return; + } + if (!restoreFocusUnlessMoved(modeButtonRef.current)) { + modeButtonRef.current?.focus({ preventScroll: true }); + } + pendingModeSelectionFocusRef.current = null; + }; + window.requestAnimationFrame(() => { + window.requestAnimationFrame(restoreSameModeFocus); + }); + return; + } + pendingModeSelectionFocusRef.current = mode.id; onSearchModeChange(mode.id); return; } @@ -993,10 +1048,7 @@ export function MasterSearchHeader({ onFocus={() => prefetchModeDestination(mode.id)} onPointerEnter={() => prefetchModeDestination(mode.id)} onKeyDown={(event) => handleModeOptionKeyDown(event, index)} - onClick={() => { - selectAppMode(mode); - window.requestAnimationFrame(() => modeButtonRef.current?.focus()); - }} + onClick={() => selectAppMode(mode)} className={cn( "relative grid w-full items-center gap-2 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", usesPhoneSearchLayout @@ -2063,7 +2115,19 @@ export function MasterSearchHeader({ >