Skip to content

fix(chat): render the sidebar filter menu's pickers inline on a phone - #6909

Merged
buluoray merged 2 commits into
mainfrom
fix/sidebar-filter-submenu-narrow
Aug 30, 2026
Merged

fix(chat): render the sidebar filter menu's pickers inline on a phone#6909
buluoray merged 2 commits into
mainfrom
fix/sidebar-filter-submenu-narrow

Conversation

@buluoray

@buluoray buluoray commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Tapping Recent in the sessions filter menu on a phone opened its window picker off the screen edge — partly visible on one side, the rest unreachable.

Root cause

Both duration pickers in that menu are nested Radix submenus. @radix-ui/react-menu hardcodes a submenu to side="right" (it is not a prop), and @radix-ui/react-popper gives it shift({ mainAxis: true, crossAxis: false }) — for a right-side placement that is the vertical axis. So a submenu is free to slide up and down and never moves along the axis that is actually short.

At phone width neither side fits beside a menu that already spans most of the screen. Measured on the built SPA at a 390px viewport:

before after
Recent flyout width 249px inline, no flyout
off the right edge 192px 0px
--radix-popper-available-width 57px n/a

flip's bestFit fallback picks whichever side overflows less, so the same defect surfaces on the left edge on some viewports — which is what the report showed. No width, padding or collisionPadding tuning recovers it.

Change

On a phone the options render inline inside the one open menu:

  • RecentWithin presets + the custom amount/unit row sit under their row, and only while the filter is ON. The row keeps its only job (toggle the filter) and loses the chevron, since there is nothing left to open.
  • Collapse dormant sessions — thresholds render as chips rather than seven more menu rows, under a section caption.
  • New chat in folder (the ⌄ menu beside New) — same mechanism, same fix: folders inline under a caption.

The menu also gains max-w-[calc(100vw-1rem)]. Radix sizes the popper wrapper to max-content, so the dormant-collapse caption sentence alone stretched the menu to 510px inside a 390px viewport once it moved inline.

A wide viewport keeps the flyout, at identical measured geometry (249px wide, 0px clipped, 117.3px tall before and after).

Two defects the inline move exposed

Both were already there, hidden inside a flyout nobody could see, and both are now front and centre on the surface this PR fixes. Raised by UX Review.

  • The recency-unit select rendered broken on touch. NativeSelect draws its chevron as an overlay at right-3, and cn is tailwind-merge, which cannot split a caller's px-* shorthand — so this call site's px-1.5 took the right gutter away and the arrow painted over the word (hou⌄rs). Separately, SimpleSelect's touch branch put the caller's style on the <select>, which is w-full inside the wrapper, so a flex: 1 1 0% there was inert and the control stayed content-width. The gutter is now an inline constant of the component (no dependency on class ordering) and style lands on the positioning wrapper on both SimpleSelect paths. 20 call sites pass style to SimpleSelect and every one of them was affected on touch — my first count of three was wrong. The values are all layout intent (minWidth, flexShrink: 0, flex: '0 0 140px', width: '100%', marginLeft: 'auto'), and on the <select> they were inert or actively wrong: a minWidth larger than the wrapper pushed the control out from under the chevron, and a flex rule on a non-flex-item did nothing. Moving them to the wrapper makes each one effective, so some touch-path selects will legitimately change size. Note what the suite does and does not prove: useIsTouchDevice is false under jsdom, so the 26,161 passing tests exercise the Radix path, not the touch path, for those 20 — the touch path is covered by the new NativeSelect/SimpleSelect tests and the phone screenshots here.
  • Choosing a window did not enable the filter. With the picker always visible, tapping 1 week on an inactive Recent turned the chip green and changed nothing in the list. The picker is now gated on the filter being on, and committing a window turns the filter on — the gate alone would have fixed only the phone, since the wide viewport reaches the same picker through the flyout. Gated on a real change, so a blur that re-commits the identical value is not a hidden toggle.

Known limitation

This fixes the three submenus reachable from the sidebar's header menus. The same Radix constraint applies to every nested submenu in the app, and these still have no phone branch: FolderMoveSubmenu (rendered in this same sidebar, one tap from the reported screen), SendToInstanceSubmenu, CronRowActions, ChatPage, WebPreviewPanel — 41 DropdownMenuSub* occurrences across 8 files. The cause-level fix is a phone-aware Sub primitive in ui/dropdown-menu.tsx, which cannot be done from inside SubContent (the Sub/SubTrigger/SubContent triple is structural) and so is a larger change than this bug report warrants. Tracked in #6911 rather than left implied.

Evidence

Before / after, phone (390px), dark — the flyout hanging off the right edge vs. the picker inline:

before after

Light theme, same pair:

before after

The dormant-collapse thresholds inline (menu scrolled to its end), and the wide-viewport flyout left untouched:

phone, dormant-collapse desktop, unchanged

Verification

  • website/scripts/capture-sidebar-duration-pickers-narrow.mjs (new) drives the menu open on the real built SPA and throws if any open menu surface falls outside the viewport. It fails on this PR's base with the 192px overflow above, and passes on the fix — so the evidence is a falsification pair, not two pictures.
  • ChatSidebar.narrowDurationPickers.test.tsx (6 tests) pins the switch from both sides: the inline controls exist at phone width and are hidden while Recent is off, and the rows are still aria-haspopup="menu" with nothing of the picker in the DOM on a wide viewport. NativeSelect.chevronGutter.test.tsx (5 tests) pins the gutter and the wrapper routing, including through SimpleSelect itself rather than only the primitive.
  • Mutation-verified, 8 mutations: unconditional mobile branch, mobile branch disabled per picker, width cap removed, gutter style removed, wrapper style ignored, style routed back onto the control, picker un-gated, caption back to a menu item. Each reddens a test.
  • native-select.tsx is shared by 141 call sites, so consumer safety is the full suite: tsc clean, eslint 0 errors (warning count unchanged from base), jscpd 0 clones, vitest run 1,658 files / 26,159 tests passed. The 17 failures in the test:electron lane reproduce identically on unmodified main and are unrelated to this diff.

No linked issue — reported directly in chat with a phone screenshot.

The Recent window picker and the dormant-collapse threshold were nested
Radix submenus. Radix hardcodes a submenu to side="right" and its popper
shifts only on the cross axis, so a submenu never moves along the axis
that is short. At phone width neither side fits beside a menu that
already spans most of the screen: measured at a 390px viewport the Recent
flyout came out 249px wide with 192px of it past the right edge
(--radix-popper-available-width: 57px), and flip's bestFit fallback can
land it off the LEFT edge instead, which is what was reported. No width
or padding tuning recovers that.

On a phone the options now render inline inside the one open menu — the
Recent presets and custom amount under their row, the dormant-collapse
thresholds as chips rather than seven more rows, and the folders of "New
chat in folder" under a caption. The menu also gains a viewport width
cap: Radix sizes the popper wrapper to max-content, so an inline caption
sentence alone took the menu to 510px inside a 390px viewport.

A wide viewport keeps the flyout untouched, at the same measured
geometry.
@buluoray
buluoray requested a review from a team August 30, 2026 03:38
@buluoray
buluoray requested a review from a team as a code owner August 30, 2026 03:38
@buluoray
buluoray requested a review from bolichen97 August 30, 2026 03:38
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of e674e9c9c8faee34880f0df166a926cfca8135f7 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Call-site fix for a primitive-level constraint: three hand-rolled inline branches ship while five submenus — one in this same sidebar — stay broken on phones.

Watch

  • Cause → mechanism → consequence: the Radix side-hardcoding lives in the shared ui/dropdown-menu.tsx Sub primitive, but the fix lands as three bespoke isMobile branches ("41 DropdownMenuSub* occurrences across 8 files… still have no phone branch"). Each branch has a different inline presentation (chips vs. rows vs. gated picker), so the deferred phone-aware Sub primitive now has three divergent precedents to absorb or coexist with, and FolderMoveSubmenu — "one tap from the reported screen" — still overflows for the same phone user who reported this. Make sure the follow-up is actually filed with a link, not just "tracked separately", and treat these three as interim, not the pattern to copy.
  • The "picker while filter off changes nothing" defect is fixed only on the mobile branch; the desktop flyout still opens on hover with Recent inactive, so the same green-chip-no-effect lie survives on wide viewports.

Suggestions

  • Apply the same active-gating (or make preset selection enable the filter) on the wide-viewport flyout, so the fixed defect isn't reintroduced per-modality.

[DESIGN-REVIEWED] e674e9c

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of e674e9c9c8faee34880f0df166a926cfca8135f7 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

First-Principles-Verdict: CONCERNS

The declared phone-picker fix is sound at cause level, but the patch carries ~10 undeclared changes — at least three provably belong to PRs already merged on the base, so the diff base is stale.

What this change ships

Intent: let a phone user actually reach the Recent-window and dormant-collapse pickers, which opened off the screen edge — a FIX.

  1. Recent window picker renders inline on a phone, row loses its chevron — justified (Radix hardcodes submenu side; 192px measured off-screen)
  2. Dormant-collapse thresholds render as inline chips on a phone — justified
  3. "New chat in folder" folders render inline on a phone — justified
  4. Filter menu capped at viewport width — justified (measured 510px in a 390px viewport)
  5. Caller style on touch-path selects moves to the wrapper; chevron gutter made un-overridable — undeclared
  6. Sidebar drag bar and split-view divider bars inset from rounded corners — undeclared, rides along
  7. Dev Fleet failed-provision dismiss now survives reload (new server endpoint) — undeclared, likely not this PR's
  8. Meetings hold speech during agent init and replay it — undeclared; matches merge c815f9c (fix(meetings): hold the opening of a meeting through agent initialization #6649) on the base
  9. Remote-crew proxy allowlist widened to the peer's api/stream — undeclared security-surface change, likely not this PR's
  10. Folder-tree generation on WS frames, channel-post type validation, secrets-vault tip (matches fde6c46/feat(tips): add curated tip surfacing the secrets vault #6822), memory-graph test rework (matches f71d48d/test: make memory graph integration deterministic #6898), notebook single-flight save, i18n gate skip — undeclared; list capped at 10, the change has more

Watch

  • The base's own log names three of these hunks as already-merged PRs (fix(meetings): hold the opening of a meeting through agent initialization #6649, feat(tips): add curated tip surfacing the secrets vault #6822, test: make memory graph integration deterministic #6898), so the diff was cut against a commit older than the merge-base and items 7–10 are main's work, not the author's. Regenerate the diff against the merge-base before holding the author to any of them; if the PR commit really does carry them, items 7–10 are riders in a fix(chat) and each needs its own review.
  • Item 5 is a real cause-level fix (tailwind-merge cannot split a caller's px-* shorthand, so the gutter must be inline), but the description never mentions it, and it changes where style lands for every touch-path SimpleSelect caller (2 pass style today: ArtifactDeployPage.tsx:369, :549).
  • The root cause — a Radix flyout has nowhere to open beside a full-width phone menu — has 5 sibling DropdownMenuSubTrigger sites left unfixed (FolderMoveSubmenu.tsx:86, CronRowActions.tsx:110, SendToInstanceSubmenu.tsx:184, WebPreviewPanel.tsx:950, ChatPage.tsx:356). Per-menu inlining is accepted-and-deferred, not general.

Subtractions

  • Drop wrapperClassName from NativeSelectProps (native-select.tsx:26) — 0 consumers (grep wrapperClassName: defining file only); wrapperStyle (1 consumer, SimpleSelect.tsx:89) is all the seam needs.
  • Unexport CHEVRON_GUTTER (native-select.tsx:37) — 0 non-test consumers; let the test assert the literal '2.25rem'.

[FIRST-PRINCIPLES-REVIEWED] e674e9c

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of e674e9c9c8faee34880f0df166a926cfca8135f7 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- website/src/pages/ChatSidebar.tsx:5382 -- clicking an active desktop Recent trigger disables the filter, then "selectRecentPreset" updates only its window, leaving the list unfiltered -> Fix: re-enable recent when a changed preset is committed.

[GPT-REVIEWED] e674e9c

False positive or not applicable? A repository writer can comment:
/ai-review override gpt e674e9c9c8faee34880f0df166a926cfca8135f7: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed e674e9c9c8faee34880f0df166a926cfca8135f7 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] e674e9c

Verdict parsed from the review's SHA-scoped output markers for commit e674e9c9c8faee34880f0df166a926cfca8135f7.

False positive or not applicable? A repository writer can comment:
/ai-review override fable e674e9c9c8faee34880f0df166a926cfca8135f7: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of e674e9c9c8faee34880f0df166a926cfca8135f7 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The PR's actual surface (ChatSidebar inline pickers, select fixes, screenshots) is well-executed: existing i18n keys reused, captions match the FILTER/SORT BY pattern, the picker is gated so an inactive filter can't show a lying control, chips carry aria-pressed, the chevron-over-text defect is fixed at the component, and desktop is verifiably untouched. The unrelated files in the workflow's diff range are already-merged main PRs, not this branch's work. No finding survives the kill-filter.

UX-Verdict: PASS

Inline pickers match the menu's existing caption/chip vocabulary, the gated Recent picker prevents a lying control, and screenshots prove the phone fix without touching desktop.

[UX-REVIEWED] e674e9c

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 30, 2026
The Recent window picker and the dormant-collapse threshold were nested
Radix submenus. Radix hardcodes a submenu to side="right" and its popper
shifts only on the cross axis, so a submenu never moves along the axis
that is short. At phone width neither side fits beside a menu that
already spans most of the screen: measured at a 390px viewport the Recent
flyout came out 249px wide with 192px of it past the right edge
(--radix-popper-available-width: 57px), and flip's bestFit fallback can
land it off the LEFT edge instead, which is what was reported. No width
or padding tuning recovers that.

On a phone the options now render inline inside the one open menu — the
Recent presets and custom amount under their row, the dormant-collapse
thresholds as chips rather than seven more rows, and the folders of "New
chat in folder" under a caption. The menu also gains a viewport width
cap: Radix sizes the popper wrapper to max-content, so an inline caption
sentence alone took the menu to 510px inside a 390px viewport.

Bringing the picker into view exposed a defect that had been hiding in
the off-screen flyout: NativeSelect draws its chevron as an overlay at
right-3, and `cn` is tailwind-merge, which cannot split a caller's `px-*`
shorthand — so the recency-unit control lost its right gutter and the
arrow painted over the word. The gutter is now an inline constant of the
component, independent of class ordering, and a caller's `style` lands on
the positioning wrapper (the layout box) on both SimpleSelect paths
rather than on the w-full control where a flex rule is inert.

The Recent picker also shows only while the filter is on. Choosing a
window does not enable it, so an always-visible picker turned a chip
green while the list did not change. The dormant-collapse row is a
section caption rather than an inert row styled like the tappable ones.

A wide viewport keeps the flyout untouched, at the same measured
geometry.
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Aug 30, 2026
@buluoray
buluoray enabled auto-merge (squash) August 30, 2026 04:43
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@buluoray
buluoray merged commit 4c5680d into main Aug 30, 2026
83 of 86 checks passed
@buluoray
buluoray deleted the fix/sidebar-filter-submenu-narrow branch August 30, 2026 04:48
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 30, 2026
@buluoray

Copy link
Copy Markdown
Contributor Author

Disposition for the two advisory CONCERNS on e674e9c9c — head is now 51228150c.

Fixed

Design: the "picker while filter off changes nothing" defect survived on wide viewports. Correct, and it was my own asymmetry — I gated the mobile branch and left the flyout alone. Fixing it on one modality is not fixing it. Committing a window now enables the filter wherever the window was chosen (setRecentWindow, one seam that all three paths — preset chip, amount commit, unit change — already route through), gated on a real change so a blur re-committing the identical value is not a hidden toggle. Two mutation-verified guards: removing the enable reddens, and making it unconditional reddens the blur case.

First Principles subtractions. Both confirmed zero-consumer and dropped: wrapperClassName (grep: defining file only) and the CHEVRON_GUTTER export. The second is more than tidying — a test importing that constant could only assert the padding equals the value it was set from, which is true by construction. The test now states 2.25rem itself, and mutating the constant to 2rem reddens three assertions that previously would have sailed through.

Corrected in the description

First Principles is right that item 5 was under-declared, and my own count was wrong. Not 3 other call sites — 20 pass style to SimpleSelect, all affected on touch. The description now says so, explains why the move is an improvement at each (a minWidth bigger than the wrapper pushed the control out from under the chevron; a flex rule on a non-flex-item did nothing), and states the coverage limit plainly: useIsTouchDevice is false under jsdom, so the 26,161 passing tests exercise the Radix path for those 20, not the touch path.

The bot cited ArtifactDeployPage.tsx:369,:549; the two style-passing sites in that file are at :364 and :543, and they are 2 of the 20.

Rebutted — the "~10 undeclared changes / stale diff base" finding

The bot diagnosed this itself ("the diff was cut against a commit older than the merge-base") and it is right. Measured:

diff files net
merge-base..HEAD (true scope) 12 +2951 / −113
origin/main..HEAD (two-dot, current main) 360 +3435 / −37990

main has advanced ~40 commits past my merge-base 2ff4ce819, so a two-dot cut attributes main's own merged work to this branch — which is exactly items 6–10 (#6649, #6822, #6898, the sidebar drag-bar insets, the remote-crew allowlist). None of them appear in merge-base..HEAD. Item 5 is genuinely mine and is handled above; items 1–4 are the declared change.

Standing, not re-litigated

  • Design: "make sure the follow-up is actually filed with a link." Already done — the Known limitation section links Nested submenus open off-screen at phone width (Radix pins them to side=right) #6911, with the five remaining DropdownMenuSubTrigger sites named. Agreed on the framing: these three inline branches are interim, not the pattern to copy, and the issue says so.
  • Long-form dormant chip labels — declined, unchanged. Needs i18n keys in 12 languages for a cosmetic consistency nit beside a non-blocking verdict.

buluoray added a commit that referenced this pull request Aug 30, 2026
Follow-up to #6909. Picking a Recent window stored the window and nothing
else, so on an inactive filter the chip went green and the session list
did not change. #6909 hid the picker while the filter was off, but only
on the phone -- the wide viewport reaches the same picker through the
flyout, so the defect stayed live there.

Picking a window now enables the filter, wherever it was picked. Intent
is decided at the call site rather than by comparing the new window to
the stored one: an explicit pick (preset chip, unit change) enables
unconditionally, because an identical value does not mean the user did
nothing -- DEFAULT_RECENT_WINDOW_MS equals the "1 hour" preset, so a
value-equality gate would swallow exactly the chip a fresh user is most
likely to click. The identical-value test survives only on the amount
field, which commits on blur as well as Enter and is the one path where
"the user did nothing" is real.

The mobile-only picker gate is also gone. It existed because picking a
window did not enable the filter, so a visible picker on an inactive
filter reported an effect it was not having -- this change makes that
reason false, and a gate on one viewport is the per-modality thinking
that caused the defect. Removing it also makes the mobile test leg real:
it used to tap the row to reveal the gated picker, writing the filter
flag before the pick, so its assertion passed without the fix.

Also drops NativeSelect's wrapperClassName (no consumer) and unexports
CHEVRON_GUTTER: a test importing it could only assert the padding equals
the constant it was set from, which is true by construction. The test
now states the value itself, which is what makes a changed gutter redden.
chenmingwei23 pushed a commit that referenced this pull request Aug 30, 2026
Follow-up to #6909. Picking a Recent window stored the window and nothing
else, so on an inactive filter the chip went green and the session list
did not change. #6909 hid the picker while the filter was off, but only
on the phone -- the wide viewport reaches the same picker through the
flyout, so the defect stayed live there.

Picking a window now enables the filter, wherever it was picked. Intent
is decided at the call site rather than by comparing the new window to
the stored one: an explicit pick (preset chip, unit change) enables
unconditionally, because an identical value does not mean the user did
nothing -- DEFAULT_RECENT_WINDOW_MS equals the "1 hour" preset, so a
value-equality gate would swallow exactly the chip a fresh user is most
likely to click. The identical-value test survives only on the amount
field, which commits on blur as well as Enter and is the one path where
"the user did nothing" is real.

The mobile-only picker gate is also gone. It existed because picking a
window did not enable the filter, so a visible picker on an inactive
filter reported an effect it was not having -- this change makes that
reason false, and a gate on one viewport is the per-modality thinking
that caused the defect. Removing it also makes the mobile test leg real:
it used to tap the row to reveal the gated picker, writing the filter
flag before the pick, so its assertion passed without the fix.

Also drops NativeSelect's wrapperClassName (no consumer) and unexports
CHEVRON_GUTTER: a test importing it could only assert the padding equals
the constant it was set from, which is true by construction. The test
now states the value itself, which is what makes a changed gutter redden.
bolichen97 pushed a commit that referenced this pull request Sep 1, 2026
…7327)

The 'New ephemeral chat' entry in the + New caret dropdown was an unconditional Radix flyout submenu. At phone width a flyout pins to the trigger's side and only shifts vertically, so it opened past the left viewport edge and the Incognito/Temporary rows were unreadable. Apply the same treatment the folder picker and crew submenu received in #6909: at phone width, list the two ephemeral rows inline under a caption label; wider viewports keep the flyout unchanged. Rows are hoisted into a shared fragment (folderRows pattern) so the two branches cannot drift. Two mobile-viewport tests added alongside the existing four desktop tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants