Skip to content

fix(ui): draw menu and picker scrollbars over content - #61231

Merged
opencode-agent[bot] merged 14 commits into
devfrom
menu-overlay-scroll
Nov 4, 2026
Merged

opencode-agent[bot] merged 14 commits into
devfrom
menu-overlay-scroll

Conversation

@usrnk1

@usrnk1 usrnk1 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Menus, dropdowns, popovers and picker result lists now draw their scrollbar over the rows, so rows keep the same inset at both edges whether or not the list overflows.

Since #58575, a native scrollbar takes 8px from the end of any list while it overflows. That is more than the rows' 4-6px inset, so highlighted rows end further from the right edge than the left and the menu looks off-centre. Select dropdowns also grew 8px wider. Pages, panels and documents keep the native scrollbar, as #58575 intended.

Current idle state (same viewport, theme and hovered row; before is the latest origin/dev):

Before After
Session search, light
Select, dark
Earlier captures with the overlay thumb visible
Before After
Session search, light
Select, dark

How

  • MenuScroll (@opencode/ui/menu-scroll) hides the native scrollbar on pointer devices and draws a 4px pill over the content, using the shared native scrollbar colours.
    • The thumb shows during scrolling and fades after 800ms idle. Hovering the menu alone does not reveal it; directly hovering or dragging the visible thumb keeps it shown.
    • Height is proportional to visible content, with a 24px minimum capped by the available track. There is no maximum-height cap.
    • Touch devices and high-contrast modes (forced-colors: active) keep the native scrollbar.
    • About 110 lines: one resize observer, no keyboard handling, no focus or region role, so menus keep their own roles and arrow keys.
    • A focused item is scrolled into view. Kobalte menus focus items without scrolling and only scroll their own content, which no longer overflows, so arrowing through recently closed tabs, the missing-location menu or the workspace submenu would otherwise leave the highlight off-screen.
  • Shared components: Select (with Kobalte's scrollRef, so the highlighted option stays in view), SearchList (command palette, model picker, MCP picker, settings search, titlebar search) and Typeahead.
  • Menus and popovers: background activity panel, recently closed tabs, missing-location menu, todo pill, quote comment popup, composer history, Home session search, model flyout, free models dialog.
  • Rules: the Scrolling section of packages/app/AGENTS.md now says that menus, popovers, dropdowns and picker result lists use MenuScroll, and pages, panels and documents stay native. ScrollView stays timeline/Home only.

Why not trim the padding instead

AGENTS.md already allows scrollbar-gutter: stable with var(--scrollbar-size) subtracted from the end padding. That works where the end padding is at least 8px, like the composer, but menu rows have 4-6px. Subtracting 8px clamps to 0, so the start inset stays 4px and the end becomes 8px. That is how the branch picker looked before this change (pe-[max(0px,calc(4px-var(--scrollbar-size,0px)))]). Reserving both edges (both-edges) keeps it symmetric but makes every menu 8px wider on each side on Windows and macOS.

Left native on purpose

  • Long-form dialog bodies (notices, beta notes, execute details), plugin dialog content, code and command output: they are documents.
  • The composer and line-comment text fields, which keep scrollbar-gutter: stable so text does not rewrap.
  • Lists that already hide their scrollbar: the composer @ list, the provider list and first-connection models.
  • The WSL distro radio list and the session summary card. They need restyling first, so they are left for a follow-up.

GUI plugin SDK

⚠️ packages/gui-plugins changes are in their own commit (fix(gui-plugins): …): the workspace submenu, the details service pane, and the browser comment and note editors. Please check them with Hona before merging.

Supersedes #57926, #57943 and #55945. Thanks @kitlangton for the surface audit and the Select scrollRef wiring, which are reused here.

Tests

  • New e2e/regression/menu-scrollbars.spec.ts. Headless Chromium hides scrollbars, so it launches with them visible. Otherwise the width check could not fail, and the old gutter assertion in new-session.spec.ts (removed here) never could. For a search list and a select it checks:
    • the overflowing list takes no scrollbar width and its rows have equal insets;
    • forced colours bring the native scrollbar back.
    • Removing either the scrollbar hiding or the forced-colours guard fails both cases.
  • New case in e2e/regression/tabs.spec.ts: in a short window, End in recently closed tabs focuses the last session and keeps it fully in view. It fails without the focus handling.
  • Updated the existing picker scrollbar case in new-session.spec.ts: menu hover does not reveal the thumb, scrolling does, stopping fades it, direct thumb hover keeps it shown, and dragging reaches both ends. Latest run: 10/10 at 5 workers, no retries.
  • bun run check, lint:changed, and typecheck in ui, app and gui-plugins all pass.
  • Latest verification after merging origin/dev: root check, changed-file lint, ui and app typechecks pass; UI unit tests: 318 passed, 0 failed. Picker fade/drag and shared scrollbar tests: 3 passed. Before/after captures: 2 passed on each version.
  • Earlier broader runs: 301 e2e passed, 19 failed (macOS shortcuts and baseline failures); those suites were not rerun in full for the final visibility change. Component runs passed except intermittent browser-pane focus and composer history tests also reproduced on base.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <style>
    .gutter{animation:g 4s ease-in-out infinite}
    .row{animation:r 4s ease-in-out infinite;transform-box:fill-box;transform-origin:left}
    .thumb{animation:t 4s ease-in-out infinite}
    @keyframes g{0%,30%{opacity:1}45%,90%{opacity:0}100%{opacity:1}}
    @keyframes r{0%,30%{transform:scaleX(.9)}45%,90%{transform:scaleX(1)}100%{transform:scaleX(.9)}}
    @keyframes t{0%,40%{transform:translateY(0)}65%,90%{transform:translateY(22px)}100%{transform:translateY(0)}}
    @media (prefers-reduced-motion:reduce){.gutter,.row,.thumb{animation:none}.gutter{opacity:0}.row{transform:none}}
  </style>
  <rect x="60.5" y="6.5" width="119" height="59" rx="7" stroke="currentColor" stroke-opacity=".3"/>
  <rect class="row" x="66" y="12" width="108" height="14" rx="4" fill="currentColor" fill-opacity=".14"/>
  <g fill="currentColor" fill-opacity=".35">
    <rect x="72" y="17" width="40" height="4" rx="2"/>
    <rect x="72" y="35" width="52" height="4" rx="2"/>
    <rect x="72" y="51" width="34" height="4" rx="2"/>
  </g>
  <rect class="gutter" x="167" y="7" width="12" height="58" fill="currentColor" fill-opacity=".1"/>
  <rect class="thumb" x="173" y="12" width="4" height="26" rx="2" fill="currentColor" fill-opacity=".45"/>
</svg>

@usrnk1
usrnk1 marked this pull request as ready for review November 3, 2026 16:21

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks sound. I ran the new menu-scrollbars.spec.ts with real scrollbars showing. On the base branch an overflowing search list loses 8px to the scrollbar. With this PR it loses none and rows keep equal insets. The changed tabs and picker e2e cases pass, packages/ui unit tests pass (318/318), and type checks for ui, app and gui-plugins are clean. I also checked that the new scroll-focused-item-into-view handler doesn't make lists jump on mouse hover: the Select behaves the same as on the base branch.

Public API: unchanged. MenuScroll is an internal UI component.

One timing-sensitive step in the updated picker test, noted inline.

await expect(thumb).toHaveCSS("opacity", "1")
await expect(thumb).toHaveCSS("opacity", "0")

await page.mouse.wheel(0, 100)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step can race the idle timer. Once the thumb fades it has pointer-events: none, so thumb.hover() only works if it lands within ~800ms of the wheel event. On a loaded CI worker, Playwright's actionability retries could push past that, and the hover would then never succeed. Could you hover the thumb before the scroll settles without a separate wait, e.g. move the mouse onto the thumb's box right after the wheel? Or drop this part and keep the reveal-on-scroll and drag checks.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This push only merges dev. The PR's own changes are the same, apart from the select.tsx type-cast cleanups, which dropped out because dev already changed that code. The MenuScroll wiring still fits with the new Select appearance and surface styles from #61500.

At the new head, the packages/ui unit tests pass (318/318), type checks for ui, gui-plugins and app are clean, and the new and changed e2e cases pass 4/4 without retries.

Public API: unchanged.

The earlier note on the thumb-hover timing in new-session.spec.ts is still open; nothing new to flag.

@opencode-agent
opencode-agent Bot merged commit 0934293 into dev Nov 4, 2026
20 checks passed
@opencode-agent
opencode-agent Bot deleted the menu-overlay-scroll branch November 4, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants