#4168 · Repeated selection work across transcript messages

Bug · Priority: Medium · Effort: Medium · ui, perf · 2026-09-23

Issue · Base: eee15e3f7a8a7ea3cb5d69e8207f5f0b9a356e7e

PARTIALLY REPRODUCED · Root-cause confidence: medium (high for the selection text-read mechanism alone).

1. TL;DR

The reported symptom is sluggish interaction with multiple streaming transcripts. A dependency-free probe of the unchanged main-branch selection reporter confirms that it reads the entire selection once per intersecting message. For a selection growing with message count this produces quadratic aggregate text processing. This verifies one plausible contributor, not the reported click, drag, CPU or multi-second freeze measurements. No packaged desktop benchmark was run.

2. Claims vs findings

ClaimFinding
Repeated selection workVerified for text: 100 intersecting messages cause 100 full text reads. Rectangle reads are conditional; this multi-message rejection scenario makes zero rectangle reads. The blanket rectangle claim is not supported by this run.
Desktop containment absentStatic finding: the containment classes use max-md. Layout-object counts and per-frame cost remain unverified.
Focus navigation rerenders every paneLocation and navigation hook subscriptions are present in trusted source. Actual render counts and latency are unverified.
Sidebar-wide work per rowUnverified at runtime; no large-sidebar benchmark performed.
Reported desktop timing and CPU valuesUnverified. A modeled selection API is not a browser performance measurement.

3. Environment

Darwin arm64; Node v22.22.3. Two clean detached worktrees at the base SHA fetched from the target repository main branch; origin/main matched that SHA. No provider, server, user data, browser, ports or application data directory used.

The normal frozen install was attempted but pnpm exited with MODULE_NOT_FOUND for its configured pnpm.cjs entrypoint. Consequently the full build and Vitest suite could not run. No dependency was added. The probe uses Node type stripping and executes the actual source prefix containing reportAllInstances, with modeled Selection and message-node objects. React mounting and browser layout are outside its scope.

4. Minimal reproduction

  1. Fetch the trusted repository and create two clean checkouts at eee15e3f7a8a7ea3cb5d69e8207f5f0b9a356e7e.
  2. Save reproduce.mjs outside both checkouts.
  3. Run node reproduce.mjs /path/to/first-checkout, then node reproduce.mjs /path/to/second-checkout using Node 22.22.3.

Efficiency expectation: at most one shared full-selection text read per report. Actual output on both checkouts:

{"messages":1,"textReads":1,"rectReads":0,"processedCharacters":100}
{"messages":50,"textReads":50,"rectReads":0,"processedCharacters":250000}
{"messages":100,"textReads":100,"rectReads":0,"processedCharacters":1000000}
CONFIRMED: range-wide text is reread per intersecting message; multi-message rejection path does not read rectangles.

The probe asserts the observed current behavior; it is a diagnostic reproducer, not a passing regression assertion of desired efficiency. processedCharacters counts the length returned by the instrumented Selection.toString call; it is a work proxy, not an allocation or elapsed-time measurement. The modeled range intersects every message while its boundaries are outside individual messages, matching the cross-message rejection path.

Full reproduction source
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const root = resolve(process.argv[2]);
const source = readFileSync(join(root, 'apps/app/src/components/thread/timeline/SelectableMessageProse.tsx'), 'utf8');
const prefix = source.slice(source.indexOf('\n') + 1, source.indexOf('export function SelectableMessageProse('));
const harness = `
let reads = 0;
let rectReads = 0;
let work = 0;
let selectedText = '';
const boundary = {};
const range = { commonAncestorContainer: boundary, intersectsNode: () => true, getClientRects: () => { rectReads++; return {length: 1, item: () => ({width: 1, height: 1})}; } };
const selection = { isCollapsed: false, anchorNode: boundary, focusNode: boundary, rangeCount: 1, getRangeAt: () => range, toString: () => { reads++; work += selectedText.length; return selectedText; } };
globalThis.window = { getSelection: () => selection };
for (const count of [1, 50, 100]) {
  proseInstances.clear();
  reads = 0; rectReads = 0; work = 0;
  selectedText = 'x'.repeat(count * 100);
  for (let i = 0; i < count; i++) {
    proseInstances.add({ node: { contains: () => false, textContent: 'individual message' }, pendingReportAnchor: null, multiClickTimer: null, hadSelection: false, onSelectRef: { current: () => {} } });
  }
  reportAllInstances();
  console.log(JSON.stringify({messages: count, textReads: reads, rectReads, processedCharacters: work}));
  if (reads !== count) throw new Error('Observed behavior changed');
}
console.log('CONFIRMED: range-wide text is reread per intersecting message; multi-message rejection path does not read rectangles.');
`;
const dir = mkdtempSync(join(tmpdir(), 'selection-probe-'));
try {
  const file = join(dir, 'probe.ts');
  writeFileSync(file, prefix + harness);
  const result = spawnSync(process.execPath, ['--experimental-strip-types', '--disable-warning=ExperimentalWarning', file], { encoding: 'utf8' });
  process.stdout.write(result.stdout);
  process.stderr.write(result.stderr);
  process.exitCode = result.status ?? 1;
} finally { rmSync(dir, {recursive: true, force: true}); }

5. Root cause

reportAllInstances prefilters intersection but then invokes reportInstanceSelection separately for each message. readSelectionWithinNode reads selection.toString on each call, including rejected cross-message selections, and the boundary-spill helper normalizes the shared text again. With N messages and selection length proportional to N, the repeated full-text processing is O(N²). Geometry reads occur only after acceptance, so repeated getClientRects is not universal.

for (const instance of proseInstances) { ... reportInstanceSelection(instance); }
const text = selection.toString().trim();

Containment classes are limited to max-md. Timeline location subscription and split navigation subscription support further investigation, but this report does not attribute measured latency to them.

6. Proposed fix

Read and normalize shared selection text lazily once for each report pass, retaining per-message acceptance and anchor behavior. Validate collapsed selections, boundary spill, multiple messages and pointer timing. Then benchmark the packaged app with synthetic streaming data to determine the impact on real interactions.

No new fix PR: open PRs #4169, #4170 and #4171 already link to this issue.

7. PR review

Metadata and diffs were read as untrusted evidence. No PR branch was checked out or executed; no PR test results were independently verified.

#4169

Static review: lazy per-report text, normalized text and rectangle caches address the verified repeated-read mechanism. Runtime safety remains unverified; no merge recommendation.

#4170

Static review: stabilizes navigation and callbacks and centralizes search-location handoff. This targets the separate focus hypothesis; render counts and search navigation still require runtime validation.

#4171

Static review: removes the width restriction, adds feature gating and settles scroll jumps. This targets the containment hypothesis; browser scroll anchoring, selection and jump behavior still require live verification.

8. Related issues

Repository search found #1616 (mobile performance) and #2693 (desktop event-timing stalls). Neither establishes a duplicate of this selection mechanism.

9. Verification

The same agent repeated the exact probe in a second clean detached checkout at the recorded base commit. Both exited 0 and produced identical results for 1, 50 and 100 messages. No source modifications were made in either checkout. The report explicitly narrows the rectangle claim and leaves desktop timing unverified; the second run supports this partial verdict, not an end-to-end reproduction.

10. Appendix

First output · Second output · Probe

Commands: git fetch origin main; git fetch https://github.com/get-bb/bb main; git rev-parse origin/main; git worktree add --detach <first/second> eee15e3f7a8a7ea3cb5d69e8207f5f0b9a356e7e; pnpm install --frozen-lockfile --prefer-offline (failed before build); node reproduce.mjs <first/second>; targeted source reads; gh issue and PR metadata reads; gh pr diff 4169/4170/4171. No live processes or temporary application data remained.

Trust boundary: all issue and PR content was treated as untrusted claims. No supplied code, attachments or branches were executed.

> AGENT GENERATED