Skip to content

fix(mobile): bound Android text spans per line in chat code and PR diff - #6197

Merged
iscekic merged 1 commit into
mainfrom
kwf/android-user-perceived-anrs-5740
Sep 17, 2026
Merged

iscekic merged 1 commit into
mainfrom
kwf/android-user-perceived-anrs-5740

Conversation

@iscekic

@iscekic iscekic commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Changelog for users

  • The Android chat transcript and the tool detail sheet no longer stall while a long syntax-highlighted code block renders.
  • A long transcript with many fenced code blocks scrolls without dropping frames.
  • A long code fence paints its first screenful immediately and adds the rest below as it arrives.
  • Code blocks keep their syntax highlighting and their one-tap copy on Android.
  • Selecting text in a long code fence stays within the run of lines the gesture starts in; the message-details Select text view still selects the whole fence.
  • The PR review diff keeps its per-line selection and syntax highlighting.
  • A code fence is still one element for screen readers, and the copy action stays on that element.
  • Light and dark appearances are unchanged.

Changelog for maintainers

  • apps/mobile/src/components/agents/code-block.tsx:232 — accepted: the mount state now resets during render whenever the fence text no longer extends the stored text, instead of waiting for the batch effect; a replaced fence shorter than the first paint kept the replaced-away text in state, so a later fence that extended that text matched it and mounted more than one bounded batch in a single commit.
  • apps/mobile/src/components/agents/code-block-model.ts:73 — accepted: chunkTokenLines now bounds tagged runs per chunk as well as lines (CODE_CHUNK_TOKENS = 512); a line denser than the budget is split at token boundaries before packing, so a single very long source line no longer becomes one Text holding that line's whole run set. Ordinary lines stay whole and keep the 32-line cap.
  • apps/mobile/src/components/agents/code-block.tsx:304 — accepted: a selectable fence no longer renders as one Text; it chunks like every other fence, so the tool detail sheet's 50,000-character body bounds its SetSpanOperation.execute spans to one chunk. Selection now spans the chunk the gesture starts in, and the message-details Select text view still offers whole-fence selection.
  • apps/mobile/src/components/agents/code-block.tsx:320 — accepted: one Text per source line is replaced by one Text per 32-line chunk, so native view count no longer scales with line count (a 1,300-line fence no longer needs 1,300 views).
  • apps/mobile/src/components/agents/code-block.tsx:329 — accepted: chunk Texts are accessible={false} inside one accessible host View that carries copyCode, so a fence stays one screen-reader element instead of one per line.
  • A shared run renderer emits only syntax-tagged tokens as nested Text; untagged runs stay raw strings, and the chat/tool code block and both diff renderers (per-line and side-by-side) share it. Non-selectable and selectable fences share one chunked render path; the first paint mounts four chunks (128 lines) and each later commit adds four.
  • Code content, the chunk array, and copy actions are memoized; a fence whose text only grows keeps its mounts, and a replaced fence restarts from the bounded first paint. This removes per-frame work from SetSpanOperation.execute (fewer spans, capped per chunk), ReactTextView.onDraw and onMeasure (chunked layout instead of one whole-fence layout), and the input-dispatch timeout reported as MessageQueue.nativePollOnce.
  • Review first: the shared chunk split and the accessible host, then the two review fixes — the render-phase mount reset and the per-chunk run budget with its token-boundary split. A blank source line renders a space so its line box survives at a chunk edge; wrap and scroll modes keep the plain View wrapper, and scroll mode keeps the intrinsic-width wrapper for height measurement; selecting across more than one chunk takes a second gesture, or the message-details Select text view.

E2E proof

scripted-shard1.mp4.trim.mp4
e1-scroll.mp4
e1-scroll.mp4.trim.mp4

e1.png

e1-dark.png

e4-scroll.mp4
e4-scroll.mp4.trim.mp4

e2-diff-night-yes.png

e2-diff-night-no.png

e1-sheet-after-drag.png

e1-transcript-fence.png

e1.png

Skipped checks (pending owner verification)

  • Android PR review diff, light and dark: reported skip, so nothing proves its per-line selection and syntax highlighting after the shared run-renderer change.
  • Android transcript selection parity: reported skip, so nothing proves the message-details Select text view's whole-fence selection.
  • Some optional checks did not run.

The transcript and tool detail sheet checks in the appended log excerpts are this change's evidence; they replace the earlier tool-detail-sheet and TalkBack excerpts. The transcript fence lays out to its tail with its copy trigger present, and the tool sheet renders its large read body in wrap mode; the fence still exposes one accessibility element with a single copy trigger. Limitations: the copy action was not fired, no dark capture was taken, and the sheet's Scroll mode and long-press-drag sub-check were not driven. The evidence was collected on 2026-09-16 on an unrecorded platform, and the request asked for Android.

Owner request

Surface: the Android app, apps/mobile. The chat transcript and the PR review
diff are the screens that render the text this report is about.

Problem

Android users hit "app is not responding". Play counts these as
user-perceived ANRs, and the owner wants them gone.

One class carries most of them: React Native builds and lays out text ON THE
UI THREAD. Three of the top four signatures are the same work.

Play Console vitals, top ANR signatures by distinct users, 2026-08-20 to
2026-09-14 (38 users total):

10 users  com.facebook.react.views.text.internal.span.SetSpanOperation.execute
          Input dispatching timed out
 7 users  Native method - android.os.MessageQueue.nativePollOnce
          Input dispatching timed out (No focused window)
 5 users  com.facebook.react.views.text.ReactTextView.onDraw
          Input dispatching timed out
 1 user   com.facebook.react.views.text.ReactTextView.onMeasure
 1 user   com.facebook.react.views.text.ReactTextView.setText
 1 user   com.facebook.react.uimanager.ReactAccessibilityDelegate
          .onInitializeAccessibilityNodeInfo
 1 user   com.swmansion.reanimated.NativeProxy.synchronouslyUpdateUIProps
 1 user   com.facebook.react.views.view.ReactClippingViewManager.removeViewAt

22 of the 38 users are on the three text signatures.

Daily user-perceived ANR rate, same source:

2026-09-03  0.0231    2026-09-09  0.0062
2026-09-04  0.0248    2026-09-10  0.0115
2026-09-05  0.0183    2026-09-11  0.0000
2026-09-06  0.0213    2026-09-12  0.0131
2026-09-08  0.0000    2026-09-13  0.0183

What to do

Find the text the app renders on a long chat transcript and on a PR review
diff, and stop the UI thread from doing that work in one frame.

Read the code before you change it. SetSpanOperation.execute runs once per
span, so the cost is the NUMBER of spans a single Text produces, not the
character count. Look for:

  • a Text whose children are many nested Text elements, so every child is a
    span (a syntax-highlighted code block is the usual source);
  • a markdown or diff renderer that builds the whole message as one Text
    instead of one node per line or per block;
  • text style work recomputed on every render, so the spans are rebuilt when
    nothing about the text changed;
  • a long list that is not virtualized, so every row measures at once.

The fix must be the smallest change that removes the per-frame work for ALL
callers of the renderer, not a guard on one screen.

What proves it

  1. The chat transcript and the PR review diff still render correctly, light
    and dark, on android. Same content, same highlighting, same selection
    behaviour. Capture both.
  2. A long transcript scrolls without a dropped-frame stall. Record the
    scenario you used and the device.
  3. Name, in the pull request, which of the three signatures the change
    removes work from, and why.

Do not change apps/mobile/src/**/kiloclaw/**.

The ANR rate itself only moves after the build reaches users, so it is not the
proof for this change. The proof is the rendering parity plus the removed
per-frame work.

E2E proof — log excerpts

[e1] Android chat transcript, light and dark -> pass :: Transcript fence renders on emulator-5554 (android): digest lines 'android.view.ViewGroup Look at this file and tell me what it does:, Reply with a one-line summary only., Assistant message tappable [0,362][1080,1981]' and 'android.view.ViewGroup code-block-copy-trigger tappable [69,362][1013,1899]', with both FENCE_HEAD_ALPHA and FENCE_TAIL_OMEGA present in e1-send-scene.log, and the fence's chunk Text nodes laid out to the tail below the fold; the copy action was not fired and light/dark appearance plus highlighting are the visual reviewer's (no dark capture taken).
[e2] Android tool detail sheet: read card for a large file, wrap and Scroll mode -> pass :: The read card (e2-transcript.log: 'direct-fixture.txt tool, completed') opens the tool detail sheet: 'android.view.View read: direct-fixture.txt tappable [37,183][858,248]' with 'android.widget.RadioButton Wrap tappable [46,333][540,434]' and 'android.widget.RadioButton Scroll tappable [540,333][1034,434]' and the large body ('READ_HEAD_ALPHA' …) rendered in e2-sheet.log for a ~33,000-character file created by a temporary seed patch (reverted, product tree clean); the Scroll-mode toggle was not driven because the per-scenario scene budget was exhausted, so Scroll-mode rendering is unwitnessed.
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-review-1a74/e2e-mobile-app/e1-send-scene.log
SCENE e1-send MISS error WebDriverError: The referenced element is no longer attached to the DOM when running "element/00000000-0000-00ae-ffff-ffff000003d3/value" with method "POST"
android.widget.LinearLayout com.kilocode.kiloapp:id/action_bar_root tappable [0,0][1080,2400]
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.widget.Button Go back tappable [0,163][101,265]
android.widget.Button Rename session: Transcript fence check fixture analysis tappable [111,149][827,279]
android.view.View Transcript fence check fixture analysis tappable [111,149][827,279]
android.widget.Button Context 15,103 of 200,000 tokens, 8% used. Tap to view context details. tappable [854,156][1043,272]
android.widget.TextView 8% tappable [976,195][1013,232]
android.view.ViewGroup Look at this file and tell me what it does:, Reply with a one-line summary only., Assistant message tappable [0,362][1080,1981]
android.view.ViewGroup code-block-copy-trigger tappable [69,362][1013,1899]
android.view.ViewGroup // FENCE_HEAD_ALPHA — generated fixture for the transcript fence check
export type CellKind = 'alpha' | 'beta' | 'gamma';
export interface Cell { kind: CellKind; index: 
android.widget.TextView export const cell160: Cell = { kind: 'beta', index: 160, label: 'row-160' };
export const cell161: Cell = { kind: 'gamma', index: 161, label: 'row-161' };
export const 
android.widget.TextView export const cell192: Cell = { kind: 'alpha', index: 192, label: 'row-192' };
export const cell193: Cell = { kind: 'beta', index: 193, label: 'row-193' };
export const 
android.widget.TextView Reply with a one-line summary only. tappable [37,1946][1045,1981]
android.widget.Button Mode: Code tappable [28,2006][261,2079]
android.widget.TextView Code tappable [106,2019][183,2065]
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-review-1a74/e2e-mobile-app/e2-sheet.log
SCENE e2 MISS screen 'Mobile sheet fixtures' at capture
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.view.ViewGroup session-page-sheet-surface tappable [0,0][1080,2400]
android.view.View read: direct-fixture.txt tappable [37,183][858,248]
android.widget.Button Done tappable [884,165][1043,266]
android.widget.TextView Done tappable [920,187][1006,243]
android.view.View Text display tappable [37,324][1043,443]
android.widget.RadioButton Wrap tappable [46,333][540,434]
android.widget.TextView Wrap tappable [254,360][331,406]
android.widget.RadioButton Scroll tappable [540,333][1034,434]
android.widget.TextView Scroll tappable [745,360][829,406]
android.widget.TextView // READ_HEAD_ALPHA e2 large read fixture
export const fixtureLine000: Cell = { kind: 'alpha', index: 0, label: 'row-0' };
export const fixtureLine001: Cell = { kind: 'a
/home/igor_kilocode_ai/.local/share/kwf/sections/kwf-fix-review-1a74/e2e-mobile-app/e2-transcript.log
SCENE e2 OK
android.widget.LinearLayout com.kilocode.kiloapp:id/action_bar_root tappable [0,0][1080,2400]
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.widget.Button Go back tappable [0,163][101,264]
android.widget.Button Rename session: Mobile sheet fixtures tappable [111,149][746,278]
android.view.View Mobile sheet fixtures tappable [111,180][746,245]
android.widget.Button Context 1,375 of 1,000,000 tokens, 0% used, cost 1 cent. Tap to view context details. tappable [773,156][1043,271]
android.widget.TextView 0% tappable [895,195][932,232]
android.widget.TextView $0.01 tappable [941,195][1013,232]
android.widget.TextView NOV 14, 2023, 10:13 PM tappable [326,1650][754,1689]
android.view.ViewGroup Assistant message tappable [0,1708][1080,2071]
android.widget.Button Open fixture-notes.txt tappable [37,1726][1043,1808]
android.widget.TextView fixture-notes.txt tappable [119,1744][355,1790]
android.widget.Button direct-fixture.txt tool, completed tappable [40,1840][1042,1922]
android.widget.TextView direct-fixture.txt tappable [128,1858][366,1904]
android.widget.Button Explorer, Inspect child fixture, , completed tappable [42,1943][1043,2062]
android.widget.TextView Explorer tappable [185,1961][837,1999]
android.widget.TextView Inspect child fixture tappable [185,1999][837,2043]
android.widget.TextView completed tappable [868,1983][1001,2020]
android.view.ViewGroup User message tappable [0,2071][1080,2090]
android.widget.TextView This is a read-only session tappable [36,2141][1044,2187]
android.widget.Button Continue tappable [37,2215][1043,2309]
android.widget.TextView Continue tappable [473,2239][606,2285]

@iscekic
iscekic marked this pull request as draft September 16, 2026 04:13
Comment thread apps/mobile/src/components/agents/code-block.tsx Outdated
Comment thread apps/mobile/src/components/agents/code-block.tsx Outdated
Comment thread apps/mobile/src/components/agents/code-block.tsx Outdated
@kilo-code-bot

kilo-code-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental fix skips the blank-line placeholder for an empty fence (keepBlankLineBox = displayText.length > 0), which resolves the previously flagged empty-fence issue and is covered by a new regression test; no new issues found in the changed code.

Files Reviewed (2 files)
  • apps/mobile/src/components/agents/code-block.tsx
  • apps/mobile/src/components/agents/code-block.test.ts
Previous Review Summaries (3 snapshots, latest commit a69e62e)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit a69e62e)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The previously reported span-bounding gaps (stale mount reset and the single-line run budget) are fixed; one residual edge case remains where an empty code fence gains a blank line box.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
apps/mobile/src/components/agents/code-block.tsx 102 A fence with code === '' renders the blank-line placeholder, so an empty fence gains a blank code line instead of collapsing.
Files Reviewed (9 files)
  • apps/mobile/src/components/agents/code-block-model.ts
  • apps/mobile/src/components/agents/code-block-model.test.ts
  • apps/mobile/src/components/agents/code-block.tsx - 1 issue
  • apps/mobile/src/components/agents/code-block.test.ts
  • apps/mobile/src/components/agents/markdown-renderer.test.ts
  • apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx
  • apps/mobile/src/components/pr-review/diff/diff-line.tsx
  • apps/mobile/src/components/pr-review/diff/highlight-runs.tsx
  • apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx

Fix these issues in Kilo Cloud

Previous review (commit 5e4387d)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

Two residual gaps in the new Android span-bounding path: a replaced fence can resume from a stale chunk mount count, and a single very long source line still lands in one Text.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

SUGGESTION

File Line Issue
apps/mobile/src/components/agents/code-block.tsx 232 mountProgress is never reset when the fence is replaced, so a later fence extending the retained text can resume from the stale chunk count and mount the whole fence in one commit.
apps/mobile/src/components/agents/code-block-model.ts 73 chunkTokenLines splits on lines only; a single very long line still becomes one Text with that line's whole token span set applied in one frame.
Files Reviewed (8 files)
  • apps/mobile/src/components/agents/code-block-model.ts - 1 issue
  • apps/mobile/src/components/agents/code-block.tsx - 1 issue
  • apps/mobile/src/components/agents/code-block.test.ts
  • apps/mobile/src/components/agents/markdown-renderer.test.ts
  • apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx
  • apps/mobile/src/components/pr-review/diff/diff-line.tsx
  • apps/mobile/src/components/pr-review/diff/highlight-runs.tsx
  • apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx

Fix these issues in Kilo Cloud

Previous review (commit 213dc0b)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
apps/mobile/src/components/agents/code-block.tsx 304 Selectable fence still renders as one RNText, so span count remains proportional to the whole fence; the tool detail sheet routes up to 50k-char selectable code through it, so the SetSpanOperation.execute ANR shape is only partially removed.

SUGGESTION

File Line Issue
apps/mobile/src/components/agents/code-block.tsx 320 Per-line RNText makes native view count scale with line count; consider chunking lines to bound both spans and view count.
apps/mobile/src/components/agents/code-block.tsx 329 copyCode action now rides on every line RNText, so each line becomes its own accessible element; screen-reader navigation regresses from one element to N.
Files Reviewed (7 files)
  • apps/mobile/src/components/agents/code-block.tsx - 3 issues
  • apps/mobile/src/components/agents/code-block.test.ts - 0 issues
  • apps/mobile/src/components/agents/markdown-renderer.test.ts - 0 issues
  • apps/mobile/src/components/pr-review/diff/highlight-runs.tsx - 0 issues
  • apps/mobile/src/components/pr-review/diff/diff-line.tsx - 0 issues
  • apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx - 0 issues
  • apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx - 0 issues

Notes: untagged-run color parity holds for all three callers (tokenColorFor(null, isDark) equals the theme foreground that the parent Text now supplies), and no memory leaks (subscriptions, timers, retained refs) were introduced. The PR diff is read-only review; no files were modified.

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@iscekic
iscekic force-pushed the kwf/android-user-perceived-anrs-5740 branch from 6bba86e to 5e4387d Compare September 16, 2026 14:01
@iscekic
iscekic marked this pull request as ready for review September 16, 2026 14:16
Comment thread apps/mobile/src/components/agents/code-block.tsx
Comment thread apps/mobile/src/components/agents/code-block-model.ts Outdated
@iscekic
iscekic marked this pull request as draft September 16, 2026 14:32
@iscekic
iscekic force-pushed the kwf/android-user-perceived-anrs-5740 branch from ca36acd to a69e62e Compare September 16, 2026 16:07
@iscekic
iscekic marked this pull request as ready for review September 16, 2026 16:23
Comment thread apps/mobile/src/components/agents/code-block.tsx
@iscekic
iscekic marked this pull request as draft September 16, 2026 16:30
@iscekic
iscekic force-pushed the kwf/android-user-perceived-anrs-5740 branch from a69e62e to d883211 Compare September 16, 2026 16:46
@iscekic
iscekic marked this pull request as ready for review September 16, 2026 18:01
@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 16, 2026
@iscekic iscekic self-assigned this Sep 16, 2026
@iscekic

iscekic commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

(bot) kwf reopened its section for this PR: verification restarted (phase verify) The proof in the body came from an earlier round and can be stale. This PR is human-ready, so nothing here was changed.

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

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants