From df8c3fb778f50dea45430bf9d36611b391ec6fd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 17:17:29 +0000 Subject: [PATCH 1/6] refactor(search): delete the inert command-scope system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the app could ever set a command scope. `universal-search-command-surface.tsx` received `commandScopes` and `onCommandScopesChange` and discarded both with `void`, and every other write in the repo passed an empty array, so the value was permanently `[]` in production. Confirmed on `origin/main` before this branch existed. Everything built on it was therefore unreachable, and this deletes all of it: - the two voided props, and the scope state, callbacks and context fields in `ClinicalDashboard`, `global-search-shell` and `master-search-header` - the `scopes` config arrays for six modes and the `CommandScopeChip` type - `recordMatchesCommandScopes`, `favouriteMatchesCommandScopes` and `medicationMatchesCommandScopes`, plus their scope-matching helpers - the four call sites that filtered against them — services, forms, prescribing, favourites - the results band's applied-filter shelf, its funnel-icon branch, and the empty state's "Clear scope filters" button Behaviour-preserving by construction rather than by argument: each matcher early-returned `true` on an empty array, so every call site was already a no-op, and the shelf's render condition could never be true. `SearchCommandContext` survives with `query` and `modeId` — `applications-launcher-page.tsx` still consumes it. Only the scope fields go. The tests that covered this exercised it from the wrong side: the band DOM test constructed the context by hand and the surface unit test called the matchers directly, so neither ever touched the production path. That is why the dead wiring survived, and both are removed with the code they described. Ledger `#182` closed with this outcome. Verified: `npm run verify:cheap` exit 0, 460 files / 4792 tests passed; `ui-smoke.spec.ts` 93 passed / 1 failed, the failure being the pre-existing document-viewer PDF canvas test that fails identically without this change (Chromium 1194 here against the pinned 1228). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/outstanding-issues.md | 2 +- src/components/ClinicalDashboard.tsx | 15 +- .../favourites-command-library-page.tsx | 25 +-- .../global-search-shell.tsx | 16 +- .../master-search-header.tsx | 6 - .../medication-prescribing-workspace.tsx | 10 +- .../search-command-context.tsx | 3 - .../search-results-header-band.tsx | 97 +----------- .../universal-search-command-surface.tsx | 6 - .../forms/forms-search-results-page.tsx | 13 +- .../services/services-navigator-page.tsx | 17 +- src/lib/search-command-surface.ts | 145 ------------------ tests/search-command-surface.test.ts | 90 ----------- tests/search-results-header-band.dom.test.tsx | 33 +--- 14 files changed, 24 insertions(+), 454 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 43f88ddaa8..1abfb831b2 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -160,7 +160,6 @@ removed after current-main verification; it is not missing recommended work. | #179 | P3 | issue | The full therapy catalogue silently switched from minified to pretty-printed | **Outcome:** the full catalogue's on-disk format is a decision someone made, not a side effect. **Detail:** `public/therapy-compass-data/therapies.json` was a single minified line (2,529,429 bytes) until PR #1532 routed it through `syncTarget` to scrub the tag-echo `modality`. `syncTarget` serialises with `JSON.stringify(records, null, 2)`, so the file is now 18,400 pretty-printed lines at 2,652,783 bytes. **Measured cost:** +123,354 bytes raw (+4.9%), but only +3,409 bytes gzipped (333,428 -> 336,837, +1.0%) — gzip absorbs almost all the added whitespace, and the file is not JavaScript so `check:bundle-budget` never sees it. The visible cost is git churn: each future data revision now produces ~18,400 changed lines in this file plus another ~18,400 in its content-addressed twin, which is why PR #1532 read as 42,071 insertions when only 381 of them were hand-written. **The benefit is real and probably wins:** a 205-record clinical dataset with a one-line diff is unreviewable, and a reviewer needs to see which clinical field changed. The two index projections were already pretty-printed, so this is now consistent. **Next:** confirm the format deliberately, then either leave it (recommended — 1% wire cost buys reviewable clinical diffs) or pass a minified serialiser for the full payload only, which would land below main's original size now that `modality` is gone. Either way record the reason next to `syncTarget`. **Stop:** do not minify the index or home projections to "match" — they are small and their diffs are the review surface for catalogue changes. | PR #1532; session 2026-07-31 | 2026-07-31 | | #180 | P3 | issue | build-therapies-index now overwrites its own source input | **Outcome:** the therapy catalogue generator has a source it does not also destroy. **Detail:** `scripts/build-therapies-index.mjs` reads `public/therapy-compass-data/therapies.json` as `source`, and since PR #1532 also writes the curated full payload back to that same path (`syncTarget(legacyFullTarget, curatedFull)`). Input and output are now the same file, so the generator is no longer idempotent with respect to its own input in the strict sense: it is stable in practice only because `curatedModality` is a fixed point (a null modality stays null, a genuinely curated non-tag value survives a second pass). The original tag-echo values are now recoverable only from git history. **Why it matters beyond tidiness:** `#175` asks someone to curate real modality values in this file. They will be editing the generator's output, and any field the generator ever decides to rewrite will silently discard their edit on the next run — with no gate to catch it, because `--check` compares the file against what the generator would produce, so a clobbered hand edit reads as "current". **Next:** separate the two roles — keep an unprocessed source (`therapies.source.json`, or move curation upstream of `public/`) and let `therapies.json` be purely generated, or at minimum add a header comment to the file stating it is generated and hand edits to processed fields will not survive. **Stop:** do not fix this by reverting the full-catalogue scrub; detail and recommend load `catalogue: "full"`, so the scrub is what keeps the ECT/rTMS mislabel off the only screens that render the chip. | PR #1532; session 2026-07-31 | 2026-07-31 | | #181 | P3 | issue | Correction to `#171`: source-type does NOT duplicate the `Document type` facet group | **CORRECTS a claim already merged to `main` in `#171`.** `#171` states that the documents source-type control "duplicates the facet group already named `Document type`". That is wrong, and it was asserted from a shared word rather than from the code. **What is actually true:** `resultTypeTabs`/`filterMatchesByResultType` (`document-search-results.tsx:253-273`) filter on artefact properties of the file — `match.tableCount > 0`, `match.imageCount > 0`, `match.file_name.endsWith(".pdf")`. The `Document type` **facet** group comes from `label_type: "document_type"` labels, meaning the *kind* of document: policy, guideline, form. A guideline containing a table is both; neither implies the other. They are complementary axes, not duplicates. **The real duplication** is the scope chip `{ id: "tables", label: "Tables" }` in `search-command-surface.ts` against the source-type `Tables` tab — same meaning, two controls. **Consequence for `#171`:** the merge it recommends is still worth doing (four entry points for narrowing one list is too many), but the shape changes — source-type becomes its own group in the panel (Format) rather than being absorbed into `Document type`, and the scope chips are the part that genuinely folds away. `#171`'s other claim, that `Sources` is navigation rather than a filter, is unaffected and stands. **Stop:** do not implement the absorption `#171` describes. **Before merging any of it,** verify the scope-chip semantics per mode — only services and prescribing were traced, and this row exists because an untraced inference reached `main` as fact. | `document-search-results.tsx:253-273`; `document-tags.ts` groupLabels; session 2026-07-31 | 2026-07-31 | -| #182 | P2 | issue | The command-scope system is inert — chips are configured and matchers exist, but nothing can ever set a scope | **Outcome:** either scopes can be applied by a user, or the dead config, matchers, props and UI that serve them are removed — not the current middle state where five surfaces look wired and none are. **Detail:** `src/components/clinical-dashboard/universal-search-command-surface.tsx:392-393` does `void commandScopes; void onCommandScopesChange;` — the surface receives both the value and the setter and discards them. Every other write in the repo passes an empty array (`ClinicalDashboard.tsx:2367,2671`; `global-search-shell.tsx:630,660`), so `commandScopes` is permanently `[]` in production. Consequences, all currently invisible: `recordMatchesCommandScopes`, `favouriteMatchesCommandScopes` and `medicationMatchesCommandScopes` (`src/lib/search-command-surface.ts:253,269,293`) each early-return `true` on an empty array, so the four pages that call them — services, prescribing, favourites, forms — filter nothing; the `scopes:` arrays configured for six modes in `search-command-surface.ts` (documents, services, prescribing, forms, favourites, differentials) are unreachable config; and the scope-chip shelf added to `search-results-header-band.tsx:171,676` in PR #1523 can never render, since `command?.commandScopes` is always empty. `tests/search-results-header-band.dom.test.tsx` passes because it constructs the command context by hand, and `tests/search-command-surface.test.ts` unit-tests the matchers directly — neither test reaches the production path, which is why this survived. Not a wiring-conventions violation: no control is clickable, so nothing advertises an action it fails to perform. It is cruft that reads as a feature, and it cost this session a full trace to establish that the documents mode was not uniquely broken. Predates the current branch — confirmed present on `origin/main`. **Next:** decide before building any further filtering UI on top of it. Cheapest honest option is deletion: drop the two voided props, the `scopes` config arrays, the three matcher functions and their call sites, and the band's scope shelf, then let the smart-tag facet panel be the single filtering surface for documents. The alternative — wiring chips into the command dropdown — is a real feature and needs its own design, not a quiet revival. **Stop:** do not add a second filtering surface that reads `commandScopes` while it is inert, and do not treat the DOM/unit tests above as evidence that scopes work end to end. | session 2026-07-31 Batch B trace; universal-search-command-surface.tsx:392-393; search-command-surface.ts:253-296; PR #1523 | 2026-07-31 | | #183 | P2 | task | Create Sentry metric alert for production DB span p95 > 500ms | Still blocked 2026-08-01 closeout: SUPABASE_ACCESS_TOKEN and SENTRY_AUTH_TOKEN missing from session env; Sentry MCP OAuth can list/get alerts but has no create tool; browser hits login wall; no metric rules exist yet on clinibase-xz. Create Metric Alert: p95(span.duration), filter span.op:db, environment production, threshold >500ms, notify Active Members. Provide SENTRY_AUTH_TOKEN in session to finish via sentry alert metrics create. | session 2026-07-31 db-query-perf follow-up | 2026-07-31 | @@ -297,4 +296,5 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #028 | rec | Runtime error tracking (Sentry or similar) | Enabled production Sentry server error + DB tracing on Railway Database/production (SENTRY_DSN, SENTRY_ENVIRONMENT=production, SENTRY_TRACES_SAMPLE_RATE=0.1); staging app enabled with environment=staging; browser DSN/source-map tokens intentionally unset; verified production span.op:db in Sentry. | 2026-07-31 | | #185 | task | Confirm hosted migration history row for 20260731150000 db-query-perf | Confirmed on live Clinical KB Database (sjrfecxgysukkwxsowpy): supabase_migrations.schema_migrations has version 20260731150000 name db_query_perf_rate_limit_and_image_indexes; document_images_searchable_doc_page_relevance_idx present. No re-apply needed. | 2026-07-31 | | #184 | task | Regenerate supabase/drift-manifest.json after db-query-perf migration | Regenerated supabase/drift-manifest.json via Docker npm run drift:manifest on host; includes document_images_searchable_doc_page_relevance_idx and updated consume_api_*_rate_limit inventory; schema_sha256 matches schema.sql. | 2026-07-31 | +| #182 | issue | The command-scope system is inert — chips are configured and matchers exist, but nothing can ever set a scope | Deleted. Took the option the row recommended: dropped the two voided props from universal-search-command-surface, the scope state and callbacks from both shells and the master header, the scopes config arrays and CommandScopeChip type, the three matcher functions, the four no-op consumer filters (services, forms, prescribing, favourites), and the band's scope shelf plus its empty-state Clear-scopes button. Net 453 deletions / 23 insertions across 13 files. Behaviour-preserving by construction: every matcher early-returned true on the permanently-empty array, so each call site was already a no-op. SearchCommandContext survives with query/modeId — applications-launcher-page still consumes it. Verified verify:cheap exit 0 (460 files / 4792 tests) and ui-smoke 93 passed, the one failure being the pre-existing PDF-canvas test that fails identically without this change. | 2026-07-31 | diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 31cdaae73b..8cafb75c60 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -557,21 +557,12 @@ export function ClinicalDashboard({ const [documentDrawerStatusFilter, setDocumentDrawerStatusFilter] = useState("indexed"); const [indexingMonitorFilter, setIndexingMonitorFilter] = useState("all"); const [recentQueries, setRecentQueries] = useState([]); - const [commandScopes, setCommandScopes] = useState([]); - const removeCommandScope = useCallback( - (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - [], - ); - const clearCommandScopes = useCallback(() => setCommandScopes([]), []); const searchCommandContextValue = useMemo( () => ({ query, modeId: searchMode, - commandScopes, - onRemoveScope: removeCommandScope, - onClearScopes: clearCommandScopes, }), - [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], + [query, searchMode], ); const [indexingActionId, setIndexingActionId] = useState(null); const [indexingActive, setIndexingActive] = useState(false); @@ -2364,7 +2355,6 @@ export function ClinicalDashboard({ } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); - setCommandScopes([]); setQuery(crossQuery); setModeSearchSubmitted(false); setLoading(false); @@ -2668,7 +2658,6 @@ export function ClinicalDashboard({ modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); - setCommandScopes([]); if (mode === "answer") { resetAnswerThread(); setAnswer(null); @@ -3356,8 +3345,6 @@ export function ClinicalDashboard({ queryInputRef={composerInputRef} queryInputAutoFocus={shouldAutoFocusComposer} recentQueries={recentQueries} - commandScopes={commandScopes} - onCommandScopesChange={setCommandScopes} onPickRecent={(recent) => { pickRecentQuery(recent); void ask(); diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index ca1dfd77c2..72df82b9e5 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -43,8 +43,6 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; -import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; -import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; @@ -606,7 +604,6 @@ function FavouritesTable({ viewMode, sortMode, selectedItemId, - commandScopes = [], onSortModeChange, onSelectItem, }: { @@ -617,7 +614,6 @@ function FavouritesTable({ viewMode: ViewMode; sortMode: SortMode; selectedItemId: string | null; - commandScopes?: string[]; onSortModeChange: (value: SortMode) => void; onSelectItem: (id: string) => void; }) { @@ -629,9 +625,8 @@ function FavouritesTable({ viewMode, sortMode, }); - if (!commandScopes.length) return rows; - return rows.filter((item) => favouriteMatchesCommandScopes(item, commandScopes)); - }, [commandScopes, items, searchTerm, selectedSet, selectedTypeId, viewMode, sortMode]); + return rows; + }, [items, searchTerm, selectedSet, selectedTypeId, viewMode, sortMode]); // With the item workspace open (only at 2xl), the middle column narrows sharply. // Drop the leading icon and the secondary Evidence column there so titles keep @@ -1022,7 +1017,6 @@ function ItemWorkspace({ item, onClose }: { item: FavouriteItem; onClose: () => export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: string; demoMode: boolean }) { const router = useRouter(); - const command = useSearchCommand(); const auth = useAuthSession(); const favouritesAccessible = canAccessFavouritesMode({ authenticated: auth.status === "authenticated", @@ -1072,15 +1066,9 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: }), [items, query, selectedTypeId, selectedSet, viewMode, sortMode], ); - const scopedItems = useMemo(() => { - const scopes = command?.commandScopes ?? []; - if (!scopes.length) return filteredItems; - return filteredItems.filter((item) => favouriteMatchesCommandScopes(item, scopes)); - }, [command?.commandScopes, filteredItems]); - const continueItem = useMemo(() => getMostRecentlyUsedItem(items), [items]); const showContinueStrip = - continueItem !== null && scopedItems.some((item) => item.id === continueItem.id) && scopedItems.length > 0; + continueItem !== null && filteredItems.some((item) => item.id === continueItem.id) && filteredItems.length > 0; const selectedItem = selectedItemId ? (items.find((item) => item.id === selectedItemId) ?? null) : null; @@ -1208,7 +1196,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: + {query.trim() && filteredItems.length === 0 && favouritesRegistryStatus === "ready" ? ( + ) : ( diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 5600e6136e..4c03417f34 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -6,7 +6,6 @@ import { type CSSProperties, type ReactNode, type UIEvent, - useCallback, useEffect, useLayoutEffect, useMemo, @@ -399,21 +398,12 @@ function GlobalStandaloneSearchShellBody({ const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); - const [commandScopes, setCommandScopes] = useState([]); - const removeCommandScope = useCallback( - (scopeId: string) => setCommandScopes((current) => current.filter((scope) => scope !== scopeId)), - [setCommandScopes], - ); - const clearCommandScopes = useCallback(() => setCommandScopes([]), [setCommandScopes]); const searchCommandContextValue = useMemo( () => ({ query, modeId: searchMode, - commandScopes, - onRemoveScope: removeCommandScope, - onClearScopes: clearCommandScopes, }), - [query, searchMode, commandScopes, removeCommandScope, clearCommandScopes], + [query, searchMode], ); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); @@ -627,7 +617,6 @@ function GlobalStandaloneSearchShellBody({ return; } setQuery(""); - setCommandScopes([]); setMobileMenuOpen(false); // Let the URL sync (render-time) own searchMode. Optimistic setSearchMode // before pathname updates was the namespaced mode-switch reserve flip. @@ -657,7 +646,6 @@ function GlobalStandaloneSearchShellBody({ return; } setQuery(crossQuery); - setCommandScopes([]); setMobileMenuOpen(false); navigateToMode(mode, { query: crossQuery, focus: false, run: true }); } @@ -829,8 +817,6 @@ function GlobalStandaloneSearchShellBody({ queryModeOptions={mockupQueryModeOptions} queryInputRef={inputRef} recentQueries={recentQueries} - commandScopes={commandScopes} - onCommandScopesChange={setCommandScopes} onPickRecent={pickRecentQuery} onCrossModeSearch={crossModeSearch} headerVariant={isDifferentialPresentationWorkflow ? "workflow" : "default"} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 1af54d93d0..008bc33d7c 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -174,8 +174,6 @@ export function MasterSearchHeader({ queryInputAutoFocus = false, composerPlaceholder, recentQueries = [], - commandScopes = [], - onCommandScopesChange, onPickRecent, onCrossModeSearch, composerFollowUpSuggestions, @@ -229,8 +227,6 @@ export function MasterSearchHeader({ /** Overrides the mode's default input placeholder (e.g. "Ask a follow-up..." mid-thread). */ composerPlaceholder?: string; recentQueries?: string[]; - commandScopes?: string[]; - onCommandScopesChange?: (scopes: string[]) => void; onPickRecent?: (query: string) => void; onCrossModeSearch?: (modeId: AppModeId, query: string) => void; composerFollowUpSuggestions?: string[]; @@ -1618,7 +1614,6 @@ export function MasterSearchHeader({ modeId={searchMode} query={query} recentQueries={recentQueries} - commandScopes={commandScopes} placement={commandSurfacePlacement} dropdownOpen={commandDropdownOpen} onDropdownOpenChange={setCommandDropdownOpen} @@ -1646,7 +1641,6 @@ export function MasterSearchHeader({ onAsk(); }} onRunModeAction={runModeAction} - onCommandScopesChange={(scopes) => onCommandScopesChange?.(scopes)} onListboxIdReady={setCommandListboxId} onActiveItemIdChange={setCommandActiveItemId} onFocusSearchInput={() => queryInputRef?.current?.focus()} diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 540cb8cc85..a4b9d7d0a9 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -29,7 +29,6 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { considerationSummaryBadge } from "@/components/clinical-dashboard/medication-considerations"; import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context"; import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel"; -import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { evaluatePatientAlerts } from "@/lib/medication-patient-alerts"; import { @@ -39,7 +38,6 @@ import { type ClinicalBadgeTone, } from "@/components/clinical-dashboard/clinical-badge"; import { medicationIdentityBadges, type MedicationRecord } from "@/lib/medications"; -import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; import { SEMANTIC_TONE_META } from "@/lib/semantic-tone"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { cn, EmptyState, pageContainer } from "@/components/ui-primitives"; @@ -399,7 +397,6 @@ function MedicationResults({ MedicationPrescribingWorkspaceProps, "query" | "realDataReady" | "authUnavailable" | "apiUnavailable" | "setupWarning" >) { - const command = useSearchCommand(); // Debounced + aborted fetches (see useMedicationCatalog) stop keystroke storms. // Keep the full catalogue payload here: Safety/Monitoring chips and patient // alerts need sections/stats/quick that `fields=index` strips. Cross-mode @@ -440,10 +437,7 @@ function MedicationResults({ record, ), ); - const scopes = command?.commandScopes ?? []; - const scoped = scopes.length - ? sourceRows.filter((row) => medicationMatchesCommandScopes(row.result, scopes)) - : sourceRows; + const scoped = sourceRows; const filterCounts: Record = { best: 0, indication: 0, safety: 0, monitoring: 0 }; for (const row of scoped) { for (const filter of medicationResultFilters) { @@ -455,7 +449,7 @@ function MedicationResults({ counts: filterCounts, totalAvailable: scoped.length, }; - }, [activeFilter, catalog.data, command?.commandScopes, profile, profileEmpty]); + }, [activeFilter, catalog.data, profile, profileEmpty]); const resultCount = rows.length; // The match-quality badge only earns its slot when it differentiates: hide it on // "Exact clinical fit" rows when every visible row says the same thing. diff --git a/src/components/clinical-dashboard/search-command-context.tsx b/src/components/clinical-dashboard/search-command-context.tsx index 31bde2f4ff..741e50f236 100644 --- a/src/components/clinical-dashboard/search-command-context.tsx +++ b/src/components/clinical-dashboard/search-command-context.tsx @@ -7,9 +7,6 @@ import type { AppModeId } from "@/lib/app-modes"; export type SearchCommandContextValue = { query: string; modeId: AppModeId; - commandScopes: string[]; - onRemoveScope: (scopeId: string) => void; - onClearScopes: () => void; }; const SearchCommandContext = createContext(null); diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index c4932f6943..4a19693339 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,21 +1,10 @@ "use client"; -import { - Bookmark, - ChevronsUpDown, - CircleAlert, - Funnel, - LayoutList, - LoaderCircle, - Search, - Table2, - X, -} from "lucide-react"; +import { Bookmark, ChevronsUpDown, CircleAlert, LayoutList, LoaderCircle, Search, Table2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; import { AsyncButton, cn } from "@/components/ui-primitives"; -import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { appModeSearchConfig, type AppModeId } from "@/lib/app-modes"; import { readResultSort, type ResultSortValue } from "@/lib/result-sort"; @@ -166,13 +155,6 @@ export function SearchResultsHeaderBand({ headingLevel?: 1 | 2; className?: string; }) { - const command = useSearchCommand(); - const config = searchCommandSurfaceConfig(modeId); - const activeScopes = command?.commandScopes ?? []; - const visibleScopes = activeScopes.flatMap((scopeId) => { - const scope = config?.scopes.find((entry) => entry.id === scopeId); - return scope ? [scope] : []; - }); const displayQuery = query.trim() || "All"; // `status` wins when both are passed; `loading` is the deprecated shim. const resolvedStatus: SearchResultsBandStatus = status ?? (loading ? "loading" : "ready"); @@ -220,11 +202,9 @@ export function SearchResultsHeaderBand({ const countUntrusted = faulted || resolvedStatus === "loading"; const pageControls = countUntrusted ? null : filterControls; const pageMobileControls = countUntrusted ? null : mobileControls; - const hasUtilities = - visibleScopes.length > 0 || - Boolean( - onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry), - ); + const hasUtilities = Boolean( + onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry), + ); const QueryHeading = headingLevel === 1 ? "h1" : "h2"; const { ref: railRef, overflowing: railOverflowing } = useRailOverflow(); @@ -257,8 +237,6 @@ export function SearchResultsHeaderBand({ one before any text is read. */} {faulted || partial ? ( - ) : visibleScopes.length > 0 ? ( - ) : ( )} @@ -456,55 +434,6 @@ export function SearchResultsHeaderBand({ ) : null} - {/* The applied-filter shelf. Its own row, labelled, with one action that - undoes all of it — removing three filters used to be three taps with no - shortcut. Unlike the page filter row below it survives `loading`: these - chips carry no counts, so keeping them through a pending search avoids - the shelf flickering out and back on every keystroke. Only a faulted - search drops them, because filtering a result set that never loaded is - meaningless. */} - {visibleScopes.length > 0 && !faulted ? ( -
- Filtered by - {visibleScopes.map((scope) => ( - - ))} - - {command?.onClearScopes && visibleScopes.length > 1 ? ( - - ) : null} -
- ) : null} {pageControls ? (
({ export function SearchResultsEmptyState({ modeId, query, - onClearScopes, onTryExample, onCrossMode, canAccessFavourites = false, }: { modeId: AppModeId; query: string; - onClearScopes?: () => void; onTryExample?: (example: string) => void; onCrossMode?: (modeId: AppModeId) => void; canAccessFavourites?: boolean; }) { - const command = useSearchCommand(); const config = searchCommandSurfaceConfig(modeId); const crossModes = (config?.crossModes ?? []).filter((target) => canAccessFavourites || target !== "favourites"); - const activeScopes = command?.commandScopes ?? []; return (
@@ -684,21 +609,9 @@ export function SearchResultsEmptyState({ No matches for “{query.trim() || "your search"}”

- Relax the scope, try an example, or jump to another mode. + Try an example, or jump to another mode.

- {activeScopes.length > 0 && onClearScopes ? ( - - ) : null} {config?.examples[0] && onTryExample ? ( ) : null } + appliedFilters={appliedFilters} + onClearFilters={clearAllFilters} filterLabel="Filter documents" // The same trigger goes in both slots: the ribbon shows `mobileControls` // below `sm` and `filterControls` from `sm` up, never both at once. @@ -1293,10 +1328,7 @@ function DocumentSearchResultsPanelImpl({ activeResultType={effectiveResultType} onResultTypeChange={setActiveResultType} onToggle={toggleTagFacet} - onClear={() => { - setActiveFacetState({ query, keys: [] }); - setActiveResultType("all"); - }} + onClear={clearAllFilters} resultCount={sortedMatches.length} onDone={() => setFilterPanelState({ query, open: false })} /> diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 4a19693339..c1c11f2ef0 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bookmark, ChevronsUpDown, CircleAlert, LayoutList, LoaderCircle, Search, Table2 } from "lucide-react"; +import { Bookmark, ChevronsUpDown, CircleAlert, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; @@ -100,6 +100,14 @@ function useRailOverflow() { return { ref, overflowing } as const; } +export type AppliedFilterChip = { + id: string; + label: string; + onRemove: () => void; +}; + +const EMPTY_APPLIED_FILTERS: AppliedFilterChip[] = []; + export function SearchResultsHeaderBand({ modeId, query, @@ -118,6 +126,8 @@ export function SearchResultsHeaderBand({ utilityControls, mobileControls, filterControls, + appliedFilters = EMPTY_APPLIED_FILTERS, + onClearFilters, filterLabel = "Filter search results", headingLevel = 2, className, @@ -150,6 +160,13 @@ export function SearchResultsHeaderBand({ mobileControls?: ReactNode; /** Page-specific filters rendered as a full-width row within the shared ribbon. */ filterControls?: ReactNode; + /** Applied filters, shown on a labelled shelf under the bar. Each removes in + one tap. Supplied by the page rather than read from context: the previous + shelf pulled from a context value nothing could populate, so it rendered + for nobody. */ + appliedFilters?: AppliedFilterChip[]; + /** Clears every applied filter at once. Omit to hide the trailing Clear. */ + onClearFilters?: () => void; filterLabel?: string; /** Use level 1 when the ribbon is the route's primary page heading. */ headingLevel?: 1 | 2; @@ -447,6 +464,56 @@ export function SearchResultsHeaderBand({ {pageControls}
) : null} + {/* The shelf. `Filtered by` labels it as state rather than a second bank + of buttons — without the label a row of accent pills reads as a + toolbar. It deliberately survives `loading` and a zero result: nothing + matching is exactly when you need to relax a filter, and dropping it + mid-search would flicker the chips out and back on every keystroke. + Only a fault removes it, because filtering a result set that never + loaded is meaningless. */} + {appliedFilters.length > 0 && !faulted ? ( +
+ Filtered by + {appliedFilters.map((filter) => ( + + ))} + + {onClearFilters && appliedFilters.length > 1 ? ( + + ) : null} +
+ ) : null} {/* The fault panel carries the announcement and the recovery affordance. `role="alert"` is a distinct role from the spine's `role="status"`, so singular role queries in jsdom and Playwright still resolve to exactly diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index fd9609c8ba..17e04a286e 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -29,6 +29,14 @@ export function SearchScreen() { // not a filter. Counting it there makes the control announce a state the page // is not in, which is the exact defect this screen's sheet was built to fix. const activeFilterCount = b.search.tags.length + availabilityFilterCount; + // Topics and availability both narrow the same list, so both belong on the + // shelf. The query is deliberately absent: it is stated in the composer and + // removing it is not a filter operation. + const appliedFilters = [ + ...b.search.tags.map((tag) => ({ id: `topic-${tag}`, label: tag, onRemove: () => b.toggleTag(tag) })), + ...(b.search.reviewedOnly ? [{ id: "reviewed", label: "Reviewed only", onRemove: b.toggleReviewedOnly }] : []), + ...(b.search.briefOnly ? [{ id: "brief", label: "Brief available", onRemove: b.toggleBriefOnly }] : []), + ]; const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); @@ -42,6 +50,8 @@ export function SearchScreen() { faultBody={b.error ?? undefined} onRetry={b.retryData} headingLevel={1} + appliedFilters={appliedFilters} + onClearFilters={b.clearSearch} filterLabel="Filter therapy results" mobileControls={ { expect(screen.getByTestId("document-filter-trigger-phone")).toHaveAttribute("aria-expanded", "false"); }); }); + +describe("applied-filter shelf", () => { + async function selectClozapine(user: ReturnType) { + await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done")); + } + + it("shows an applied facet as a chip and removes it in one tap", async () => { + const user = userEvent.setup(); + render(); + expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + + await selectClozapine(user); + + const shelf = screen.getByTestId("search-query-ribbon-shelf"); + expect(shelf).toHaveTextContent("Filtered by"); + expect(resultTitles()).toHaveLength(1); + + // One tap, and the count follows immediately. + await user.click(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })); + expect(resultTitles()).toHaveLength(2); + expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + }); + + it("carries the source type alongside facets, and Clear tears both down at once", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("document-filter-trigger-phone")); + const panel = screen.getByTestId("document-filter-panel"); + await user.click(within(panel).getByRole("button", { name: /Clozapine/ })); + await user.click(within(panel).getByRole("radio", { name: /Tables/ })); + await user.click(within(panel).getByTestId("document-filter-done")); + + const shelf = screen.getByTestId("search-query-ribbon-shelf"); + expect(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })).toBeInTheDocument(); + expect(within(shelf).getByRole("button", { name: /Remove Tables filter/ })).toBeInTheDocument(); + + // Clear appears only past one chip — teardown that used to be one tap each. + await user.click(within(shelf).getByTestId("search-query-ribbon-shelf-clear")); + expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + expect(resultTitles()).toHaveLength(2); + }); + + it("survives a pending search, because chips must not flicker on every keystroke", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await selectClozapine(user); + expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); + }); + + it("drops only on a fault, where filtering a set that never loaded is meaningless", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await selectClozapine(user); + + rerender(); + + expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + }); +}); From 6917e7322b2005de00bc8c65666e564b3f47f950 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 01:47:34 +0000 Subject: [PATCH 3/6] =?UTF-8?q?wip(search):=20bar=20anatomy=20=E2=80=94=20?= =?UTF-8?q?tile=20states=20and=20Filter=20at=20the=20right=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNVERIFIED. Typecheck and lint pass; `verify:cheap` and the browser gates have NOT been run against this commit. Do not open a PR on it as-is — see docs/handoff-search-bar.md. Step 4 of the `Results bar — perfected` study, applied in the shared band so all twelve search modes take it at once: - The state tile already existed but carried only two of its four states. It now shows a spinner while a search runs and a funnel once the result set is narrowed, alongside the existing alert and search icons. The funnel is driven by `appliedFilters.length`, so it tracks the same live data as the shelf — the previous funnel branch keyed off the inert command-scope system and was removed with it. - Filter moves hard against the ribbon's right edge and Sort moves inboard. Sort is set about once a session; Filter is the only control carrying state and the one a thumb reaches for. Three tests asserted Sort and Filter as an adjacent *pair*, which this change deliberately separates. They are rewritten to assert what still has to hold — the page filter is the utility rail's last child, and both controls keep a matched phone tap height of at least 43px — rather than the old adjacency. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../search-results-header-band.tsx | 64 ++++++++++--------- tests/search-results-header-band.dom.test.tsx | 11 +++- tests/ui-tools.spec.ts | 56 +++++++++------- 3 files changed, 77 insertions(+), 54 deletions(-) diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index c1c11f2ef0..cae75fe4d1 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,6 +1,16 @@ "use client"; -import { Bookmark, ChevronsUpDown, CircleAlert, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; +import { + Bookmark, + ChevronsUpDown, + CircleAlert, + Funnel, + LayoutList, + LoaderCircle, + Search, + Table2, + X, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; @@ -249,11 +259,16 @@ export function SearchResultsHeaderBand({ )} > {/* The tile carries state as shape as well as colour: alert when the - search failed or degraded, funnel once the result set is narrowed, - search otherwise. A filtered list looks different from an unfiltered - one before any text is read. */} + search failed or degraded, a spinner while one is running, funnel + once the result set is narrowed, search otherwise. A filtered list + looks different from an unfiltered one before any text is read, and + a failure is visible before it is read. */} {faulted || partial ? ( + ) : busy ? ( + + ) : appliedFilters.length > 0 ? ( + ) : ( )} @@ -371,31 +386,12 @@ export function SearchResultsHeaderBand({ Retry ) : null} - {onSortChange && pageMobileControls ? ( -
- -
- {pageMobileControls} -
-
- ) : ( - <> - {onSortChange ? : null} - {pageMobileControls ? ( -
- {pageMobileControls} -
- ) : null} - - )} + {/* Sort inboard: it is set about once a session, so it does not need + to be the easiest thing to hit. Filter is rendered last instead — + it is the only control carrying state, the one you return to + repeatedly, and on a phone the right edge is where the thumb + already is. */} + {onSortChange ? : null} {utilityControls} {onViewChange ? (
Save search ) : null} + {pageMobileControls ? ( +
+ {pageMobileControls} +
+ ) : null}
) : null}
diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 09c68467f3..6083d66db3 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -338,9 +338,14 @@ describe("SearchResultsHeaderBand", () => { />, ); - const pair = screen.getByTestId("search-query-ribbon-mobile-control-pair"); - await user.click(within(pair).getByRole("button", { name: "A–Z" })); - await user.selectOptions(within(pair).getByLabelText("Filter by result type"), "diagnosis"); + // Sort and the page filter are no longer paired: Sort is inboard and the page + // filter renders last, hard against the ribbon's right edge, because it is the + // only control carrying state and the one a thumb reaches for. + const utilities = screen.getByTestId("search-query-ribbon-utilities"); + const pageFilters = screen.getByTestId("search-query-ribbon-mobile-controls"); + expect(utilities.lastElementChild).toBe(pageFilters); + await user.click(within(utilities).getByRole("button", { name: "A–Z" })); + await user.selectOptions(within(pageFilters).getByLabelText("Filter by result type"), "diagnosis"); expect(onSortChange).toHaveBeenCalledWith("alpha"); expect(onFilterChange).toHaveBeenCalledWith("diagnosis"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index f719593574..8db5b8a765 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1601,19 +1601,25 @@ test.describe("Clinical KB tools launcher", () => { await expect(typeSelect).toHaveValue("diagnosis"); await typeSelect.selectOption("all"); - // Sort is a segmented group and the page filter is a select; they sit side by - // side at matched tap height, and the pair itself never scrolls internally -- - // any overflow belongs to the utility rail that owns it. - const mobilePair = page.getByTestId("search-query-ribbon-mobile-control-pair"); - const pairMetrics = await mobilePair.evaluate((element) => ({ + // Sort is a segmented group and the page filter is a select. They no longer + // sit paired: Sort is inboard and the page filter renders last, hard against + // the ribbon's right edge. What still has to hold is that both keep a matched + // phone tap height, and that the utility rail owns any overflow. + const utilities = page.getByTestId("search-query-ribbon-utilities"); + const pageFilters = page.getByTestId("search-query-ribbon-mobile-controls"); + const railMetrics = await utilities.evaluate((element) => ({ width: element.getBoundingClientRect().width, - scrollWidth: element.scrollWidth, - controlHeights: Array.from(element.children).map((child) => child.getBoundingClientRect().height), + right: element.getBoundingClientRect().right, + lastChildIsPageFilter: + element.lastElementChild?.getAttribute("data-testid") === "search-query-ribbon-mobile-controls", })); - expect(pairMetrics.scrollWidth).toBeLessThanOrEqual(pairMetrics.width + 1); - expect(pairMetrics.controlHeights).toHaveLength(2); - expect(Math.abs(pairMetrics.controlHeights[0] - pairMetrics.controlHeights[1])).toBeLessThanOrEqual(1); - expect(Math.min(...pairMetrics.controlHeights)).toBeGreaterThanOrEqual(43); + expect(railMetrics.lastChildIsPageFilter).toBe(true); + const sortHeight = await utilities + .getByRole("group", { name: "Sort results" }) + .evaluate((element) => element.getBoundingClientRect().height); + const filterHeight = await pageFilters.evaluate((element) => element.getBoundingClientRect().height); + expect(Math.abs(sortHeight - filterHeight)).toBeLessThanOrEqual(1); + expect(Math.min(sortHeight, filterHeight)).toBeGreaterThanOrEqual(43); const emergentBadge = page.getByTestId("differential-status-badge").first(); await expect(emergentBadge).toBeVisible(); @@ -1702,19 +1708,25 @@ test.describe("Clinical KB tools launcher", () => { await expect(typeSelect).toHaveValue("presentation"); await typeSelect.selectOption("all"); - // Sort is a segmented group and the page filter is a select; they sit side by - // side at matched tap height, and the pair itself never scrolls internally -- - // any overflow belongs to the utility rail that owns it. - const mobilePair = page.getByTestId("search-query-ribbon-mobile-control-pair"); - const pairMetrics = await mobilePair.evaluate((element) => ({ + // Sort is a segmented group and the page filter is a select. They no longer + // sit paired: Sort is inboard and the page filter renders last, hard against + // the ribbon's right edge. What still has to hold is that both keep a matched + // phone tap height, and that the utility rail owns any overflow. + const utilities = page.getByTestId("search-query-ribbon-utilities"); + const pageFilters = page.getByTestId("search-query-ribbon-mobile-controls"); + const railMetrics = await utilities.evaluate((element) => ({ width: element.getBoundingClientRect().width, - scrollWidth: element.scrollWidth, - controlHeights: Array.from(element.children).map((child) => child.getBoundingClientRect().height), + right: element.getBoundingClientRect().right, + lastChildIsPageFilter: + element.lastElementChild?.getAttribute("data-testid") === "search-query-ribbon-mobile-controls", })); - expect(pairMetrics.scrollWidth).toBeLessThanOrEqual(pairMetrics.width + 1); - expect(pairMetrics.controlHeights).toHaveLength(2); - expect(Math.abs(pairMetrics.controlHeights[0] - pairMetrics.controlHeights[1])).toBeLessThanOrEqual(1); - expect(Math.min(...pairMetrics.controlHeights)).toBeGreaterThanOrEqual(43); + expect(railMetrics.lastChildIsPageFilter).toBe(true); + const sortHeight = await utilities + .getByRole("group", { name: "Sort results" }) + .evaluate((element) => element.getBoundingClientRect().height); + const filterHeight = await pageFilters.evaluate((element) => element.getBoundingClientRect().height); + expect(Math.abs(sortHeight - filterHeight)).toBeLessThanOrEqual(1); + expect(Math.min(sortHeight, filterHeight)).toBeGreaterThanOrEqual(43); const emergentBadge = page.getByTestId("differential-status-badge").first(); await expect(emergentBadge).toBeVisible(); From 0cacba9684cf356d50384658b7bde745511a7bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 01:49:07 +0000 Subject: [PATCH 4/6] docs: hand off the search results bar work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what is verified, what is not, which study steps are done, and the two decisions that should not be relitigated — why the library button stays in the bar, and why the shelf is scoped to two pages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/handoff-search-bar.md | 123 +++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/handoff-search-bar.md diff --git a/docs/handoff-search-bar.md b/docs/handoff-search-bar.md new file mode 100644 index 0000000000..53e4a2f130 --- /dev/null +++ b/docs/handoff-search-bar.md @@ -0,0 +1,123 @@ +# Handoff — search results bar, `Results bar — perfected` + +Branch `claude/top-search-design-mockups-w53znc`, pushed, **no PR open**. +Three commits on top of `origin/main` (`40814b44`). + +## Do this first + +`6917e732` is **unverified**. Run, in order: + +```bash +npm run verify:cheap +npm run ensure +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/opt/pw-browsers/chromium-1194/chrome-linux/chrome \ + node scripts/run-playwright.mjs tests/ui-tools.spec.ts tests/ui-smoke.spec.ts \ + tests/ui-accessibility.spec.ts --project=chromium +``` + +`ui-tools.spec.ts` is the one at risk: it had two assertions on the Sort/Filter +pair that this commit rewrites, and it runs `expectNoPageHorizontalOverflow` at +390 px right after them. Moving Filter to the right edge changes the utility +rail's layout, so overflow at 390 px is the plausible failure. + +**Known-environmental:** `ui-smoke.spec.ts` → `document viewer puts the PDF +preview first with pinned evidence after it on mobile` fails on +`pdfScroller.locator("canvas")`. It fails identically with these changes +stashed — this box has Chromium 1194 against the project's pinned 1228. Do not +chase it. + +## The three commits + +| SHA | State | What | +| ---------- | -------------- | --------------------------------------------------- | +| `df8c3fb7` | verified | Delete the inert command-scope system | +| `cea1d1ca` | verified | Rebuild the applied-filter shelf on live facet data | +| `6917e732` | **unverified** | Bar anatomy — tile states, Filter to the right edge | + +Verified means `verify:cheap` exit 0 (460 files / 4796 tests) plus `ui-smoke` + +`ui-accessibility` 108 passed / 1 failed (the PDF canvas above), taken before +the rebase onto current `main`. Re-run after the rebase regardless. + +## Where the work came from + +Artifact **`Results bar — perfected`** (`007a83f4-9922-4c96-ab13-47852605bdbe`), +its own seven-step build list. Current state of each: + +| # | Step | State | +| --- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| 1 | Merge scope chips + source-type into the facet panel | Done — source-type merged in #1536; scope chips were inert, so `df8c3fb7` deletes rather than merges them | +| 2 | Counts against the same set, disable dead-end facets | Done (#1523 era) | +| 3 | Drop "of 12", use the mode's own noun | Done (#1523) | +| 4 | State tile, Filter right edge, Sort inboard | **`6917e732`, unverified** | +| 5 | The shelf — `Filtered by`, trailing Clear, survives zero result | Done (`cea1d1ca`) | +| 6 | Remove Sources from the results bar | **Deliberately not done** — see below | +| 7 | Decide OR-within-group | Done | + +### Step 6 is declined, not pending + +The study says corpus browsing belongs in nav and the bar button should go. It +explicitly declines to check where nav puts it. In this app the documents action +menu routes through `onSearchModeChange`, which calls `setQuery("")` and +`setModeSearchSubmitted(false)` (`ClinicalDashboard.tsx`), so reaching the +library that way **discards the search being read**. The bar button is the only +in-context route. It was renamed `Library` / `Open source library` instead. Do +not remove it without first giving nav a route that preserves the query. + +### Step 4, what is actually left + +The state tile already existed and already had its alert and search states. +`6917e732` adds the spinner and the funnel. What the study specifies and this +branch does **not** do: + +- **Sort moves into the sheet on phone**, with the sheet retitled + `Filter and sort`. Not done, and it is per-page work: only documents and + therapy-compass have sheets. The other six modes would lose Sort on phone + entirely, which is the exact defect the study itself records fixing in an + earlier round. Do not do this in the shared band. + +## Scope decisions worth not relitigating + +The shelf is on **documents** and **therapy-compass** only. Both have +multi-valued filters hidden behind a panel. The other six modes +(differentials, prescribing, specifiers, formulation, services, factsheets) +have a single-select dimension whose control is already visible in the bar, so +a shelf would restate what is on screen. + +Two related traps, both hit and corrected during this work: + +- **Count what a control does, not how many there are.** Formulation's + "Pattern" and factsheets' "Category" look like filters and are navigation + (`router.push`). Services' "quick filter" rewrites the query. None of them + belong behind a filter surface. +- **A shared component must not read filter state from context.** The old shelf + pulled `commandScopes` from a context no page populated, so it passed a DOM + test that constructed the context by hand and rendered for nobody in + production. The new one is prop-driven: the page supplies `appliedFilters` + (`id`, `label`, `onRemove`). Keep it that way. + +## Ledger + +`#182` (inert command-scope system) is closed by `df8c3fb7`, with the outcome +recorded. `docs/outstanding-issues.md` conflicted on the rebase — `main` had +added `#183`–`#185` while this branch archived `#182`; resolved keeping both +sides. `npm run check:outstanding-issues` passes: 183 rows, 62 open, 121 +archived, next-id 186. + +**Not yet captured** and worth an `/issues` row: `ci.yml`'s "Sync PR policy +body" job reads `PR_POLICY_BODY.md` from the PR head and overwrites the PR +description with it. #1546 committed that scratch file to `main`, so every open +PR had its body replaced with #1546's content — and `pr-policy.mjs` parses the +body as merge-gating input, so governance checklists and verification claims +were showing on PRs they did not belong to. #1548 deleted the file, which fixes +it, but nothing records the underlying habit. + +## Before opening the PR + +- `classifyPullRequestFiles` against the **full** `origin/main...HEAD` diff, not + the tip commit. Getting this wrong on #1536 produced a governance section that + was checked for the wrong change. +- Assemble every commit before the first push. `cancel-in-progress: true` means + a second push cancels the in-flight run and `PR required` scores the + cancellation as a failure — that pattern cost this branch four CI runs. +- If auto-merge is armed, disable it before pushing anything further, then + re-enable. From 4f4440fd6776742f5de203ee15295f372205321d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 02:06:22 +0000 Subject: [PATCH 5/6] docs: record the gate results for the search bar work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the `wip(search)` commit's UNVERIFIED caveat. History was not rewritten to correct it in place, because the branch was already pushed and a force-push is not worth a stale adjective. `verify:pr-local` exit 0 — 460 files / 4796 tests, production build, client-bundle secret scan, RAG fixtures 36 cases / 23 suites. `ui-tools.spec.ts` 87 passed; `ui-smoke` + `ui-accessibility` 108 passed and 1 failed, the failure being the pre-existing document-viewer PDF canvas test that fails identically with these changes stashed. `ui-tools` was the real risk and is the reason it was run first and separately: it carried two assertions on the Sort/Filter pair that this work separates, and runs `expectNoPageHorizontalOverflow` at 390px straight after them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/handoff-search-bar.md | 53 ++++++++++++++------------------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/docs/handoff-search-bar.md b/docs/handoff-search-bar.md index 53e4a2f130..00829a55a2 100644 --- a/docs/handoff-search-bar.md +++ b/docs/handoff-search-bar.md @@ -1,42 +1,29 @@ # Handoff — search results bar, `Results bar — perfected` -Branch `claude/top-search-design-mockups-w53znc`, pushed, **no PR open**. -Three commits on top of `origin/main` (`40814b44`). +Branch `claude/top-search-design-mockups-w53znc`. Four commits on top of +`origin/main` (`40814b44`). -## Do this first +## Status -`6917e732` is **unverified**. Run, in order: +All four commits are verified. The `wip(search)` commit message calls itself +UNVERIFIED and points here — that caveat is **superseded**; it was written before +the gates ran, and history was not rewritten to correct it because the branch was +already pushed. -```bash -npm run verify:cheap -npm run ensure -PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/opt/pw-browsers/chromium-1194/chrome-linux/chrome \ - node scripts/run-playwright.mjs tests/ui-tools.spec.ts tests/ui-smoke.spec.ts \ - tests/ui-accessibility.spec.ts --project=chromium -``` +| Gate | Result | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `npm run verify:pr-local` | exit 0 — 460 files / 4796 tests, production build, client-bundle secret scan, RAG fixtures 36 cases / 23 suites | +| `tests/ui-tools.spec.ts` | 87 passed | +| `tests/ui-smoke.spec.ts` + `tests/ui-accessibility.spec.ts` | 108 passed, 1 failed | -`ui-tools.spec.ts` is the one at risk: it had two assertions on the Sort/Filter -pair that this commit rewrites, and it runs `expectNoPageHorizontalOverflow` at -390 px right after them. Moving Filter to the right edge changes the utility -rail's layout, so overflow at 390 px is the plausible failure. +The single failure is `document viewer puts the PDF preview first with pinned +evidence after it on mobile`, at `pdfScroller.locator("canvas")`. It fails +identically with these changes stashed — this box runs Chromium 1194 against the +project's pinned 1228. Do not chase it. -**Known-environmental:** `ui-smoke.spec.ts` → `document viewer puts the PDF -preview first with pinned evidence after it on mobile` fails on -`pdfScroller.locator("canvas")`. It fails identically with these changes -stashed — this box has Chromium 1194 against the project's pinned 1228. Do not -chase it. - -## The three commits - -| SHA | State | What | -| ---------- | -------------- | --------------------------------------------------- | -| `df8c3fb7` | verified | Delete the inert command-scope system | -| `cea1d1ca` | verified | Rebuild the applied-filter shelf on live facet data | -| `6917e732` | **unverified** | Bar anatomy — tile states, Filter to the right edge | - -Verified means `verify:cheap` exit 0 (460 files / 4796 tests) plus `ui-smoke` + -`ui-accessibility` 108 passed / 1 failed (the PDF canvas above), taken before -the rebase onto current `main`. Re-run after the rebase regardless. +`ui-tools` was the one at genuine risk: it carried two assertions on the +Sort/Filter pair that this work deliberately separates, and it runs +`expectNoPageHorizontalOverflow` at 390 px straight after them. Both clear. ## Where the work came from @@ -48,7 +35,7 @@ its own seven-step build list. Current state of each: | 1 | Merge scope chips + source-type into the facet panel | Done — source-type merged in #1536; scope chips were inert, so `df8c3fb7` deletes rather than merges them | | 2 | Counts against the same set, disable dead-end facets | Done (#1523 era) | | 3 | Drop "of 12", use the mode's own noun | Done (#1523) | -| 4 | State tile, Filter right edge, Sort inboard | **`6917e732`, unverified** | +| 4 | State tile, Filter right edge, Sort inboard | `6917e732` | | 5 | The shelf — `Filtered by`, trailing Clear, survives zero result | Done (`cea1d1ca`) | | 6 | Remove Sources from the results bar | **Deliberately not done** — see below | | 7 | Decide OR-within-group | Done | From 92e72c2862052c4679f9c269580111fa4ff7cc97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 02:09:00 +0000 Subject: [PATCH 6/6] docs: record the search results bar handoff in the branch review ledger Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 01444173f5..1c931a9b23 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -572,3 +572,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | PR #1542 | c028450b31441e272a04471292cd162f9c8c826b | documents filter sheet (C1) | Handoff. Moved the documents filter panel into ui/sheet.tsx: bottom sheet below sm, centred dialog above, header/footer split into the sheet slots, trigger upgraded to a dialog opener with aria-controls via Sheet's new id prop. Branch restarted from origin/main after PR #1536 merged and its branch was deleted; C1 cherry-picked on top rather than stacked on merged history. | verify:cheap exit 0 (457 files / 4780 tests); document-filter-panel.dom.test.tsx 9 passed incl. new dialog+Escape cases; ui-smoke documents journey 1 passed chromium; verify:phone-chrome stages green except the pre-existing PDF-canvas failure, focused-browser stage run directly 7 passed | | 2026-07-31 | claude/sentry-agent-monitoring-eri94v | 38378ac78c0b288b6e93b638019653001f375042 | Sentry AI agent monitoring (OpenAI wrap, gen_ai scrubber allowlist, conversation id) | pass — metadata-only instrumentation; privacy boundary preserved | verify:pr-local,verify:cheap,typecheck,vitest 458 files green | | 2026-07-31 | claude/top-search-design-mockups-w53znc | c09eb64918ae2956968d35f3253c405ed9b915d5 | therapy-compass filter sheet + documents review fixes (C2) | Therapy Compass's phone Topics/availability selects were faking multi-select (value pinned to "", literal check-prefix in option text, a Clear-filters action among the options); replaced with one sheet of aria-pressed toggles matching the wide viewport, in the tc- idiom. Also lands two CodeRabbit findings from merged PR #1542: sheet footer primary action to sm:min-h-12, and the dialog test now pins aria-expanded/aria-controls. Third finding (unmount skips focus restore) dispositioned no-change with a test pinning the scroll lock is not leaked. | verify:cheap exit 0 (458 files / 4785 tests); ui-accessibility.spec.ts 15 passed chromium incl. the rewritten Therapy Compass case; ui-smoke documents journey 1 passed chromium | +| 2026-08-01 | claude/top-search-design-mockups-w53znc | 4f4440fd6776742f5de203ee15295f372205321d | search results bar: scope-system deletion, filter shelf, bar anatomy | Handoff for PR #1555. Deleted the inert command-scope system (voided props, six modes' scope config, three matchers, four no-op call sites, the original shelf) — behaviour-preserving because every matcher early-returned true on a permanently-empty array. Rebuilt the applied-filter shelf prop-driven on live facet data, scoped to documents and therapy-compass. Landed the bar anatomy: tile spinner and funnel states, Filter to the right edge, Sort inboard. Study step 6 (remove the library button) deliberately declined — the nav route clears the query via onSearchModeChange. Ledger #182 closed. | verify:pr-local exit 0 (460 files / 4796 tests, production build, client-bundle secret scan, RAG fixtures 36 cases / 23 suites); ui-tools 87 passed; ui-smoke + ui-accessibility 108 passed 1 failed (pre-existing PDF-canvas test, fails identically stashed, Chromium 1194 vs pinned 1228); mutation-tested the shelf's survives-loading guard |