← reports

#3182 · Mobile landscape sidebar leaves one thread row

Bug Medium Effort: Medium mobile open on GitHub 2026-09-06 · base 6cdb4ba61

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

At a 740×360 mobile landscape viewport, the open sidebar gives its thread list only 61 px, enough for one visible thread row. The navigation block takes its full 182 px intrinsic height before the flexible thread region is laid out. Search is already behind More in the reproduced default state, and hiding another optional navigation row increases the thread region to only 103 px. The layout has no short-viewport rule that reserves thread-list height or changes disclosure based on available vertical space.

2. Claims vs findings

ClaimStatusEvidence
Landscape mobile height leaves very little sidebar space for threads.VerifiedBoth isolated runs measured a 61 px scrollable thread region in a 360 px-high viewport, with one visible thread row.
Moving navigation items into More mitigates but does not guarantee useful thread space.VerifiedSearch was already disclosed under More. Hiding Automations as a second optional row raised the thread region from 61 px to 103 px, still less than two complete 60 px fixture rows.
Navigation disclosure responds to preferences rather than available height.VerifiedThe preference calculation uses stored visibility and one default-hidden key; it reads no viewport-height or container-size input.
The problem remains on trusted main.VerifiedGitHub main and both checkouts resolved to 6cdb4ba6125514b7660332cf311037bc09c82b0e.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base commit, run pnpm install --frozen-lockfile --prefer-offline, then pnpm exec turbo run build.
  2. Launch a fresh checkout-derived dev instance with scripts/bb-dev-app current.
  3. Seed one project and 18 threads with pnpm seed:perf -- --data-dir <fresh-dev-data-dir> --projects 1 --threads 18 --events 36 --seed 3182.
  4. Run the saved doobie script from the repository root, substituting the printed app URL if the checkout assigns a different port: doobie --headless -b issue-3182 < issues/3182/repro/mobile-landscape-repro.js.

Expected: the thread list retains room for at least two complete fixture rows while the remaining navigation stays reachable.

Actual:

{
  "viewport": { "width": 740, "height": 360 },
  "navigation": {
    "y": 48,
    "height": 182,
    "bottom": 230,
    "flexShrink": "0",
    "overflowY": "visible"
  },
  "threadContent": {
    "y": 247,
    "height": 61,
    "bottom": 308,
    "flexShrink": "1",
    "overflowY": "auto"
  },
  "visibleThreadRows": 1
}
The 320 pixel-wide mobile sidebar at a 360 pixel viewport height, with four navigation rows and only one clipped thread row above the footer
The cropped image contains only the public sidebar surface. Four 40 px navigation rows occupy the upper half; below the divider, only the project header and one clipped thread row fit before the footer.

Repro file: mobile-landscape-repro.js

Reproduction test source
const page = await browser.getPage("issue-3182");
await page.setViewport({
  width: 740,
  height: 360,
  deviceScaleFactor: 1,
  isMobile: true,
  hasTouch: true,
});
await page.goto("http://localhost:18166");
await page.waitForLoad();
await page.waitForSelector("[data-sidebar='trigger']", { timeout: 15000 });
const state = await page.$eval(
  "[data-sidebar='panel']",
  (element) => element.getAttribute("data-state"),
);
if (state !== "open") {
  await page.click("[data-sidebar='trigger']");
}
await page.waitForSelector("[data-sidebar='panel'][data-state='open']", {
  timeout: 5000,
});
await new Promise((resolve) => setTimeout(resolve, 350));
const result = await page.evaluate(() => {
  const measure = (selector) => {
    const element = document.querySelector(selector);
    if (!(element instanceof HTMLElement)) throw new Error(`Missing ${selector}`);
    const rect = element.getBoundingClientRect();
    const style = getComputedStyle(element);
    return {
      y: rect.y,
      height: rect.height,
      bottom: rect.bottom,
      flexShrink: style.flexShrink,
      overflowY: style.overflowY,
    };
  };
  const navigation = measure("[data-testid='plugin-nav-sidebar-items']");
  const threadContent = measure("[data-sidebar='content']");
  const content = document.querySelector("[data-sidebar='content']");
  const contentRect = content.getBoundingClientRect();
  const visibleThreadRows = [...content.querySelectorAll("a[href*='/threads/']")].filter(
    (element) => {
      const rect = element.getBoundingClientRect();
      return rect.bottom > contentRect.top && rect.top < contentRect.bottom;
    },
  ).length;
  return { viewport: { width: innerWidth, height: innerHeight }, navigation, threadContent, visibleThreadRows };
});
if (result.threadContent.height >= 120) {
  throw new Error(`Expected less than two 60px thread rows, received ${result.threadContent.height}px`);
}
result;

5. Verification

The same agent created a second clean detached checkout at the recorded base SHA, repeated the frozen install and full Turbo build, launched new checkout-derived ports and a new data store, seeded the same deterministic fixture, and reran the mobile geometry check with a separate browser profile. After built-in plugins finished loading, the second run again measured navigation at 182 px and thread content at 61 px. No report claims were corrected after the second run.

6. Root cause

The normal navigation list is explicitly non-shrinking and renders every preference-visible row plus the More row. See PluginNavSidebarItems.tsx lines 431–469:

<div
  className="shrink-0 space-y-0.5 px-2 py-2 ..."
>
  {visible.map(...)}
  {hidden.length > 0 ? <SidebarNavigationMoreRow ... /> : null}
</div>

The app places that navigation before a non-shrinking divider and a flexible, scrollable thread content region. See AppSidebar.tsx lines 225–275 and sidebar.tsx lines 1632–1665. Flexbox therefore preserves the navigation’s intrinsic height and gives the thread list only the remainder after the top reserve, divider, and footer.

The disclosure calculation accepts stored order, stored visibility, and default-hidden keys, but no height input. Only Search is hidden by default. See pluginNavSidebarOrder.ts lines 8–24 and lines 66–100. This explains why rotating to a short viewport changes neither the set nor height of visible navigation rows.

7. Proposed fix (first principles)

Define a short-viewport product invariant for minimum thread-list height, then make the navigation honor it. Two viable implementations are to move lower-priority rows into More as available height falls, or cap the navigation region and make it independently scrollable while reserving the agreed thread-list minimum. Coverage should measure actual layout at representative compact heights, include user-reordered and plugin-contributed rows, keep the active destination reachable, and verify plugin navigation replacements. The exact reserve and priority policy are intentionally not selected here because they are product decisions.

8. Related issues

#2731 concerns an optional cap within each project’s thread rows. It does not address navigation chrome consuming height before the overall thread region, so it is related but not a duplicate.

9. Appendix

Commands run

git fetch origin main
gh api repos/get-bb/bb/commits/main --jq .sha
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current
pnpm seed:perf -- --data-dir <fresh-dev-data-dir> --projects 1 --threads 18 --events 36 --seed 3182
doobie --headless -b slopcop-3182-a
doobie --headless -b slopcop-3182-b

Trust handling

The issue title, body, comments, links, code blocks, and suggestions were treated as untrusted claims. No linked external URL, script, patch, binary, branch, test, or pull request code was fetched or run. All executed application code came from the trusted GitHub main SHA or changes made solely to the report artifact.

Limits

This verifies responsive web layout in Chrome mobile emulation. It does not establish iOS Safari-specific rendering behavior, but the reproduced height allocation comes from measured DOM boxes and the same CSS/flex structure on trusted main.