Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ function routeWithQuery(path: string, query: string, selectedIds?: Set<string>)
}

/**
* Phone-only floating compare action. Portals into the bottom search dock's
* addon slot so it stays anchored just above the composer pill (and hides
* with it on scroll), but renders as a self-contained floating pill so it
* reads as a batch-selection action rather than composer chrome.
* Mobile/tablet floating compare action. Portals into the search composer's
* addon slot so it stays anchored beside the active result controls, but
* renders as a self-contained floating pill so it reads as a batch-selection
* action rather than composer chrome.
*/
function DifferentialsMobileCompareBar({
selectedCount,
Expand Down
15 changes: 11 additions & 4 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from "@/lib/app-modes";
import { isLocalNoAuthMode } from "@/lib/client-env";
import { documentsSearchHref } from "@/lib/document-flow-routes";
import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context";
import type { SearchScopeFilters } from "@/lib/search-scope";
import { useAuthSession } from "@/lib/supabase/client";
Expand Down Expand Up @@ -241,6 +241,8 @@ function GlobalStandaloneSearchShellClient({
const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search");
const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0;
const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView;
const differentialsCompareAddonActive =
pathname === "/differentials" && searchMode === "differentials" && hasSubmittedModeSearch;
// Registry and local decision-support modes own their submitted-search views on their
// standalone routes; the shell must not swap them to the dashboard. On the
// home route the dashboard always renders, so these exclusions only apply
Expand Down Expand Up @@ -283,9 +285,11 @@ function GlobalStandaloneSearchShellClient({
? "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))";
: differentialsCompareAddonActive
? "calc(8.75rem + 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
Expand Down Expand Up @@ -551,6 +555,9 @@ function GlobalStandaloneSearchShellClient({
// result views: compact the phone bottom composer so results keep
// maximum screen space. Mode homes keep the chip-row layout.
mobileBottomSearchVariant={useCompactBottomSearch ? "compact" : "default"}
mobileBottomSearchAddonSlotId={
differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined
}
Comment thread
BigSimmo marked this conversation as resolved.
desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"}
searchComposerVisible={shouldShowSearchComposer}
desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined}
Expand Down
14 changes: 10 additions & 4 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export function MasterSearchHeader({
* 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. */
/** Mobile/tablet slot rendered above the search pill for page-specific composer addons. */
mobileBottomSearchAddonSlotId?: string;
mobileLeadingAction?: "menu" | "back";
onMobileBack?: () => void;
Expand Down Expand Up @@ -321,7 +321,9 @@ export function MasterSearchHeader({
searchComposerVisible &&
!desktopHomeComposerSlotId &&
(isAnswerFooterComposer || mobileSearchPlacement === "bottom");
const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive);
const bottomComposerScrollHiddenActive = Boolean(
hideOnScroll && phoneBottomSearchDockActive && !mobileBottomSearchAddonSlotId,
);
const bottomComposerHidden =
bottomComposerScrollHiddenActive &&
scrollHidden &&
Expand Down Expand Up @@ -1188,7 +1190,11 @@ export function MasterSearchHeader({
const ModeIdentityIcon = appModeIcons[searchMode];
const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms";
const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;
const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock);
// A differential comparison is a persistent batch action: hiding its host
// dock on downward scroll makes the CTA slide under mobile browser chrome
// and disables pointer events just when users finish reviewing the list.
// Keep that dock pinned while the header can still collapse independently.
const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock && !mobileBottomSearchAddonSlotId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
// Phone submitted non-answer result docks reserve pill-only scroll
// clearance (ClinicalDashboard <main> margins / global-search-shell
// mobileComposerReserve), so an extra notice line would push the fixed
Expand Down Expand Up @@ -1237,7 +1243,7 @@ export function MasterSearchHeader({
)}
>
{usesBottomComposerPlacement ? <div className="answer-footer-search-backdrop" aria-hidden="true" /> : null}
{usesPhoneFooterDock && mobileBottomSearchAddonSlotId ? (
{usesMobileBottomStyle && mobileBottomSearchAddonSlotId ? (
<div
id={mobileBottomSearchAddonSlotId}
className="differentials-mobile-search-addon relative z-10 w-full empty:hidden"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mode-home-composer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const modeHomeDesktopComposerSlotId = "mode-home-desktop-composer-slot";

/** Phone-only slot in the bottom search dock for differentials compare actions. */
/** Mobile/tablet search-composer slot for differentials compare actions. */
export const differentialsMobileCompareAddonSlotId = "differentials-mobile-compare-addon-slot";
134 changes: 133 additions & 1 deletion tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from "playwright/test";
import type { Route } from "playwright-core";
import { acuteConfusionPresentationWorkflow } from "../src/lib/differentials";
import { acuteConfusionPresentationWorkflow, differentialRecords } from "../src/lib/differentials";
import { demoAnswer, demoDocuments } from "../src/lib/demo-data";
import { formRecords } from "../src/lib/forms";
import { loadMedicationSnapshot } from "../src/lib/medication-snapshot";
Expand Down Expand Up @@ -138,6 +138,41 @@ async function mockAnswerDashboardApi(page: Page) {
});
}

async function mockDifferentialCatalogApi(page: Page) {
await page.route(/\/api\/differentials(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url());
const query = url.searchParams.get("q")?.trim() ?? "";
const kind = url.searchParams.get("kind") ?? "diagnosis";

if (kind === "presentation") {
await route.fulfill({
json: {
matches: [
{
workflow: acuteConfusionPresentationWorkflow,
score: 1,
reasons: [`Matched ${query}`],
},
],
demoMode: true,
},
});
return;
}

await route.fulfill({
json: {
matches: differentialRecords.slice(0, 20).map((record, index) => ({
record,
score: 1 - index / 10,
reasons: [`Matched ${query}`],
})),
demoMode: true,
},
});
});
}

async function commandSurfaceOpensAbovePill(page: Page) {
const input = visibleGlobalSearchInput(page).first();
await expect(input).toBeVisible();
Expand Down Expand Up @@ -1332,6 +1367,103 @@ test.describe("Clinical KB tools launcher", () => {
await expectNoPageHorizontalOverflow(page);
});

test("mobile differential compare action stays tappable while results scroll", async ({ page }) => {
await mockAnswerDashboardApi(page);
await mockDifferentialCatalogApi(page);
await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
await route.fulfill({
json: {
results: [],
visualEvidence: [],
relatedDocuments: [],
documentMatches: [
{
document_id: "11111111-1111-4111-8111-111111111111",
title: "Acute confusion differential guide",
file_name: "acute-confusion-differentials.pdf",
labels: [],
summarySnippet: "Reviewed acute confusion differential guidance.",
bestPages: [1],
bestChunkIds: ["chunk-acute-confusion"],
imageCount: 0,
tableCount: 0,
matchReason: "Matched indexed passage",
score: 0.93,
},
],
relevance: { verdict: "strong", score: 0.93, directSourceCount: 1, weakSourceCount: 0 },
smartPanel: {},
telemetry: { query_class: "differential_compare", retrieval_strategy: "text_fast_path" },
scope: { queryMode: "compare_guidance" },
sourceGovernanceWarnings: [],
demoMode: true,
},
});
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoLauncher(page, "/differentials");

const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first();
const submit = page.locator('button[aria-label="Search differential presentations"]:visible');
await input.fill("acute confusion");
await expect(submit).toBeEnabled();
await submit.click();

const compareAction = page.getByTestId("differentials-compare-selected-mobile");
const dock = page.locator("form.answer-footer-search-dock");
const scrollport = page.getByTestId("differentials-search-results");
const mainContent = page.locator("#main-content");
await expect(scrollport).toBeVisible();
await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1);
await expect(compareAction).toBeVisible();
await expect(compareAction).toContainText("Compare selected");

await scrollport.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" }));
await expect.poll(() => scrollport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
await page.waitForTimeout(300);

await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
const mainPaddingBottom = await mainContent.evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).paddingBottom),
);
const dockHeight = await dock.evaluate((element) => element.getBoundingClientRect().height);
expect(mainPaddingBottom).toBeGreaterThanOrEqual(dockHeight);

await mainContent.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" }));
const lastResultBottom = await page
.getByTestId("differential-status-badge")
.last()
.evaluate(
(element) =>
element.closest("article")?.getBoundingClientRect().bottom ?? element.getBoundingClientRect().bottom,
);
const dockTop = await dock.evaluate((element) => element.getBoundingClientRect().top);
expect(lastResultBottom).toBeLessThanOrEqual(dockTop);

const compareGeometry = await compareAction.evaluate((element) => {
const rect = element.getBoundingClientRect();
const centreX = rect.left + rect.width / 2;
const centreY = rect.top + rect.height / 2;
const hit = document.elementFromPoint(centreX, centreY);
return {
top: rect.top,
bottom: rect.bottom,
viewportHeight: window.innerHeight,
receivesPointer: hit === element || element.contains(hit),
};
});
expect(compareGeometry.top).toBeGreaterThanOrEqual(0);
expect(compareGeometry.bottom).toBeLessThanOrEqual(compareGeometry.viewportHeight);
expect(compareGeometry.receivesPointer).toBe(true);

// The result cards and compare bar remain in their non-desktop layout up
// to 1023px, so the composer must keep providing the portal host on tablet.
await page.setViewportSize({ width: 768, height: 1024 });
await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1);
await expect(compareAction).toBeVisible();
await expect(compareAction).toContainText("Compare selected");
});

test("diagnosis detail actions stay tappable and tabs stay single-line", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 800 });
await gotoLauncher(page, "/differentials/diagnoses/delirium");
Expand Down