Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3c40262
fix(ui): synchronize mobile answer chrome
cursoragent Jul 25, 2026
a09ecdb
test(ui): require answer scroll hide without blur
cursoragent Jul 25, 2026
48a0be6
fix(ui): release stale answer composer focus
cursoragent Jul 25, 2026
5ed126b
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
731950a
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
dd2d8c3
fix(ui): bind composer autofocus retries
cursoragent Jul 25, 2026
1b3a358
Merge remote-tracking branch 'origin/cursor/fix-mobile-composer-edge-…
cursoragent Jul 25, 2026
ae77f8c
chore(ui): stay within dashboard line budget
cursoragent Jul 25, 2026
074a965
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
672c3dd
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
d41c283
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
31192aa
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
8487a26
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
bb06441
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
8b05e53
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
3ec7970
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
6a05aa7
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
f1393a5
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
4825d7c
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
b47d001
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
62e20c6
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
0c2b60a
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
68d9f21
fix(ui): hide chrome on compact answer results
cursoragent Jul 25, 2026
3b5ef43
fix(ui): hold chrome through fractional scroll clamp
cursoragent Jul 25, 2026
0808481
docs(review): record mobile chrome Bugbot pass
cursoragent Jul 25, 2026
164139c
test(ui): distinguish range clamps from upward intent
cursoragent Jul 25, 2026
4f9fbf3
fix(ui): distinguish layout clamps from upward scroll
cursoragent Jul 25, 2026
23bbb97
refactor(ui): compact bound composer focus retry
cursoragent Jul 25, 2026
ff3c222
docs(ui): codify geometry-based clamp handling
cursoragent Jul 25, 2026
69dc0db
style: format use-hide-on-scroll.ts to satisfy prettier check
cursoragent Jul 25, 2026
c08ed37
docs(review): record current PR Bugbot pass
cursoragent Jul 25, 2026
80081aa
docs(review): record document scroll behavior findings
cursoragent Jul 25, 2026
3efc68a
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
8b812b1
Merge branch 'main' into cursor/fix-mobile-composer-edge-scroll-5b1d
github-actions[bot] Jul 25, 2026
ac14e66
fix: resolve header scroll hide behavior
BigSimmo Jul 28, 2026
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
3 changes: 3 additions & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,6 @@ This file is append-only. Never rewrite or delete an existing review record; app
| 2026-07-25 | open-pr-babysit-continuation-20260725 | multipass | Babysit continuation after 14 merges: #1177 landed; #1174/#1178/#1153 in progress; drafts #1187/#1192 skipped; large cluster #1162/#1185/#1186/#1188/#1190 content-conflicted (skip). | merge-tree inventory; no provider-backed checks. |
| 2026-07-25 | audit-remediation (PR #1153) | 5a731df5c25fed9b07fd2321a0ad4b6519471f4b | PR babysit: CodeRabbit thread fixes + merge | Before: MERGEABLE/BLOCKED on required_review_thread_resolution + pending CI; 6 CodeRabbit threads. After: fixed sync-skills pad/YAML escape, PDF temp cleanup, squash-aware rollback wording; dispositioned ledger mid-table + retained false-positive; approved CI; merged to main `191b17d2f` (merge commit); branch deleted; tip is ancestor of main. | Hosted CI green on tip; no provider-backed checks. |
| 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. |
| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 3b5ef43f1825dd8cf11dd767069569ba1c701c45 | Bugbot branch review: mobile Answer edge dock, synchronized hide/reveal, focus safety, reserve-collapse and fractional-clamp safeguards | No bugs found. Highest residual risk is physical iOS Safari toolbar/visual-viewport behavior beyond Chromium emulation. | `npm run verify:cheap` (3,357 passed); `npm run verify:ui` (272 passed); focused clamp/reserve Vitest (28 passed); focused production Chromium regression passed; clean headed-phone video proof; no provider-backed checks run. |
| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 69dc0dbfb46586f54f5934199d4a65b9f6a0aba8 | User-requested Bugbot review of current PR head after geometry-aware clamp handling and CI formatting fix | No bugs found. | Bugbot branch review; prior focused unit/Chromium/manual proofs retained; no provider-backed checks run. |
| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | c08ed37c5e8c7b62cf7d547089afff7a63c9122c | Live local document-detail scroll/ownership review at 390x844, 768x1024, and 1440x900 | FINDINGS: P2 canonical phone detail renders shared mobile header plus DocumentViewer header; P2 expanded desktop sticky rail scrolls its section navigation off-screen; P3 390px in-flow section nav fully hides Images with no overflow cue. Composer focus pinning, actions sheet, endpoint clearance, safe-area gap, and single composer/content reserve ownership otherwise held. | `npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` + `/api/local-project-id` identity; live local Chromium natural down/up, anchors, focus, sheet, endpoint and geometry probes; focused document-viewer Playwright 3/3; reduced-motion + forced-colors visibility at 390/1440; no OpenAI/Supabase/GitHub/hosted CI/provider calls. |
2 changes: 2 additions & 0 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ This repo uses one shared search experience across the global shell, dashboard r
6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge.
7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both.
8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof.
9. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent. Do not use synthetic page padding to make the stricter gate pass.
10. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally.

## Change checklist

Expand Down
33 changes: 21 additions & 12 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import {
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate";
import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions";
import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
import {
answerReferencesDocument,
Expand Down Expand Up @@ -354,6 +354,7 @@ export function ClinicalDashboard({
const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() =>
Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"),
);
const shouldAutoFocusComposer = focusSearch && !(searchMode === "answer" && modeSearchSubmitted);
const [answer, setAnswer] = useState<RagAnswer | null>(null);
const [sources, setSources] = useState<SearchResult[]>([]);
// Answer-mode conversation thread. `priorAnswerTurns` holds completed
Expand Down Expand Up @@ -1495,11 +1496,14 @@ export function ClinicalDashboard({
}, []);

useEffect(() => {
if (!focusSearch) return undefined;
focusComposerInput();
const timeout = window.setTimeout(focusComposerInput, 500);
if (!shouldAutoFocusComposer) {
if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur();
return undefined;
}
focusComposerInput(true);
const timeout = window.setTimeout(() => focusComposerInput(true), 500);
return () => window.clearTimeout(timeout);
}, [focusSearch]);
}, [shouldAutoFocusComposer]);

// Abort any in-flight answer/library search if the dashboard unmounts.
useEffect(() => {
Expand Down Expand Up @@ -1536,7 +1540,7 @@ export function ClinicalDashboard({
setLoading(false);
setError(null);
setAnswerProgress(null);
if (shouldFocusComposer) focusComposerInput();
if (shouldFocusComposer) focusComposerInput(true);
});
return () => window.cancelAnimationFrame(frame);
}, [searchParams, clearDifferentialModeResultState]);
Expand All @@ -1556,7 +1560,7 @@ export function ClinicalDashboard({
// run=1 URLs name the latest answered question; the composer stays empty
// while an answer thread is active (including after localStorage restore).
if (searchText && params.get("run") !== "1") setQuery(searchText);
if (shouldFocusComposer) focusComposerInput();
if (shouldFocusComposer) focusComposerInput(true);
});
return () => window.cancelAnimationFrame(frame);
}, [clearDifferentialModeResultState]);
Expand Down Expand Up @@ -2611,10 +2615,15 @@ export function ClinicalDashboard({
router.push(appModeHomeHref(mode, { queryMode, scopeFilters }));
}

function focusComposerInput() {
function focusComposerInput(retainTarget = false) {
const requestedInput = retainTarget ? composerInputRef.current : null;
const focusBoundInput = () => {
const input = retainTarget ? requestedInput : composerInputRef.current;
if (input?.isConnected && composerInputRef.current === input) input.focus({ preventScroll: true });
};
window.requestAnimationFrame(() => {
composerInputRef.current?.focus({ preventScroll: true });
window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150);
focusBoundInput();
window.setTimeout(focusBoundInput, 150);
});
}

Expand Down Expand Up @@ -2804,7 +2813,7 @@ export function ClinicalDashboard({
reportPhoneScrollHideRef.current({
offset: main.scrollTop,
maxOffset: Math.max(0, main.scrollHeight - main.clientHeight),
collapseBudget: readChromeCollapseBudget(main),
...readChromeCollapseMetrics(main),
source: main,
});
});
Expand Down Expand Up @@ -3366,7 +3375,7 @@ export function ClinicalDashboard({
}}
queryModeOptions={clinicalQueryModeOptions}
queryInputRef={composerInputRef}
queryInputAutoFocus={focusSearch}
queryInputAutoFocus={shouldAutoFocusComposer}
recentQueries={recentQueries}
commandScopes={commandScopes}
onCommandScopesChange={setCommandScopes}
Expand Down
6 changes: 3 additions & 3 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
resolveMobileComposerReserve,
resolveShellVisibleMobileComposerReserve,
} from "@/components/clinical-dashboard/mobile-composer-reserve";
import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton";
import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed";
import {
Expand Down Expand Up @@ -506,7 +506,7 @@ function GlobalStandaloneSearchShellClient({
phoneScrollHide.reportScroll({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
collapseBudget: readChromeCollapseBudget(target),
...readChromeCollapseMetrics(target),
source: target,
});
}
Expand All @@ -531,7 +531,7 @@ function GlobalStandaloneSearchShellClient({
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
// Collapsing chrome releases layout into nested scrollers too (their
// flex height cap grows with the shell), so the same budget applies.
collapseBudget: readChromeCollapseBudget(main),
...readChromeCollapseMetrics(main),
source: target,
});
};
Expand Down
116 changes: 88 additions & 28 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,20 @@ export function MasterSearchHeader({
* matching top padding on its scroll container.
*/
allBreakpoints?: boolean;
/**
* Collapse-only: how the chrome hides above the phone breakpoint. Omitting
* it keeps the hide/reveal phone-only.
*
* "collapse" releases the header's layout row at every width — for hosts
* whose scrollport is an internal element at every width (ClinicalDashboard's
* `<main>`), where the released strip goes straight to the content.
*
* "sticky" pins the chrome to the viewport top and slides it away instead —
* for hosts that hand scrolling back to the document above the phone
* breakpoint (GlobalSearchShell), where releasing flow space would jump the
* page under the reader.
*/
wide?: "collapse" | "sticky";
/** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */
scrollHidden?: boolean;
};
Expand Down Expand Up @@ -350,8 +364,19 @@ export function MasterSearchHeader({
disabled: !hideOnScroll || hideOnScroll.scrollHidden !== undefined,
});
const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden;
const headerChromeHidden =
scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused;
// Header and composer share one scroll signal, so any active surface or
// focus inside either edge pins both edges. This preserves keyboard focus
// safety without letting the unfocused header disappear above a still-
// focused composer (or vice versa).
const sharedChromePinned =
modeMenuOpen ||
actionMenuOpen ||
commandDropdownOpen ||
scopeOpen ||
scopeSheetOpen ||
headerChromeFocused ||
composerChromeFocused;
const headerChromeHidden = scrollHidden && !sharedChromePinned;
// Mode homes portal the composer into the hero slot. With "all" the hero owns
// every width (the answer home keeps its in-flow pill on phones); "sm-up"
// hero hosts hand phones the bottom dock instead.
Expand All @@ -364,14 +389,7 @@ export function MasterSearchHeader({
// Compare addon chrome lives inside the phone dock; hide/reveal with it so
// the search pill and Compare selected bar reclaim space together.
const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive);
const bottomComposerHidden =
bottomComposerScrollHiddenActive &&
scrollHidden &&
!actionMenuOpen &&
!commandDropdownOpen &&
!scopeOpen &&
!scopeSheetOpen &&
!composerChromeFocused;
const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && !sharedChromePinned;

useEffect(() => {
onBottomComposerHiddenChange?.(bottomComposerHidden);
Expand Down Expand Up @@ -1676,6 +1694,12 @@ export function MasterSearchHeader({
// flow (absolute over the scrolling <main>, which reserves matching top
// padding) so content frosts under the glass bar at every width.
const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints);
const wideCollapseBehaviour = hideStrategy === "collapse" ? hideOnScroll?.wide : undefined;
// Collapse hosts whose scrollport is internal at every width release the
// header row at every width too; hosts that hand scrolling back to the
// document above the phone breakpoint stick and translate there instead.
const collapsesAtEveryWidth = wideCollapseBehaviour === "collapse";
const sticksAbovePhones = wideCollapseBehaviour === "sticky";
const chromeFocusProps = hideOnScroll
? {
onFocusCapture: () => setHeaderChromeFocused(true),
Expand Down Expand Up @@ -1705,13 +1729,19 @@ export function MasterSearchHeader({
"edge-glass-header universal-header z-30 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)]",
// Collapse hosts keep the header above an internally scrolling <main>, so
// sticky is unnecessary on phones and fights the 0fr grid collapse by
// pinning the bar inside the viewport. All-breakpoints overlay hosts take
// the header out of flow entirely (absolute over the padded <main>) —
// sticky would be inert there because the scroll container is <main>, not
// an ancestor of the header. Legacy overlay hosts keep sticky (they ride
// document scroll) and can translate away with zero layout shift.
// pinning the bar inside the viewport. Above phones the pinning belongs
// to the collapse wrapper instead: this <header> sits inside two
// header-height boxes, which leaves a sticky rule here zero travel — the
// bar simply scrolled off with the page and only came back at scroll
// top. All-breakpoints overlay hosts take the header out of flow
// entirely (absolute over the padded <main>) — sticky would be inert
// there because the scroll container is <main>, not an ancestor of the
// header. Legacy overlay hosts keep sticky (they ride document scroll)
// and can translate away with zero layout shift.
hideStrategy === "collapse"
? "max-sm:relative sm:sticky sm:top-0"
? sticksAbovePhones
? "relative"
: "max-sm:relative sm:sticky sm:top-0"
: overlayAllBreakpoints
? "absolute inset-x-0 top-0"
: "sticky top-0",
Expand Down Expand Up @@ -1933,32 +1963,62 @@ export function MasterSearchHeader({
);

if (hideStrategy === "collapse") {
// Collapse hide-on-scroll (phones): the host renders the header above an
// internally scrolling element, so hiding must also release the header's
// layout space. A 1fr -> 0fr grid row animates the collapse without any
// height measurement; the bottom-anchored inner track makes the chrome
// slide up out of the viewport top. Fixed-position composers (answer
// footer, mobile bottom search) escape the wrapper naturally because it
// never carries a transform, and everything is inert from sm up.
// Collapse hide-on-scroll: the host renders the header above an internally
// scrolling element, so hiding must also release the header's layout space.
// A 1fr -> 0fr grid row animates the collapse without any height
// measurement; the bottom-anchored inner track makes the chrome slide up
// out of the viewport top. Fixed-position composers (answer footer, mobile
// bottom search) escape the wrapper naturally because it carries no
// transform in this mode.
//
// Above the phone breakpoint a `wide: "sticky"` host scrolls the document
// instead, so this wrapper — not the <header> inside it, which has no
// sticky travel within its header-height parents — pins to the viewport top
// and translates away. Releasing the row there would pull the whole page up
// by the header height mid-scroll; translating leaves the geometry alone.
return (
<div
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-testid="universal-header-collapse"
className={cn(
"max-sm:grid max-sm:transition-[grid-template-rows] motion-reduce:transition-none",
headerChromeHidden
? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
"motion-reduce:transition-none",
collapsesAtEveryWidth
? cn(
"grid transition-[grid-template-rows]",
headerChromeHidden
? "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
: "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "[grid-template-rows:0fr]" : "[grid-template-rows:1fr]",
)
: cn(
"max-sm:grid max-sm:transition-[grid-template-rows]",
headerChromeHidden
? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
sticksAbovePhones &&
cn(
"sm:sticky sm:top-0 sm:z-30 sm:transition-transform",
headerChromeHidden
? "sm:duration-[240ms] sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "sm:duration-200 sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
),
// Transform only while hidden: a standing transform would make
// this wrapper the containing block for the fixed-position menus
// and composers rendered inside it.
sticksAbovePhones && headerChromeHidden && "sm:-translate-y-full",
),
)}
{...chromeFocusProps}
>
<div
className={cn(
"max-sm:flex max-sm:min-h-0 max-sm:flex-col max-sm:justify-end",
collapsesAtEveryWidth && "sm:flex sm:min-h-0 sm:flex-col sm:justify-end",
// Clip only while hiding so the edge-glass-header gradient that
// extends below the header keeps painting when the chrome is shown.
headerChromeHidden && "max-sm:overflow-hidden",
collapsesAtEveryWidth && headerChromeHidden && "sm:overflow-hidden",
)}
>
{headerAndComposer}
Expand Down
Loading
Loading