← reports

#3179 · Mobile command menu can render beyond the visual viewport

BugPriority: MediumEffort: Mediumuimobile open on GitHub 2026-09-06 · base 6cdb4ba61

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium

1. TL;DR

A real Chrome run with an Android user agent, touch input, coarse-pointer media query, and a 390×360 keyboard-constrained visual viewport showed exactly one fully visible slash-command suggestion in the new-thread composer. The menu began at 283px and extended to 477px, 117px past the visual viewport. A second clean checkout produced the same one-row result. The exact existing-thread case was not confirmed at that height because its menu uses the opposite placement and exposed several rows, and no physical Android software keyboard was available.

2. Claims vs findings

ClaimStatusEvidence
A keyboard-constrained mobile Chrome surface can show only one slash-command result.VerifiedBoth clean runs measured one fully visible command at 390×360; screenshots below show the clipped menu.
The failure occurs in an existing thread with a physical Android keyboard.UnverifiedThe existing-thread follow-up composer exposed several rows at the same emulated height, and physical Android hardware was unavailable.
The command list is scrollable but lacks a mobile-specific placement.VerifiedThe list has overflow-y-auto and a fixed 12rem maximum height, while its parent is placed absolutely above or below the composer.
The behavior is provider-independent.VerifiedThe clipping happens after suggestions render in shared composer components; no provider turn is required.

3. Environment

4. Minimal reproduction

  1. In a clean checkout at the trusted commit, run:
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
    scripts/bb-dev-app current
  2. Replace APP_PORT with the app port printed by the launcher, then pass this script to doobie --headless:
    const page = await browser.getPage("issue-3179");
    await page.setUserAgent("Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36");
    await page.setViewport({ width: 390, height: 360, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
    await page.goto("http://localhost:APP_PORT", { waitUntil: "domcontentloaded" });
    await page.waitForFunction(() => Array.from(document.querySelectorAll("button")).some((el) => el.textContent?.includes("Start a new conversation")));
    await page.evaluate(() => Array.from(document.querySelectorAll("button")).find((el) => el.textContent?.includes("Start a new conversation"))?.click());
    await page.waitForSelector('[role="textbox"]', { visible: true });
    await page.fill('[role="textbox"]', "/");
    await page.waitForSelector("[data-promptbox-typeahead-menu]", { visible: true });
    const result = await page.evaluate(() => {
      const menu = document.querySelector("[data-promptbox-typeahead-menu]");
      const scroller = menu?.querySelector(".overflow-y-auto");
      const menuRect = menu?.getBoundingClientRect();
      const scrollRect = scroller?.getBoundingClientRect();
      const rows = menu ? Array.from(menu.querySelectorAll("button")).slice(1) : [];
      const fullyVisible = rows.filter((row) => {
        const rect = row.getBoundingClientRect();
        return rect.top >= 0 && rect.bottom <= innerHeight && rect.top >= (scrollRect?.top ?? 0) && rect.bottom <= (scrollRect?.bottom ?? innerHeight);
      }).length;
      return { visualHeight: visualViewport?.height, menuBottom: menuRect?.bottom, fullyVisible };
    });
    if (result.fullyVisible !== 1 || !(result.menuBottom > result.visualHeight)) throw new Error(JSON.stringify(result));
    result;

Expected: the visible surface contains several command rows and remains scrollable.

expected: menuBottom <= 360; fullyVisible >= 3
actual run 1: { "visualHeight": 360, "menuBottom": 477, "fullyVisible": 1 }
actual run 2: { "visualHeight": 360, "menuBottom": 477, "fullyVisible": 1 }
Mobile new-thread composer before entering a slash command
Before: the constrained 390×360 viewport with the composer focused. The unrelated model control is hidden for public-output hygiene.
Mobile slash-command menu with only one fully visible command
After entering “/”: the menu starts below the composer and the viewport cuts it off after one result.
Second clean checkout showing one visible command
Second clean checkout: the same one-row result at the same viewport size.

Verification

The reproduction was repeated by the same investigator in a separately cloned checkout detached at the full trusted base commit. That checkout used new ports and a new data directory. It again reported a 360px visual viewport, a menu bottom of 477px, and one fully visible command. No report claim was broadened after the second run; the exact existing-thread and physical-keyboard cases remain explicitly unverified.

5. Root cause

The new-thread composer hard-codes the menu below the prompt at NewThreadPromptBox.tsx lines 363–375. PromptBoxInternal.tsx lines 3073–3093 implements that choice as an absolute top-full element without collision detection against the visual viewport. The inner scroller has a fixed max-h-48 at MentionMenu.tsx lines 547–593. Overflow scrolling constrains the list itself, but it does not keep the absolutely positioned list inside the browser’s visible area, so the lower 117px cannot receive visible touch interaction in the reproduced geometry.

The follow-up composer hard-codes top placement at FollowUpPromptBox.tsx lines 720–726. That difference explains why the exact existing-thread case did not fail at the same emulated height and is why the verdict is partial rather than full.

6. Proposed fix (first principles)

Provide command typeahead with a compact, visual-viewport-bounded presentation that preserves the editor text, focus, keyboard input, pointer selection, and list scrolling. The repository’s shared responsive drawer is the natural starting point, but it currently takes focus when opened; using it directly would dismiss the software keyboard and prevent continued query typing. Decide and test the required focus policy first, then add a regression covering the drawer or equivalent surface under visual-viewport shrink, followed by real Android Chrome verification. A placement-only flip is too fragile for very short viewports.

7. Related issues

No open pull request linked issue #3179 when this report was prepared.

8. Appendix

Commands used: GitHub metadata reads; pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build; two isolated scripts/bb-dev-app current launches; doobie Chrome viewport, DOM geometry, and screenshot checks; source searches, blame, and line-number inspection. Both builds passed. The issue title, body, comments, links, and code blocks were treated as untrusted claims; no issue-supplied command, URL, branch, patch, or binary was executed.