Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import { cn } from "@/components/ui-primitives";
import { appModeDefinition, type AppModeId } from "@/lib/app-modes";
import { appModeIcons } from "@/lib/app-mode-icons";
import {
commandDropdownDisplayMediaQuery,
differentialRedFlagTerms,
filteredSuggestions,
isFormCodeQuery,
searchCommandSurfaceConfig,
type CommandSurfacePlacement,
} from "@/lib/search-command-surface";
import type { UniversalSearchDomain } from "@/lib/universal-search";
import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context";
Expand Down Expand Up @@ -146,8 +148,6 @@ function OptionShell({ active, children, hint }: { active: boolean; children: Re
);
}

export type CommandSurfacePlacement = "bottom-dock" | "inline";

function SmartRotatingHint({ examples, modeLabel }: { examples: string[]; modeLabel: string }) {
const [activeExampleIndex, setActiveExampleIndex] = useState(0);
const activeExample = examples[activeExampleIndex % examples.length];
Expand Down Expand Up @@ -220,7 +220,7 @@ function CommandDropdown({
// template, so without it the section headings inherit text-center.
"universal-command-dropdown absolute left-0 right-0 z-30 overflow-hidden rounded-2xl border border-[color:var(--border-strong)] bg-[color:var(--surface)] text-left shadow-[var(--shadow-elevated)]",
opensUpward ? "bottom-[calc(100%+0.5rem)] top-auto" : "top-[calc(100%+0.5rem)]",
"block max-sm:rounded-xl",
placement === "bottom-dock" ? "hidden sm:block" : "hidden lg:block",
)}
role="presentation"
>
Expand Down Expand Up @@ -380,11 +380,29 @@ export function UniversalSearchCommandSurface({
const [activeIndex, setActiveIndex] = useState(-1);
const trimmedQuery = query.trim();
const mode = appModeDefinition(modeId);
// The dropdown is a fine-pointer desktop enhancement. Width-only checks let
// wide, zoomed, or desktop-mode phones open it over the page.
const dropdownMediaQuery = commandDropdownDisplayMediaQuery(placement);
const [dropdownDisplayable, setDropdownDisplayable] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
contextMode: modeId,
});
const savedRegistryFavourites = useSavedRegistryFavourites();
Expand Down Expand Up @@ -850,6 +868,14 @@ export function UniversalSearchCommandSurface({
const activeItemId = activeIndex >= 0 && activeIndex < flatItems.length ? flatItems[activeIndex].id : null;

function handleComposerKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (!dropdownDisplayable) {
if (event.key === "Escape") {
onDropdownOpenChange(false);
setActiveIndex(-1);
}
onInputKeyDown?.(event);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
onDropdownOpenChange(true);
Expand Down Expand Up @@ -946,7 +972,7 @@ export function UniversalSearchCommandSurface({
}
}}
onFocusCapture={() => {
onDropdownOpenChange(true);
if (dropdownDisplayable) onDropdownOpenChange(true);
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
Expand All @@ -956,7 +982,7 @@ export function UniversalSearchCommandSurface({
}}
>
{children}
{dropdownOpen ? (
{dropdownOpen && dropdownDisplayable ? (
<CommandDropdown
modeId={modeId}
query={trimmedQuery}
Expand All @@ -978,7 +1004,7 @@ export function UniversalSearchCommandSurface({
examples={config.examples}
onPickExample={(example) => {
onQueryChange(example);
onDropdownOpenChange(true);
if (dropdownDisplayable) onDropdownOpenChange(true);
onFocusSearchInput?.();
}}
/>
Expand Down
13 changes: 13 additions & 0 deletions src/lib/search-command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ export type SearchCommandSurfaceConfig = {
crossModes: AppModeId[];
};

export type CommandSurfacePlacement = "bottom-dock" | "inline";

/**
* The command panel is a desktop enhancement. Width alone is not enough to
* identify that environment: phones can report a wide viewport in landscape,
* display-zoom, or desktop-site modes. Requiring a hover-capable fine pointer
* keeps the panel closed on touch-first phones while preserving it for desktop.
*/
export function commandDropdownDisplayMediaQuery(placement: CommandSurfacePlacement) {
const minimumWidth = placement === "bottom-dock" ? "640px" : "1024px";
return `(min-width: ${minimumWidth}) and (hover: hover) and (pointer: fine)`;
}

const searchCommandSurfaceByMode: Partial<Record<AppModeId, SearchCommandSurfaceConfig>> = {
documents: {
examples: ["clozapine ANC thresholds", "lithium monitoring table", "QT prolongation quote"],
Expand Down
10 changes: 10 additions & 0 deletions tests/search-command-surface.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
commandDropdownDisplayMediaQuery,
differentialRedFlagTerms,
favouriteMatchesCommandScopes,
filteredSuggestions,
Expand Down Expand Up @@ -28,6 +29,15 @@ function serviceRecord(overrides: Partial<ServiceRecord> = {}): ServiceRecord {
}

describe("search command surface", () => {
it("requires desktop pointer capabilities before displaying the command dropdown", () => {
expect(commandDropdownDisplayMediaQuery("bottom-dock")).toBe(
"(min-width: 640px) and (hover: hover) and (pointer: fine)",
);
expect(commandDropdownDisplayMediaQuery("inline")).toBe(
"(min-width: 1024px) and (hover: hover) and (pointer: fine)",
);
});

it("returns mode-specific command surface config", () => {
const documents = searchCommandSurfaceConfig("documents");
expect(documents?.examples.length).toBeGreaterThan(0);
Expand Down
19 changes: 8 additions & 11 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,23 +662,20 @@ test.describe("Clinical KB tools launcher", () => {
}
});

test("phone bottom-dock search opens the bounded command results sheet", async ({ page }) => {
test("phone bottom-dock search keeps the command results sheet 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();
const input = visibleGlobalSearchInput(page).first();
await expect(input).toBeVisible();

// Phone searches use the same accessible listbox as larger viewports, bounded
// above the bottom dock so results stay reachable without horizontal overflow.
// Phones keep the full search results in the page instead of opening a
// command sheet over the small viewport.
await input.click();
await input.press("ArrowDown");
await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1);
await expect(page.getByRole("listbox", { name: "Services search suggestions" })).toBeVisible();
await expectNoPageHorizontalOverflow(page);

await page.evaluate(() => window.dispatchEvent(new Event("scroll")));
await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0);
await expect(page.getByRole("listbox", { name: "Services search suggestions" })).toHaveCount(0);
await expectNoPageHorizontalOverflow(page);
});

test("phone mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => {
Expand All @@ -697,11 +694,11 @@ test.describe("Clinical KB tools launcher", () => {
expect(geometry.top).toBeGreaterThan(0);
expect(geometry.bottom).toBeLessThan(geometry.viewportHeight - 40);

// Hero composers expose the same bounded universal results sheet on phones.
// Phone hero composers must not cover the page with the universal sheet.
await heroInput.click();
await heroInput.press("ArrowDown");
await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1);
await expect(page.getByRole("listbox")).toBeVisible();
await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0);
await expect(page.getByRole("listbox")).toHaveCount(0);
await expectNoPageHorizontalOverflow(page);
}
});
Expand Down
27 changes: 19 additions & 8 deletions tests/ui-universal-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,25 @@ test.describe("universal search typeahead", () => {
await expect(page.getByText("Saved").first()).toBeVisible();
});

test("shows cross-mode typeahead on a phone viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockUniversalSearch(page);
const input = await openComposer(page);
await input.fill("acamprosate");

await expect(page.getByText(/Current mode · Documents · 1/)).toBeVisible();
await expect(page.getByText("Also in Medication · Medications · 1")).toBeVisible();
test("keeps cross-mode typeahead hidden on a landscape touch phone", async ({ browser, baseURL }) => {
const context = await browser.newContext({
...(baseURL ? { baseURL } : {}),
hasTouch: true,
viewport: { width: 844, height: 390 },
});
const page = await context.newPage();

try {
await mockUniversalSearch(page);
const input = await openComposer(page);
await input.fill("acamprosate");

await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0);
await expect(page.getByText(/Current mode · Documents · 1/)).toHaveCount(0);
await expect(page.getByText("Also in Medication · Medications · 1")).toHaveCount(0);
} finally {
await context.close();
}
});

test("keeps compact cross-mode matches visible after submission", async ({ page }) => {
Expand Down
Loading