diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index cd4b75f63..53fad287e 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -43,3 +43,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | | 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | | 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 79748509e..e106c7886 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3253,7 +3253,6 @@ export function ClinicalDashboard({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopHomeComposerSlotId={desktopHomeComposerSlotId} - heroComposerFromTablet={Boolean(desktopHomeComposerSlotId)} // Phone-only: the header sits above the internally scrolling
, // so hiding must collapse its layout space to hand it to content. hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} @@ -3287,7 +3286,9 @@ export function ClinicalDashboard({ ? differentialsCompareAddonActive ? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0" : "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0" - : compactMobileModeHome + : // Mode homes keep the composer in the hero (in-flow at every + // width), so phones need no bottom-dock clearance on them. + compactMobileModeHome || showDesktopHomeComposer ? "mb-0" : "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0" : "mb-0", @@ -3310,7 +3311,7 @@ export function ClinicalDashboard({ : hasMobileBottomSearch ? compactMobileModeHome ? "pb-4 sm:pb-10 lg:pb-12" - : compactMobileBottomSearch + : compactMobileBottomSearch || showDesktopHomeComposer ? "pb-8 sm:pb-10 lg:pb-12" : "pb-32 sm:pb-10 lg:pb-12" : "pb-8 sm:pb-10 lg:pb-12", diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index d5b3f194b..58e2fa7cb 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -733,6 +733,9 @@ export function ApplicationsLauncherWorkspace({ aria-labelledby="tools-home-title" className={cn( "mx-auto w-full max-w-[90rem] overflow-x-hidden px-4 pb-8 text-[color:var(--text)] sm:px-6 lg:px-8", + // The composer sits in the hero at every width, so phones only need a + // small safe-area cushion rather than bottom-dock clearance. + "pb-[calc(2rem+env(safe-area-inset-bottom))] sm:pb-8", "pt-7 sm:pt-10 lg:pt-14", className, )} diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index dc8ee0893..cd4148462 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -255,18 +255,18 @@ function GlobalMockupStandaloneSearchShellClient({ const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; const effectiveSidebarWidth = shouldShowDesktopSidebar ? (effectiveSidebarCollapsed ? "5.25rem" : "20rem") : "0px"; const shouldShowSearchComposer = searchComposerVisible && !isDifferentialPresentationWorkflow; - const usesInlineModeHomeComposer = shouldShowSearchComposer && isStandaloneModeHome; - const reservesFloatingComposer = shouldShowSearchComposer && !usesInlineModeHomeComposer; - // Standalone mode homes use an inline hero composer from `sm` upward, but - // phones still render the fixed bottom dock. Reserve its full height on the - // phone layout even though the wider layouts do not need floating clearance. + const reservesFloatingComposer = shouldShowSearchComposer && !isStandaloneModeHome; + // Standalone mode homes portal the composer into the hero (in-flow at every + // width), so phones need no bottom-dock clearance there. const mobileComposerReserve = !shouldShowSearchComposer ? "2rem" - : searchMode === "answer" - ? "calc(9rem + env(safe-area-inset-bottom))" - : useCompactBottomSearch - ? "calc(5.5rem + env(safe-area-inset-bottom))" - : "calc(9rem + env(safe-area-inset-bottom))"; + : isStandaloneModeHome + ? "2rem" + : searchMode === "answer" + ? "calc(9rem + env(safe-area-inset-bottom))" + : useCompactBottomSearch + ? "calc(5.5rem + env(safe-area-inset-bottom))" + : "calc(9rem + env(safe-area-inset-bottom))"; useEffect(() => { // Re-derive the mode and query from the URL, but only when the search string @@ -528,7 +528,6 @@ function GlobalMockupStandaloneSearchShellClient({ desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} - heroComposerFromTablet={isStandaloneModeHome} // Phone-only: #main-content owns vertical scroll, so hide-on-scroll // collapses the header/composer to hand space back to content. hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} @@ -556,7 +555,7 @@ function GlobalMockupStandaloneSearchShellClient({ ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))]" : useCompactBottomSearch ? "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8" - : "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-8", + : "max-sm:pb-[var(--mobile-composer-reserve)] sm:pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} >
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index ac5798971..98e08c2b6 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -9,7 +9,7 @@ import { useState, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, - type Ref, + type RefObject, } from "react"; import { createPortal } from "react-dom"; @@ -71,10 +71,6 @@ import { tagSearchText } from "@/lib/document-tags"; const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; const desktopHomeComposerMediaQuery = "(min-width: 1024px)"; -// Mode-home shells centre the composer in the hero at every width (see -// heroComposerFromTablet): phones share the hero-centred landing design that -// tests/ui-tools.spec.ts "mode home routes center the shared search on mobile" -// encodes, so the query intentionally always matches. const modeHomeComposerMediaQuery = "(min-width: 0px)"; const defaultVisibleAppModeOptions = visibleAppModeDefinitions(); @@ -179,7 +175,6 @@ export function MasterSearchHeader({ searchComposerVisible = true, desktopHomeComposerSlotId, mobileBottomSearchAddonSlotId, - heroComposerFromTablet = false, mobileLeadingAction = "menu", onMobileBack, hideOnScroll, @@ -211,7 +206,7 @@ export function MasterSearchHeader({ onNewChat?: () => void; onOpenMobileSidebar?: () => void; queryModeOptions: Array<{ value: ClinicalQueryMode; label: string }>; - queryInputRef?: Ref; + queryInputRef?: RefObject; queryInputAutoFocus?: boolean; /** Overrides the mode's default input placeholder (e.g. "Ask a follow-up..." mid-thread). */ composerPlaceholder?: string; @@ -231,12 +226,12 @@ export function MasterSearchHeader({ mobileBottomSearchVariant?: "default" | "compact"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; + /** Mode-home slot the composer portals into at every viewport width, so the + * search pill sits in the middle of the hero on phones as well as desktop + * instead of docking to the bottom edge. */ desktopHomeComposerSlotId?: string; /** Phone-only slot rendered above the bottom search pill for page-specific dock addons. */ mobileBottomSearchAddonSlotId?: string; - /** Portal the composer into the hero slot from the tablet breakpoint (sm) up, - * rather than the default desktop (lg) breakpoint. */ - heroComposerFromTablet?: boolean; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; /** Phone-only hide-on-scroll for the universal header and bottom search dock. @@ -305,8 +300,13 @@ export function MasterSearchHeader({ const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; + // Mode homes portal the composer into the hero slot at every width, so the + // phone bottom dock only exists when no hero slot is provided. const phoneBottomSearchDockActive = - usesPhoneSearchLayout && searchComposerVisible && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); + usesPhoneSearchLayout && + searchComposerVisible && + !desktopHomeComposerSlotId && + (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); const bottomComposerHidden = bottomComposerScrollHiddenActive && @@ -614,6 +614,22 @@ export function MasterSearchHeader({ visibleAppModeOptions.findIndex((mode) => mode.id === selectedAppMode.id), ); + // Both the hero-portal composer and the default composer bind the caller's + // queryInputRef. During home <-> result transitions the two briefly coexist, + // and React nulls a plain shared ref when the outgoing composer unmounts — + // clobbering the surviving input's binding (quote follow-up focus broke). + // A cleanup-function ref only clears the binding it still owns. + const bindQueryInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (!element || !queryInputRef) return undefined; + queryInputRef.current = element; + return () => { + if (queryInputRef.current === element) queryInputRef.current = null; + }; + }, + [queryInputRef], + ); + function focusModeOption(index: number) { const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; modeOptionRefs.current[nextIndex]?.focus(); @@ -711,13 +727,17 @@ export function MasterSearchHeader({ // into it made React reconcile the portal against a container that another // part of the tree had already removed, throwing a null-parentNode error. // Because the host is stable, React's portal container never disappears. + // The slot is used at every viewport width — phones included — so mode + // homes keep the composer in the middle of the hero instead of docking it + // to the bottom edge. const host = document.createElement("div"); // Layout-transparent so the composer lays out as a direct child of the slot. host.style.display = "contents"; const mediaQuery = window.matchMedia( - heroComposerFromTablet ? modeHomeComposerMediaQuery : desktopHomeComposerMediaQuery, + desktopHomeComposerSlotId ? modeHomeComposerMediaQuery : desktopHomeComposerMediaQuery, ); + let frame: number | null = null; let retryTimeout: number | null = null; let portalRetryCount = 0; @@ -767,7 +787,7 @@ export function MasterSearchHeader({ setDesktopHomeComposerFallback(false); setDesktopHomeComposerHost(null); }; - }, [desktopHomeComposerSlotId, heroComposerFromTablet]); + }, [desktopHomeComposerSlotId]); const dismissModeMenu = useCallback(() => setModeMenuOpen(false), []); function dismissScope(reason: "outside" | "escape") { @@ -1098,7 +1118,11 @@ export function MasterSearchHeader({ onSubmit={submit} data-footer-variant={usesPhoneFooterDock ? (usesCompactMobileBottomStyle ? "compact" : "default") : undefined} data-footer-addon={usesPhoneFooterDock && mobileBottomSearchAddonSlotId ? "differentials-compare" : undefined} - data-command-open={usesBottomComposerPlacement && commandDropdownOpen ? "true" : undefined} + data-command-open={ + // Phones never show the command dropdown, so the dock scrim must not + // grow for it — gate the open attribute to widths that can display it. + usesBottomComposerPlacement && !usesPhoneSearchLayout && commandDropdownOpen ? "true" : undefined + } data-scroll-hidden={shouldHideBottomOnScroll && bottomComposerHidden ? "true" : undefined} {...(shouldHideBottomOnScroll ? composerFocusProps : undefined)} className={cn( @@ -1176,11 +1200,7 @@ export function MasterSearchHeader({ onRunModeAction={runModeAction} onCommandScopesChange={(scopes) => onCommandScopesChange?.(scopes)} onListboxIdReady={setCommandListboxId} - onFocusSearchInput={() => { - if (queryInputRef && "current" in queryInputRef) { - queryInputRef.current?.focus(); - } - }} + onFocusSearchInput={() => queryInputRef?.current?.focus()} >
@@ -312,11 +314,33 @@ export function UniversalSearchCommandSurface({ const [activeIndex, setActiveIndex] = useState(-1); const trimmedQuery = query.trim(); const mode = appModeDefinition(modeId); + // The dropdown is CSS-hidden below sm (bottom-dock) / lg (inline), so skip the + // typeahead fetches at widths where nothing could display the results. + const dropdownMediaQuery = placement === "bottom-dock" ? "(min-width: 640px)" : "(min-width: 1024px)"; + // Initialise from the real viewport. The mode-home composer can be moved into + // its portal while the input is receiving focus; starting every fresh mount + // at false loses that focus event and leaves the desktop popup closed. + const [dropdownDisplayable, setDropdownDisplayable] = useState( + () => typeof window !== "undefined" && window.matchMedia(dropdownMediaQuery).matches, + ); + useEffect(() => { + const mediaQuery = window.matchMedia(dropdownMediaQuery); + const sync = () => { + setDropdownDisplayable(mediaQuery.matches); + if (!mediaQuery.matches) { + onDropdownOpenChange(false); + setActiveIndex(-1); + } + }; + sync(); + mediaQuery.addEventListener("change", sync); + return () => mediaQuery.removeEventListener("change", sync); + }, [dropdownMediaQuery, onDropdownOpenChange]); // A true "everything" view: the active mode's own domain is included (no excludeDomain) so // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ query: trimmedQuery, - enabled: dropdownOpen && Boolean(config), + enabled: dropdownOpen && dropdownDisplayable && Boolean(config), }); const showSafetyBanner = @@ -698,6 +722,14 @@ export function UniversalSearchCommandSurface({ const activeItemId = activeIndex >= 0 && activeIndex < flatItems.length ? flatItems[activeIndex].id : null; function handleComposerKeyDown(event: ReactKeyboardEvent) { + if (!dropdownDisplayable) { + if (event.key === "Escape") { + onDropdownOpenChange(false); + setActiveIndex(-1); + } + onInputKeyDown?.(event); + return; + } if (event.key === "ArrowDown") { event.preventDefault(); onDropdownOpenChange(true); @@ -776,7 +808,9 @@ export function UniversalSearchCommandSurface({ handleComposerKeyDown(event as unknown as ReactKeyboardEvent); } }} - onFocusCapture={() => onDropdownOpenChange(true)} + onFocusCapture={() => { + if (dropdownDisplayable) onDropdownOpenChange(true); + }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { onDropdownOpenChange(false); @@ -785,7 +819,7 @@ export function UniversalSearchCommandSurface({ }} > {children} - {dropdownOpen ? ( + {dropdownOpen && dropdownDisplayable ? ( { onQueryChange(example); - onDropdownOpenChange(true); + if (dropdownDisplayable) onDropdownOpenChange(true); onFocusSearchInput?.(); }} /> diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index ca799adac..93fb4b121 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -210,7 +210,7 @@ async function blockExternalRequests(page: Page) { const url = new URL(route.request().url()); if ( (url.protocol === "http:" || url.protocol === "https:") && - !["localhost", "127.0.0.1", "::1"].includes(url.hostname) + !["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname) ) { await route.abort("blockedbyclient"); return; @@ -1906,9 +1906,10 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect.poll(() => requestCount).toBeGreaterThan(baselineRequestCount); const sourceStatus = page.getByRole("heading", { name: "Source status" }).locator(".."); - await expect(sourceStatus).toContainText("Not yet checked"); + const singularSourceCount = sourceStatus.getByText("1 source", { exact: true }); + await expect(singularSourceCount).toBeVisible(); await page.waitForTimeout(600); - await expect(sourceStatus).toContainText("Not yet checked"); + await expect(singularSourceCount).toBeVisible(); await expect(sourceStatus).not.toContainText("2 sources"); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 526e3c4bc..4740c1cf9 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -590,16 +590,47 @@ test.describe("Clinical KB tools launcher", () => { } }); - test("phone bottom-dock search opens the command surface above the pill", async ({ page }) => { + test("phone bottom-dock search keeps the command popup hidden", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); - await expect(visibleGlobalSearchInput(page).first()).toBeVisible(); + const input = visibleGlobalSearchInput(page).first(); + await expect(input).toBeVisible(); await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); - await commandSurfaceOpensAbovePill(page); + + // Phones never show the universal command popup — it crowds the small + // screen — so focusing/typing must not surface the dropdown or its scrim. + await input.click(); + await input.press("ArrowDown"); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.locator("form.answer-footer-search-dock")).not.toHaveAttribute("data-command-open", "true"); await expectNoPageHorizontalOverflow(page); }); + test("phone mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + for (const home of ["/services", "/forms", "/differentials", "/applications"]) { + await gotoLauncher(page, home); + const heroInput = page.locator(".mode-home-composer-slot").getByTestId("global-search-input"); + await expect(heroInput).toBeVisible({ timeout: 15_000 }); + await expect(page.locator("form.answer-footer-search-dock")).toHaveCount(0); + + // The pill sits in the flow of the hero, not fixed to the viewport bottom. + const geometry = await heroInput.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, viewportHeight: window.innerHeight }; + }); + expect(geometry.top).toBeGreaterThan(0); + expect(geometry.bottom).toBeLessThan(geometry.viewportHeight - 40); + + // Focusing the hero composer must not surface the universal popup on phones. + await heroInput.click(); + await heroInput.press("ArrowDown"); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expectNoPageHorizontalOverflow(page); + } + }); + test("desktop answer footer opens the command surface above the pill", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockAnswerDashboardApi(page);