← reports

#2537 · Ask-User-Question needs some love on mobile

Type: Bug Priority: Medium Effort: not set ask-user-question mobile open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The Ask User Question plugin exceeds a narrow phone viewport and clips its right side. A short viewport also clips the form heading above the visible scroll area.

At 362 by 390 CSS pixels, the card ended at 547.83 pixels. The heading started at -9.25 pixels.

The plugin uses a fixed viewport formula that the native question form removed in pull request 1672. Two host wrappers also keep intrinsic minimum widths.

The source patch kept the card within 346 pixels. It moved the heading below the 48-pixel app header.

2. Claims vs findings

Claim from the issueStatusEvidence
A small phone screen makes four long questions hard to read. Verified The fixed four-question payload made the card end at 547.83 pixels. The Next button ended at 530.83 pixels.
The software keyboard leaves too little visible space. Partially verified A reduced Chrome viewport put the heading at -9.25 pixels. I did not use a real software keyboard.
Landscape rotation and Brave on an iPhone show the same result. Unverified I did not use a real iPhone or Brave. Chrome mobile emulation reproduced the same geometry.
An agent makes the flow difficult to trigger. Refuted One exact Cursor prompt created four long questions twice. Both calls reached the same plugin renderer.
The problem exists in bb 0.40.0. Verified The base package reports version 0.40.0. Current origin/main has no change in the affected files.

3. Environment

4. Minimal reproduction

  1. Start an isolated development app. Wait for the app and server before any browser command.
    scripts/bb-dev-app current
    eval "$(scripts/bb-dev-app env)"
    app_url=$(scripts/bb-dev-app status | awk '/^App:/ {print $2}')
    until curl -fsS "$app_url/" >/dev/null && curl -fsS "$BB_SERVER_URL/health" >/dev/null; do
      sleep 1
    done
    pnpm bb:dev plugin enable ask-user-question
  2. Create a scratch repository and project. Use the connected host identifier from machine list.
    scratch=$(mktemp -d /tmp/bb-2537-repro-XXXXXX)
    git init "$scratch"
    host_id=$(node packages/scripts/dist/commands/run-cli.js machine list --json | jq -r '.[0].id')
    project_json=$(curl -sS -X POST "$BB_SERVER_URL/api/v1/projects" \
      -H 'content-type: application/json' \
      -d "{\"name\":\"qa-2537\",\"source\":{\"type\":\"local_path\",\"path\":\"$scratch\",\"hostId\":\"$host_id\"}}")
    project_id=$(printf '%s' "$project_json" | jq -r .id)
  3. Create a Cursor thread with this exact prompt. Save its identifier.
    thread_json=$(node packages/scripts/dist/commands/run-cli.js thread spawn \
      --project "$project_id" \
      --provider acp-cursor \
      --permission-mode full \
      --prompt 'Call the AskUserQuestion tool now. Ask exactly four questions in one tool call. Each question must have four options with long descriptions. Do not use any other tool. Do not answer the questions.' \
      --json)
    thread_id=$(printf '%s' "$thread_json" | jq -r .id)
    node packages/scripts/dist/commands/run-cli.js thread interactions list "$thread_id" --json
    Wait until the last command shows one pending plugin interaction.
  4. Set fixed tab labels. This step removes provider text as a reproduction variable.
    data_dir=$(scripts/bb-dev-app status | awk -F': ' '/^Data dir:/ {print $2}')
    interaction_id=$(node packages/scripts/dist/commands/run-cli.js \
      thread interactions list "$thread_id" --json | jq -r '.[0].id')
    sqlite3 "$data_dir/bb.db" "UPDATE pending_interactions SET payload=json_set(\
    payload,'$.data.questions[0].shortLabel','First question',\
    '$.data.questions[1].shortLabel','Second question',\
    '$.data.questions[2].shortLabel','Third question',\
    '$.data.questions[3].shortLabel','Fourth question') WHERE id='$interaction_id';"
  5. Set threadUrl in the saved test. Run the exact browser command.
    doobie -b issue2537-revise --headless --timeout 60 run \
      /tmp/bb-reports/issues/2537/repro/measure-mobile-overflow.js

    Saved test: measure-mobile-overflow.js.

Expected

banner.left >= 0
banner.right <= viewport.width
heading.top >= 48
Next.right <= viewport.width
Next.bottom <= viewport.height

Actual

{
  "viewport": {
    "width": 362,
    "height": 390
  },
  "banner": {
    "left": 16,
    "right": 547.828125,
    "width": 531.828125
  },
  "heading": {
    "top": -9.25,
    "bottom": 12.75
  },
  "next": {
    "left": 475.5,
    "right": 530.828125,
    "top": 312.75,
    "bottom": 344.75
  }
}
Error: LAYOUT ASSERTIONS FAILED:
banner.right 547.828125 exceeds viewport.width 362
heading.top -9.25 is less than app header bottom 48
next.right 530.828125 exceeds viewport.width 362
    at measure-mobile-overflow.js:78:18
[page issue-2537-repro] http://localhost:14456/projects/proj_rftsbtzc27/threads/thr_bb6wm784dy "Ask four clarification questions"
[page:issue-2537-repro] warn: [DEPRECATED] loadable is deprecated and will be removed in v3. Please use a userland util with the `unwrap` util: https://github.com/pmndrs/jotai/pull/3217

The command returned status 1. The block above is the raw browser output without added labels.

The reporter's narrow iPhone screenshot with clipped question content
The reporter image shows the card and tab row beyond the right edge.
The reproduced question form at 362 by 658 CSS pixels
The 362-pixel base view clips the final tab and the Next button edge.
The reproduced form at a 362 by 390 keyboard-size viewport
The keyboard-size view hides the card title and plugin source above the app header.
The source-patched form within the narrow keyboard-size viewport
The source patch keeps the heading, tabs, question, and action row within the viewport.

Saved test source

// Run with: doobie -b issue2537-revise --headless --timeout 60 run measure-mobile-overflow.js
// Set this URL to the thread that contains the pending form.
const threadUrl =
  "http://localhost:14456/projects/proj_rftsbtzc27/threads/thr_bb6wm784dy";

const page = await browser.getPage("issue-2537-repro");
await page.setViewport({
  width: 362,
  height: 390,
  deviceScaleFactor: 2,
  isMobile: true,
  hasTouch: true,
});
await page.goto(threadUrl, {
  waitUntil: "domcontentloaded",
  timeout: 30_000,
});
await page.waitForSelector("text/4 questions", {
  visible: true,
  timeout: 30_000,
});

const result = await page.evaluate(() => {
  const banner = document.querySelector('[data-testid="plugin-request-banner"]');
  const heading = [...document.querySelectorAll("h3")].find(
    (element) => element.textContent?.trim() === "4 questions",
  );
  const next = [...document.querySelectorAll("button")].find(
    (element) => element.textContent?.trim() === "Next",
  );
  if (!banner || !heading || !next) {
    throw new Error("The pending question form is not present.");
  }
  const bannerRect = banner.getBoundingClientRect();
  const headingRect = heading.getBoundingClientRect();
  const nextRect = next.getBoundingClientRect();
  return {
    viewport: { width: innerWidth, height: innerHeight },
    banner: {
      left: bannerRect.left,
      right: bannerRect.right,
      width: bannerRect.width,
    },
    heading: { top: headingRect.top, bottom: headingRect.bottom },
    next: {
      left: nextRect.left,
      right: nextRect.right,
      top: nextRect.top,
      bottom: nextRect.bottom,
    },
  };
});

console.log(JSON.stringify(result, null, 2));
const failures = [];
if (result.banner.left < 0) {
  failures.push(`banner.left ${result.banner.left} is less than 0`);
}
if (result.banner.right > result.viewport.width) {
  failures.push(
    `banner.right ${result.banner.right} exceeds viewport.width ${result.viewport.width}`,
  );
}
if (result.heading.top < 48) {
  failures.push(`heading.top ${result.heading.top} is less than app header bottom 48`);
}
if (result.next.right > result.viewport.width) {
  failures.push(
    `next.right ${result.next.right} exceeds viewport.width ${result.viewport.width}`,
  );
}
if (result.next.bottom > result.viewport.height) {
  failures.push(
    `next.bottom ${result.next.bottom} exceeds viewport.height ${result.viewport.height}`,
  );
}
if (failures.length > 0) {
  throw new Error(`LAYOUT ASSERTIONS FAILED:\n${failures.join("\n")}`);
}
({ status: "ALL LAYOUT ASSERTIONS PASSED", result });

5. Root cause

A fixed height repeats a known mobile defect

The plugin form sets max-height: calc(100dvh - 6rem). This formula ignores the app header, safe area, sibling footer content, and keyboard.

<div className="flex max-h-[calc(100dvh-6rem)] min-h-0 flex-col ...">
  ...
  <div className="min-h-0 ... overflow-y-auto ...">
  ...
  <div className="mt-3 flex shrink-0 ...">

Source: plugin form height and scroll layout.

The native form already measures the sticky footer. It applies the available height from the actual scroll port.

Sources: native form height and height measurement hook.

Pull request 1672 removed the same fixed formula from the native form. The plugin copy kept the old formula from pull request 858.

Host wrappers keep intrinsic minimum widths

The composer uses a 330-pixel grid track. Its plugin request child keeps min-width: auto and grows to 531.83 pixels.

Source: plugin request wrapper.

The host also wraps the slot in a fieldset. Chrome gives that fieldset min-width: min-content.

Source: plugin card and host fieldset.

ElementMeasured widthComputed minimum
Composer grid330 px0px
Plugin request child531.83 pxauto
Host fieldset497.83 pxmin-content

The plugin tab row supports horizontal scroll. The host widths prevent that row from receiving the smaller width.

Source: question tab row.

Why the visible failure follows

The sticky footer becomes taller than the 342-pixel scroll port. Its top moves behind the 48-pixel app header.

The intrinsic child also exceeds the grid track. The thread scroll area hides horizontal overflow, so the right side cannot become visible.

6. Proposed fix (first principles)

  1. Make the host own the available-height policy for every plugin interaction. Use the existing scroll-port measurement and its ResizeObserver.
  2. Apply the measured maximum height to a flexible plugin card section. Give that section min-h-0 min-w-0.
  3. Add min-w-0 to the plugin request grid child.
  4. Set the host fieldset to display: contents. It can still propagate the disabled state without a layout box.
  5. Keep the plugin's tab strip and action row fixed. Let only the question body scroll.
  6. Add a 362 by 390 browser test. Check the card, heading, tab row, and action row boundaries.

Do not add only overflow: hidden. That change hides the action buttons but does not remove the fieldset minimum.

The saved proposed fix patch implements this host-owned design.

The 362-by-390 browser test passed after the patch. The app type check and four existing composer tests also passed.

This fix changes only app layout. It does not change the host-daemon protocol.

7. Related issues

8. Appendix

Artifacts

Source patch proof

I applied the saved source patch. The card then ended at 346 pixels.

The heading moved to 61 pixels. The Next button ended at 329 pixels and 345 pixels vertically.

Commands run

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
gh issue view 2537 --comments
scripts/bb-dev-app current
pnpm bb:dev plugin enable ask-user-question
node packages/scripts/dist/commands/run-cli.js thread spawn ...
node packages/scripts/dist/commands/run-cli.js thread interactions list ... --json
sqlite3 "$data_dir/bb.db" "UPDATE pending_interactions SET payload=json_set(...) ..."
doobie -b issue2537-revise --headless --timeout 60 run measure-mobile-overflow.js
git fetch origin main
git diff ad79bbb5ec90..origin/main -- <affected paths>

Verification