Skip to content

desktop: agent select menu overflows the window with many agents — clipped instead of scrollable #54374

Open

Description

@ehealthwares

Description

In the desktop UI, the agent select in the composer footer opens upward (placement="top-start"). When more agents are configured than fit between the footer and the top of the window, the menu is not clamped to the window height: it renders past the top edge and is clipped by the app window instead of being scrollable. The agents above the cut are unreachable — there is no scrollbar, the mouse wheel / trackpad does not scroll the list, and keyboard navigation does not move the view either.

This makes a large share of configured agents impossible to select.

Steps to reproduce

  1. Configure many agents (I have 31 project agents plus the built-ins; with roughly 15+ the list already overflows).
  2. Open any session.
  3. Click the agent selector at the bottom left of the composer (shows the current agent, e.g. "Shared-Lead").
  4. The dropdown opens upward and the list extends past the top of the window; the items above the cut are clipped by the app frame and cannot be reached.

Expected behaviour: the list is clamped to the available height and scrolls (like the model/provider select lists do), with the currently selected agent scrolled into view when opened.

Environment

  • UI: OpenCode Desktop 1.18.34 (macOS 27.0.0, arm64)
  • Also verified as still present on the latest release v1.18.35 and on the dev branch source
  • OS: macOS 27.0.0 (arm64)

Root cause (verified in the shipped bundle and current source)

  • The composer agent/model/variant select is ComposerEditorSelect in packages/app/src/composer/editor/editor.tsx (shipped as PromptInputV2Select → MenuV2 in 1.18.34). It renders Menu with gutter={6} modal={false} placement="top-start" and no height constraint on Menu.Content.
  • [data-component="menu-v2-content"] in packages/ui/src/navigation/menu/menu.css declares no max-height and no overflow-y, so the floating surface grows unbounded.
  • corvu's popper only applies maxWidth/maxHeight when fitViewport is enabled, and its default is fitViewport: false.
  • Contrast with select-v2-content, whose listbox has max-height: 12rem; overflow-y: auto — select-style lists scroll correctly, menu-style lists (agent, model, variant, command menus) do not.

Suggested fix

Give menu-style floating surfaces a viewport clamp, e.g.:

  • pass fitViewport for the composer selects, and/or
  • add max-height: var(--kb-popper-content-available-height) plus overflow-y: auto to [data-component="menu-v2-content"], and
  • scroll the selected item into view when the menu opens.

Related

Note: the screenshot was intentionally not attached, per the reporting guidelines — the description above contains the details needed to reproduce.

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. You reported this on v1 (1.18.34), but v2 has the same problem, so I reproduced it there.

    Setup: a project with 31 primary agents in .opencode/agent/, the v2 web UI (same app code as desktop) in a 600px-tall window. In a new session I clicked Choose agent in the composer.

    Result: the menu opens upward to 928px tall, with its top 547px above the window. 20 of the 33 agents are cut off. The menu has no max-height and doesn't scroll with the wheel. This happens on 2.0.26 and still happens on the latest dev (7b3d4ce). The relevant code is the composer select's Menu (placement="top-start", no height limit) and menu-v2-content in menu.css (no max-height, no overflow-y), as you found.

    On v2, the agent selector only appeared after I turned on Settings → General → "Show custom agents".

    The recording and screenshot below show the clipped menu, and the script below reproduces it with Playwright.

    Related: #46987 (same symptom, on v1), #48789 (model selector should fit the screen), #50175, #41726, #43668.

    recording.mp4

    Screenshot

    Reproduction script (repro-web.ts)
    // Reproduces #54374 in the v2 web UI (same app as desktop) with Playwright (bun add playwright; bunx playwright install chromium).
    // Setup:
    //   mkdir -p proj/.opencode/agent && cd proj && git init
    //   for i in $(seq -w 1 31); do printf -- "---\ndescription: Test agent $i\nmode: primary\n---\nYou are agent $i.\n" > .opencode/agent/agent-$i.md; done
    //   opencode serve --hostname 127.0.0.1 --port 4096   # prints the server password
    // Run: PW=<server password> bun repro-web.ts
    // Prints the menu's bounding box: top is negative (above the window) and items above the viewport cannot be scrolled to.
    import { chromium } from "playwright"
    const pw = process.env.PW!
    const out = process.env.OUT ?? "/workspace/repro"
    const b = await chromium.launch()
    const ctx = await b.newContext({ viewport: { width: 1280, height: 600 }, recordVideo: { dir: out + "/video", size: { width: 1280, height: 600 } } })
    const p = await ctx.newPage()
    await p.goto("http://127.0.0.1:4096/"); await p.waitForTimeout(3000)
    await p.getByText("Use password").click(); await p.waitForTimeout(500)
    await p.fill("input[name=server]", "http://127.0.0.1:4096")
    await p.fill("input[name=password]", pw)
    await p.keyboard.press("Enter"); await p.waitForTimeout(4000)
    // Show custom agents in the composer (Settings > General > Show custom agents)
    await p.getByRole("button", { name: "Settings" }).first().click(); await p.waitForTimeout(1500)
    await p.locator('[data-action="settings-show-custom-agents"] [role="switch"], [data-action="settings-show-custom-agents"] input, [data-action="settings-show-custom-agents"] label').first().click({ force: true }); await p.waitForTimeout(800)
    await p.screenshot({ path: out + "/settings.png" })
    await p.keyboard.press("Escape"); await p.waitForTimeout(1000)
    await p.getByText("Add project").click(); await p.waitForTimeout(1500)
    await p.getByText("Select folder").click(); await p.waitForTimeout(4000)
    await p.getByText("New session").click(); await p.waitForTimeout(3000)
    await p.screenshot({ path: out + "/s5.png" })
    console.log(await p.locator("button[aria-label]").evaluateAll(e=>e.map(x=>x.getAttribute("aria-label"))))
    const trigger = p.locator('button[aria-label="Choose agent"]').first()
    console.log("trigger count", await trigger.count())
    await trigger.click(); await p.waitForTimeout(1500)
    await p.screenshot({ path: out + "/menu-open.png" })
    const info = await p.evaluate(() => {
      const el = document.querySelector('[data-component="menu-v2-content"]') as HTMLElement | null
      if (!el) return null
      const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
      const items = [...el.querySelectorAll('[role="menuitemradio"]')].map(i => (i as HTMLElement).getBoundingClientRect().top)
      return { top: r.top, bottom: r.bottom, height: r.height, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, maxHeight: cs.maxHeight, overflowY: cs.overflowY, viewport: innerHeight, items: items.length, itemsAboveViewport: items.filter(t => t < 0).length }
    })
    console.log(JSON.stringify(info))
    // Try to scroll the menu with the wheel
    await p.mouse.move(200, 100); await p.mouse.wheel(0, -2000); await p.waitForTimeout(800)
    const after = await p.evaluate(() => (document.querySelector('[data-component="menu-v2-content"]') as HTMLElement)?.getBoundingClientRect().top)
    console.log("menu top after wheel", after)
    await p.screenshot({ path: out + "/menu-after-wheel.png" })
    await ctx.close(); await b.close()

    Run it: bun repro-web.ts

  2. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Confirmed against current dev. [data-component="menu-v2-content"] in packages/ui/src/navigation/menu/menu.css declares neither max-height nor overflow-y, and the composer agent select (ComposerEditorSelect, packages/app/src/composer/editor/editor.tsx) renders <Menu.Content> with no height constraint, so the floating surface grows unbounded.

    Two findings that make this smaller than it looks:

    • Kobalte already writes --kb-popper-content-available-height onto the popper positioner unconditionally (@kobalte/core/src/popper/popper-root.tsx, set before the fitViewport branch), so the clamp does not depend on enabling fitViewport.
    • The repo already applies exactly this pattern to a menu-v2-content: packages/app/src/settings/settings.css gives .settings-mobile-menu[data-component="menu-v2-content"] a max-height: var(--kb-popper-content-available-height) plus overflow-y: auto.

    Plan: add the viewport clamp and overflow-y: auto to the shared menu content rule so every menu-style surface scrolls like the select lists already do, then add a geometry regression case to the composer keeper suite (packages/app/e2e/regression/composer.spec.ts) asserting the menu stays inside the viewport and its content is reachable by scrolling. I'll look at scrolling the selected item into view on open as a follow-up in the same PR if it stays small.

    Happy to take a different shape if you'd rather keep the change scoped to the composer rather than the shared rule.

  3. ehealthwares commented on Oct 10, 2026

    @ehealthwares
    Author

    Fix submitted in #54391 (fix(ui): clamp menu content to available height so long lists scroll). It clamps [data-component="menu-v2-content"] to the height corvu's popper already publishes as --kb-popper-content-available-height and makes the surface scrollable, so the agent/model/variant selects (and other menu surfaces) stay reachable when the list is taller than the window. Includes a LongList Storybook fixture with 32 agents to reproduce.

  4. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    Standing down on this one — #54391 (fix(ui): clamp menu content to available height so long lists scroll) covers it, and it covers it better than my plan did.

    For the record, what I had confirmed independently before opening it into a conflict:

    • --kb-popper-content-available-height is published unconditionally on the floating element by Kobalte's popper root (@kobalte/core/src/popper/popper-root.tsx, set just before the fitViewport branch), so the clamp does not depend on enabling fitViewport — which matches what fix(ui): clamp menu content to available height so long lists scroll #54391 relies on. (The issue text and the PR body both say "corvu's popper"; the publisher is Kobalte, and the kb- prefix is the tell. Worth correcting in the comment so the next reader looks in the right place — it changes nothing about the fix.)
    • [data-component="menu-v2-content"] in packages/ui/src/navigation/menu/menu.css declares neither max-height nor overflow-y, while the select listbox caps at 12rem with overflow-y: auto — so the contrast in the report is real.
    • packages/app/src/settings/settings.css already applies exactly this pattern to a menu-v2-content (.settings-mobile-menu), so this is following an existing convention rather than inventing one.

    The part I had missed, and #54391 caught, is [data-appearance="standard"]'s overflow: hidden — that declaration wins over a new overflow-y at equal specificity, so a vertical-only clamp silently does nothing for standard menus. Narrowing it to overflow-x: hidden is the right call.

    No competing patch from me. Thanks for picking it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions