Skip to content

perf(mobile): stop chat redraws per keystroke and page-wide row scans per scroll frame - #34323

Merged
AmoabaKelvin merged 2 commits into
mainfrom
33205-mobile-chat-lag
Oct 27, 2026
Merged

AmoabaKelvin merged 2 commits into
mainfrom
33205-mobile-chat-lag

Conversation

@AmoabaKelvin

@AmoabaKelvin AmoabaKelvin commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​199 $\color{#cf222e}{\Huge{\mathbf{−}}}$​77 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​122
Prod 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​119 $\color{#cf222e}{\Huge{\mathbf{−}}}$​34 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​85

Typing a prompt and scrolling back through a chat on Orca Mobile no longer get slower as the chat grows. Both did work proportional to the whole chat on every keystroke and every scroll frame (#33205).

The phone draws chat only in the over-the-air page (react-native-web inside the app's WebView, served by the desktop), so that is where the cost was.

Typing re-rendered the whole session screen. The draft text was subscribed inside useMobileNativeChatDrafts, which runs in useMobileNativeChatController at the top of MobileSessionRouteScreen. Each keystroke redrew the session screen and the chat view, re-ran every drawn row's props and memo check (hundreds once you've scrolled up), and ran the page list's layout-reading effects. Now the drafts hook returns the draft's key, and MobileNativeChatDraftComposer subscribes to the draft store and renders the same controlled MobileNativeChatComposer. A keystroke redraws only the composer. Typing, dictation, send and restore still write through the same draft store.

Scrolling scanned the whole page. On every scroll event the page list records the reader's place (mobileNativeChatReadingPlace), and to find the rows on screen it ran querySelectorAll('[data-testid^="chat-row:"]') over the scroller and read every drawn row's attribute: 9 to 12 thousand DOM nodes per frame, growing as you scroll up. The page list now marks its row box (data-chat-rows). The reading place takes that box's children plus any footer waiting rows after it, binary-searches them by position, and reads ids only for the rows it touches. The same rows, in the same order, count as anchors as before.

Since typing no longer commits the list, the page list's resize observer now also watches its own viewport. Without that, a reader parked in history moved when the composer changed height: in review, opening the / menu shifted the reading line by 301 px on the phone. With the observer it stays put.

The native FlatList path only picks up the draft-key wiring. Nothing changes on the wire, so mixed versions are unaffected.

Measurements

Release APK mobile-android-v0.0.59 on an Android 15 arm64 emulator, paired to a hidden dev desktop serving out/mobile-web built from main (6357bd7) or from this branch. The chat is a real 747-turn Claude transcript opened with claude --resume in a scratch repo. The bench drives the page's own WebView over CDP: keys through Input.dispatchKeyEvent, 500 px touch swipes through Input.synthesizeScrollGesture, and costs from Performance.getMetrics deltas. "4× CPU" is Emulation.setCPUThrottlingRate(4), which is closer to a mid-range phone than this Mac. Before and after builds were interleaved five times (before-* is main, after-* and final-5 are this branch, final-5 the last commit). The table uses the pair where both runs had 384 rows drawn after the load phase; the other pairs agree (log below).

main this PR
Typing, chat just opened (24 rows): script per key 3.9 ms 1.7 ms
Typing, 384 rows drawn: script per key 5.9 ms 1.2 ms
Typing, 384 rows drawn, 4× CPU: main-thread per key, median / p95 46.7 / 102.0 ms 21.3 / 57.3 ms
Typing, 384 rows drawn, 4× CPU: script per key 29.7 ms 6.1 ms
Scrolling through already-drawn history: script per swipe, down / up 39.3 / 40.6 ms 20.6 / 25.9 ms
Same, 4× CPU 128.4 / 118.2 ms 79.6 / 66.3 ms

Typing costs 2 to 5 times less and no longer grows with how much of the chat is drawn. Script time while scrolling roughly halves. Total main-thread time per swipe drops less, about 5 to 10%, because on this emulator most of what is left is the browser's own hit testing and paint of a large page. Key-to-paint sits at 24 to 32 ms on both builds (the emulator paints in software), so it isn't counted as a result.

On the phone with this change, the row at screen centre held its offset to the pixel through 4 s of history loading, in three rounds of scrolling up.

Full A/B log (4 interleaved runs)
[before-3] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 8.5 p95 21.2 | script 4.6 | layout+style 0.4 | key->paint >16ms: 34/34, median 24.0 max 40.0
[before-3] scroll-up x1 20 swipes 24990 ms: script/swipe 54.6 main/swipe 211.7 | frames 1410, frame median 16.7 p95 16.8 max 83.4, >50ms 9, LoAF 20 sum 1253.0 | main-thread 4234.4 script 1092.1 layout+style 787.7 | rows 348 nodes 8272
[before-3] scroll-down-drawn x1 8 swipes 9968 ms: script/swipe 36.3 main/swipe 211.4 | frames 1177, frame median 16.7 p95 16.8 max 66.7, >50ms 2, LoAF 2 sum 172.0 | main-thread 1691.0 script 290.4 layout+style 48.1 | rows 348 nodes 8272
[before-3] scroll-up-drawn x1 8 swipes 9926 ms: script/swipe 40.5 main/swipe 207.5 | frames 1748, frame median 16.7 p95 16.8 max 50.1, >50ms 6, LoAF 0 sum 0.0 | main-thread 1660.3 script 324.3 layout+style 46.5 | rows 348 nodes 8272
[before-3] scroll-down-drawn x4 8 swipes 10012 ms: script/swipe 129.2 main/swipe 733.2 | frames 519, frame median 16.7 p95 33.4 max 66.7, >50ms 2, LoAF 48 sum 2904.0 | main-thread 5865.4 script 1033.5 layout+style 113.2 | rows 348 nodes 8272
[before-3] scroll-up-drawn x4 8 swipes 9969 ms: script/swipe 133.7 main/swipe 718.1 | frames 1049, frame median 16.7 p95 33.4 max 66.7, >50ms 4, LoAF 50 sum 2950.0 | main-thread 5744.5 script 1069.3 layout+style 147.3 | rows 348 nodes 8272
[before-3] typing@after-scroll x1 rows=348 nodes=8272 keys=34 main-thread ms/key median 11.2 p95 33.6 | script 5.7 | layout+style 0.3 | key->paint >16ms: 34/34, median 24.0 max 40.0
[before-3] typing@after-scroll x4 rows=348 nodes=8272 keys=34 main-thread ms/key median 42.2 p95 71.5 | script 26.9 | layout+style 1.8 | key->paint >16ms: 35/34, median 40.0 max 152.0
[after-3] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 4.2 p95 9.1 | script 1.5 | layout+style 0.3 | key->paint >16ms: 34/34, median 32.0 max 48.0
[after-3] scroll-up x1 20 swipes 25002 ms: script/swipe 38.4 main/swipe 175.4 | frames 1432, frame median 16.7 p95 16.8 max 116.6, >50ms 8, LoAF 11 sum 811.0 | main-thread 3507.6 script 768.7 layout+style 562.5 | rows 276 nodes 6506
[after-3] scroll-down-drawn x1 8 swipes 9988 ms: script/swipe 20.8 main/swipe 166.8 | frames 1189, frame median 16.7 p95 16.8 max 33.4, >50ms 0, LoAF 2 sum 102.0 | main-thread 1334.6 script 166.5 layout+style 48.4 | rows 276 nodes 6506
[after-3] scroll-up-drawn x1 8 swipes 9958 ms: script/swipe 24.2 main/swipe 172.7 | frames 1769, frame median 16.7 p95 16.8 max 33.4, >50ms 0, LoAF 0 sum 0.0 | main-thread 1381.4 script 193.6 layout+style 49.1 | rows 276 nodes 6506
[after-3] scroll-down-drawn x4 8 swipes 10015 ms: script/swipe 64.8 main/swipe 597.4 | frames 522, frame median 16.7 p95 33.3 max 100.0, >50ms 3, LoAF 36 sum 2088.0 | main-thread 4779.4 script 518.3 layout+style 156.9 | rows 276 nodes 6506
[after-3] scroll-up-drawn x4 8 swipes 10026 ms: script/swipe 71.7 main/swipe 567.0 | frames 517, frame median 16.7 p95 33.4 max 100.0, >50ms 3, LoAF 40 sum 2765.0 | main-thread 4536.0 script 573.4 layout+style 146.6 | rows 276 nodes 6506
[after-3] typing@after-scroll x1 rows=276 nodes=6506 keys=34 main-thread ms/key median 5.5 p95 9.3 | script 1.1 | layout+style 0.3 | key->paint >16ms: 34/34, median 32.0 max 40.0
[after-3] typing@after-scroll x4 rows=276 nodes=6506 keys=34 main-thread ms/key median 22.7 p95 40.2 | script 7.3 | layout+style 1.8 | key->paint >16ms: 35/34, median 24.0 max 96.0
[before-4] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 8.0 p95 17.6 | script 3.9 | layout+style 0.3 | key->paint >16ms: 35/34, median 24.0 max 40.0
[before-4] scroll-up x1 20 swipes 25011 ms: script/swipe 56.6 main/swipe 224.6 | frames 1405, frame median 16.7 p95 16.8 max 116.7, >50ms 15, LoAF 20 sum 1511.0 | main-thread 4492.3 script 1132.5 layout+style 943.6 | rows 384 nodes 8960
[before-4] scroll-down-drawn x1 8 swipes 9907 ms: script/swipe 39.3 main/swipe 227.3 | frames 1177, frame median 16.7 p95 16.7 max 83.3, >50ms 2, LoAF 2 sum 198.0 | main-thread 1818.6 script 314.4 layout+style 112.3 | rows 384 nodes 8982
[before-4] scroll-up-drawn x1 8 swipes 9922 ms: script/swipe 40.6 main/swipe 211.5 | frames 1778, frame median 16.7 p95 16.7 max 33.4, >50ms 0, LoAF 0 sum 0.0 | main-thread 1692.2 script 324.8 layout+style 49.3 | rows 384 nodes 8982
[before-4] scroll-down-drawn x4 8 swipes 9915 ms: script/swipe 128.4 main/swipe 735.9 | frames 2087, frame median 16.7 p95 33.4 max 66.7, >50ms 16, LoAF 52 sum 3144.0 | main-thread 5887.2 script 1027.5 layout+style 113.8 | rows 384 nodes 8982
[before-4] scroll-up-drawn x4 8 swipes 9891 ms: script/swipe 118.2 main/swipe 695.2 | frames 509, frame median 16.7 p95 33.3 max 299.9, >50ms 4, LoAF 65 sum 5155.0 | main-thread 5561.4 script 945.6 layout+style 277.5 | rows 384 nodes 9010
[before-4] typing@after-scroll x1 rows=384 nodes=9010 keys=34 main-thread ms/key median 9.6 p95 18.6 | script 5.9 | layout+style 0.4 | key->paint >16ms: 35/34, median 24.0 max 40.0
[before-4] typing@after-scroll x4 rows=384 nodes=9010 keys=34 main-thread ms/key median 46.7 p95 102.0 | script 29.7 | layout+style 1.8 | key->paint >16ms: 35/34, median 40.0 max 80.0
[after-4] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 5.5 p95 14.9 | script 1.7 | layout+style 0.4 | key->paint >16ms: 34/34, median 24.0 max 56.0
[after-4] scroll-up x1 20 swipes 25119 ms: script/swipe 51.0 main/swipe 230.2 | frames 1395, frame median 16.7 p95 16.8 max 166.6, >50ms 15, LoAF 23 sum 1737.0 | main-thread 4603.6 script 1019.8 layout+style 909.7 | rows 384 nodes 9010
[after-4] scroll-down-drawn x1 8 swipes 9887 ms: script/swipe 20.6 main/swipe 197.4 | frames 1157, frame median 16.7 p95 16.8 max 83.4, >50ms 4, LoAF 4 sum 306.0 | main-thread 1579.4 script 165.1 layout+style 51.4 | rows 384 nodes 9010
[after-4] scroll-up-drawn x1 8 swipes 9938 ms: script/swipe 25.9 main/swipe 201.7 | frames 1775, frame median 16.7 p95 16.8 max 49.9, >50ms 0, LoAF 3 sum 150.0 | main-thread 1613.8 script 207.3 layout+style 52.3 | rows 384 nodes 9010
[after-4] scroll-down-drawn x4 8 swipes 10032 ms: script/swipe 79.6 main/swipe 712.0 | frames 2103, frame median 16.7 p95 33.4 max 50.1, >50ms 4, LoAF 48 sum 3020.0 | main-thread 5696.0 script 636.7 layout+style 130.7 | rows 384 nodes 9010
[after-4] scroll-up-drawn x4 8 swipes 9981 ms: script/swipe 66.3 main/swipe 653.2 | frames 529, frame median 16.7 p95 33.3 max 66.6, >50ms 5, LoAF 70 sum 4235.0 | main-thread 5225.6 script 530.5 layout+style 134.4 | rows 384 nodes 9010
[after-4] typing@after-scroll x1 rows=384 nodes=9010 keys=34 main-thread ms/key median 6.2 p95 11.0 | script 1.2 | layout+style 0.3 | key->paint >16ms: 34/34, median 32.0 max 48.0
[after-4] typing@after-scroll x4 rows=384 nodes=9010 keys=34 main-thread ms/key median 21.3 p95 57.3 | script 6.1 | layout+style 1.2 | key->paint >16ms: 35/34, median 24.0 max 32.0
[before-5] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 7.9 p95 25.8 | script 3.7 | layout+style 0.4 | key->paint >16ms: 33/34, median 32.0 max 48.0
[before-5] scroll-up x1 20 swipes 25029 ms: script/swipe 61.7 main/swipe 252.4 | frames 1401, frame median 16.7 p95 16.8 max 100.0, >50ms 14, LoAF 22 sum 1615.0 | main-thread 5048.0 script 1234.0 layout+style 1007.3 | rows 408 nodes 9823
[before-5] scroll-down-drawn x1 8 swipes 9937 ms: script/swipe 41.3 main/swipe 249.6 | frames 1171, frame median 16.7 p95 16.8 max 100.0, >50ms 2, LoAF 2 sum 226.0 | main-thread 1996.9 script 330.1 layout+style 132.9 | rows 408 nodes 9829
[before-5] scroll-up-drawn x1 8 swipes 9955 ms: script/swipe 38.9 main/swipe 235.3 | frames 1772, frame median 16.7 p95 16.8 max 50.0, >50ms 0, LoAF 3 sum 222.0 | main-thread 1882.7 script 311.0 layout+style 47.8 | rows 408 nodes 9829
[before-5] scroll-down-drawn x4 8 swipes 10014 ms: script/swipe 130.2 main/swipe 791.8 | frames 504, frame median 16.7 p95 33.4 max 66.8, >50ms 6, LoAF 68 sum 4288.0 | main-thread 6334.7 script 1041.8 layout+style 113.8 | rows 408 nodes 9829
[before-5] scroll-up-drawn x4 8 swipes 9960 ms: script/swipe 122.5 main/swipe 754.0 | frames 493, frame median 16.7 p95 33.4 max 66.8, >50ms 8, LoAF 80 sum 5045.0 | main-thread 6031.9 script 979.6 layout+style 131.9 | rows 408 nodes 9829
[before-5] typing@after-scroll x1 rows=408 nodes=9857 keys=34 main-thread ms/key median 9.6 p95 16.6 | script 6.1 | layout+style 0.4 | key->paint >16ms: 35/34, median 24.0 max 40.0
[before-5] typing@after-scroll x4 rows=408 nodes=9857 keys=34 main-thread ms/key median 47.7 p95 92.1 | script 29.7 | layout+style 1.6 | key->paint >16ms: 35/34, median 40.0 max 88.0
[final-5] typing@open x1 rows=24 nodes=581 keys=34 main-thread ms/key median 5.7 p95 12.8 | script 1.5 | layout+style 0.3 | key->paint >16ms: 35/34, median 24.0 max 40.0
[final-5] scroll-up x1 20 swipes 25093 ms: script/swipe 51.2 main/swipe 223.2 | frames 1426, frame median 16.7 p95 16.8 max 99.9, >50ms 10, LoAF 19 sum 1276.0 | main-thread 4463.9 script 1024.4 layout+style 915.3 | rows 384 nodes 9010
[final-5] scroll-down-drawn x1 8 swipes 9989 ms: script/swipe 24.3 main/swipe 217.0 | frames 1185, frame median 16.7 p95 16.8 max 50.0, >50ms 0, LoAF 4 sum 238.0 | main-thread 1735.8 script 194.4 layout+style 52.0 | rows 384 nodes 9010
[final-5] scroll-up-drawn x1 8 swipes 10007 ms: script/swipe 27.2 main/swipe 208.0 | frames 1796, frame median 16.7 p95 16.8 max 33.4, >50ms 0, LoAF 0 sum 0.0 | main-thread 1664.4 script 217.9 layout+style 50.0 | rows 384 nodes 9010
[final-5] scroll-down-drawn x4 8 swipes 9994 ms: script/swipe 71.2 main/swipe 720.7 | frames 530, frame median 16.7 p95 33.3 max 66.7, >50ms 3, LoAF 52 sum 3096.0 | main-thread 5765.4 script 569.6 layout+style 134.2 | rows 384 nodes 9010
[final-5] scroll-up-drawn x4 8 swipes 9999 ms: script/swipe 73.7 main/swipe 688.0 | frames 522, frame median 16.7 p95 33.3 max 66.7, >50ms 5, LoAF 65 sum 3870.0 | main-thread 5504.4 script 589.6 layout+style 150.9 | rows 384 nodes 9010
[final-5] typing@after-scroll x1 rows=384 nodes=9010 keys=34 main-thread ms/key median 8.3 p95 19.4 | script 1.5 | layout+style 0.5 | key->paint >16ms: 34/34, median 24.0 max 56.0
[final-5] typing@after-scroll x4 rows=384 nodes=9010 keys=34 main-thread ms/key median 19.2 p95 50.7 | script 5.9 | layout+style 1.2 | key->paint >16ms: 35/34, median 24.0 max 144.0
How to rerun
  1. Build the page from each tree with pnpm run build:mobile-web and keep a copy of out/mobile-web for each.
  2. Launch a hidden dev desktop: ORCA_BACKGROUND_LAUNCH=1 ORCA_DEV_USER_DATA_PATH=/tmp/<dir> node config/scripts/run-electron-vite-dev.mjs -- --remote-debugging-port=<port>. Add a scratch repo and run claude --resume <long session> in its terminal. The transcript is copied into ~/.claude/projects/-private-tmp-<scratch>/.
  3. Install the release APK on an emulator. Pair it with window.api.mobile.getPairingQR({ address: '10.0.2.2', connectionMode: 'local-only', rotate: true }) from the desktop page, sent with adb shell am start -d '<pairingUrl>'. Open the chat.
  4. Run adb forward tcp:9599 localabstract:webview_devtools_remote_$(adb shell pidof com.stably.orca.mobile), then run the bench script below against localhost:9599.
  5. To switch builds: swap out/mobile-web, restart the desktop (it caches the bundle per process), force-stop and relaunch the app, and reopen the chat.
// Drives the real chat page inside the Android WebView over raw CDP.
import { createRequire } from 'node:module'
const require = createRequire('<repo>/package.json')
const WebSocket = require('ws')
const [label = 'run', throttle = '1', swipes = '20', port = '9599'] = process.argv.slice(2)
const targets = await (await fetch(`http://localhost:${port}/json`)).json()
const target = targets.find((t) => t.url.includes('/session/') && JSON.parse(t.description).attached)
const ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false })
await new Promise((r) => ws.once('open', r))
let id = 0
const pending = new Map()
ws.on('message', (m) => { const d = JSON.parse(m); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id) } })
const send = (method, params = {}) => new Promise((r, j) => { const i = ++id; pending.set(i, (d) => (d.error ? j(new Error(method + ' ' + JSON.stringify(d.error))) : r(d.result))); ws.send(JSON.stringify({ id: i, method, params })) })
const evaluate = async (expr) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result.value
await send('Performance.enable')
await send('Emulation.setCPUThrottlingRate', { rate: Number(throttle) })
const KEYS = ['ScriptDuration', 'LayoutDuration', 'RecalcStyleDuration', 'TaskDuration']
const metrics = async () => Object.fromEntries((await send('Performance.getMetrics')).metrics.filter((m) => KEYS.includes(m.name)).map((m) => [m.name, m.value * 1000]))
const settle = () => evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 0))))')
const med = (a) => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN }
const p95 = (a) => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.min(s.length - 1, Math.floor(s.length * 0.95))] : NaN }
const f = (x) => x.toFixed(1)
const state = () => evaluate(`({ rows: document.querySelectorAll('[data-testid^="chat-row:"]').length, nodes: document.getElementsByTagName('*').length })`)
await evaluate(`window.__ev = []; new PerformanceObserver((l) => window.__ev.push(...l.getEntries().filter((e) => e.name === 'keydown' || e.name === 'keypress' || e.name === 'input').map((e) => ({ name: e.name, d: e.duration, id: e.interactionId })))).observe({ type: 'event', durationThreshold: 16, buffered: false }); true`)

import { execFileSync } from 'node:child_process'
async function typing(name) {
  execFileSync('adb', ['-s', 'emulator-5570', 'shell', 'input', 'tap', '540', '2070']); await new Promise((r) => setTimeout(r, 1500))
  await evaluate(`(() => { const t = document.querySelector('textarea[placeholder^="Ask"]'); t.focus(); return !!t })()`)
  await settle()
  await evaluate('window.__ev.length = 0; true')
  const per = []
  for (const ch of 'please refactor the scroll handler') {
    const a = await metrics()
    await send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, unmodifiedText: ch })
    await send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch })
    await settle()
    const b = await metrics()
    per.push({ task: b.TaskDuration - a.TaskDuration, script: b.ScriptDuration - a.ScriptDuration, layout: b.LayoutDuration - a.LayoutDuration + b.RecalcStyleDuration - a.RecalcStyleDuration })
  }
  const ev = await evaluate('window.__ev')
  const byInteraction = new Map()
  for (const e of ev) byInteraction.set(e.id, Math.max(byInteraction.get(e.id) ?? 0, e.d))
  const inp = [...byInteraction.values()]
  const col = (k) => per.map((p) => p[k])
  const s = await state()
  console.log(`[${label}] typing@${name} x${throttleNow} rows=${s.rows} nodes=${s.nodes} keys=${per.length} value="${(await evaluate(`document.querySelector('textarea[placeholder^="Ask"]').value`)).slice(0, 12)}…": main-thread ms/key median ${f(med(col('task')))} p95 ${f(p95(col('task')))} | script ${f(med(col('script')))} | layout+style ${f(med(col('layout')))} | key->paint >16ms: ${inp.length}/${per.length}, median ${f(med(inp))} max ${f(Math.max(0, ...inp))}`)
  await evaluate(`(() => { const t = document.querySelector('textarea[placeholder^="Ask"]'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); t.blur(); return true })()`)
  await settle()
}

async function scrolling(n, dy = 500, name = 'up') {
  await evaluate(`(() => { window.__frames = []; let last = performance.now(); const tick = (t) => { window.__frames.push(t - last); last = t; if (window.__frameRun) requestAnimationFrame(tick) }; window.__frameRun = true; requestAnimationFrame(tick); window.__loaf = []; try { new PerformanceObserver((l) => window.__loaf.push(...l.getEntries().map((e) => e.duration))).observe({ type: 'long-animation-frame' }) } catch {} return true })()`)
  const a = await metrics()
  const t0 = Date.now()
  for (let i = 0; i < n; i++) {
    await send('Input.synthesizeScrollGesture', { x: 180, y: dy > 0 ? 250 : 650, yDistance: dy, speed: 1500, gestureSourceType: 'touch', repeatCount: 1 })
  }
  const wall = Date.now() - t0
  const b = await metrics()
  const r = await evaluate('(() => { window.__frameRun = false; return { frames: window.__frames.slice(1), loaf: window.__loaf } })()')
  const s = await state()
  console.log(`[${label}] scroll-${name} x${throttleNow} ${n} swipes ${wall} ms: script/swipe ${f((b.ScriptDuration - a.ScriptDuration) / n)} main/swipe ${f((b.TaskDuration - a.TaskDuration) / n)} | frames ${r.frames.length}, frame median ${f(med(r.frames))} p95 ${f(p95(r.frames))} max ${f(Math.max(...r.frames))}, >50ms ${r.frames.filter((x) => x > 50).length}, LoAF ${r.loaf.length} sum ${f(r.loaf.reduce((x, y) => x + y, 0))} | main-thread ${f(b.TaskDuration - a.TaskDuration)} script ${f(b.ScriptDuration - a.ScriptDuration)} layout+style ${f(b.LayoutDuration - a.LayoutDuration + b.RecalcStyleDuration - a.RecalcStyleDuration)} | rows ${s.rows} nodes ${s.nodes}`)
}

let throttleNow = Number(throttle)
const setThrottle = async (rate) => { throttleNow = rate; await send('Emulation.setCPUThrottlingRate', { rate }) }
const mode = process.env.MODE ?? 'all'
if (mode === 'all' || mode === 'type') await typing('open')
if (mode === 'all' || mode === 'scroll') await scrolling(Number(swipes))
if (mode === 'all' || mode === 'steady') {
  for (const rate of [1, 4]) {
    await setThrottle(rate)
    await scrolling(8, -500, 'down-drawn')
    await scrolling(8, 500, 'up-drawn')
  }
  await setThrottle(Number(throttle))
}
if (mode === 'all' || mode === 'type') await typing('after-scroll')
if (mode === 'all' || mode === 'type') { await setThrottle(4); await typing('after-scroll'); await setThrottle(Number(throttle)) }
await send('Emulation.setCPUThrottlingRate', { rate: 1 })
ws.close()
process.exit(0)

Visual Proof

N/A: nothing looks different. The change is how much work a keystroke and a scroll frame do; the numbers above are the proof.

Fixes #33205

…y row per scroll frame

Typing re-rendered the whole session screen because the draft text was
subscribed in the drafts hook at the route; the composer now subscribes by
draft key. The page list's reading place indexed every drawn row with a
page-wide querySelectorAll on each scroll event; it now binary-searches the
row box's children. The list also observes its viewport so composer resizes
keep a parked reader's place without a list commit.
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Walkthrough

The composer now receives a nullable draft key and reads its text from the mobile draft store. The draft-management hook exposes the key, and related controller, view, fixture, and test code uses it. Reading-position logic now searches tagged rows within a marked row container, skips untagged elements, and responds to viewport and content resizing.

Fixed issue severity: Medium

Priority: Normal

Merge Risk: Minimal · up to 17747

The change appears mergeable after normal checks. A production-path render test would give better protection against the typing lag returning.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surv… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely summarizes the two main changes: reducing chat redraws during typing and avoiding page-wide row scans during scrolling.
Description check Passed The description is detailed and on topic. It explains the user impact, implementation, rationale, linked issue, visual proof status, test method, and performance measurements. It does not reproduce ev…
Linked Issues check Passed Issue #33205 directly reports lag while typing and scrolling in Orca Mobile. The PR addresses both paths. The draft subscription moves from the session screen to MobileNativeChatDraftComposer, so ty…
Out of Scope Changes check Passed The changed production files support the two performance fixes for #33205. Draft-key changes update the mobile chat controller, view, overlay, composer, and related draft tests. Row-box lookup and vie…
Full details: Description check

Explanation

The description is detailed and on topic. It explains the user impact, implementation, rationale, linked issue, visual proof status, test method, and performance measurements. It does not reproduce every template heading or checklist item, but the core required information is present.

Full details: Linked Issues check

Explanation

Issue #33205 directly reports lag while typing and scrolling in Orca Mobile. The PR addresses both paths. The draft subscription moves from the session screen to MobileNativeChatDraftComposer, so typing updates the composer without re-rendering the owner. The reading-place code searches the marked data-chat-rows box and nearby tagged footer rows instead of scanning all chat rows. The viewport resize observer preserves the reading position when composer height changes. Tests cover owner render isolation, reading-place lookup, viewport changes, and intervening turn-status rows. The issue has no additional coding acceptance criteria.

Full details: Out of Scope Changes check

Explanation

The changed production files support the two performance fixes for #33205. Draft-key changes update the mobile chat controller, view, overlay, composer, and related draft tests. Row-box lookup and viewport observation update the page list, reading-place code, and related tests. Native-path wiring and test-fixture changes support the same draft contract. The summary shows no unrelated feature, protocol change, or broad refactor.

Full details: Docstring Coverage

Explanation

Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 31 functions across 26 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 727838a1-f71e-4cd3-9044-0d4ff658736d
Commits

Reviewing files that changed from the base of the PR and between d1f3532 and 9cd08fe.

Files selected for processing: 26
  • mobile/src/session/MobileNativeChatOverlay.test.ts
  • mobile/src/session/MobileNativeChatOverlay.tsx
  • mobile/src/session/MobileNativeChatPageList.tsx
  • mobile/src/session/MobileNativeChatView.harness.test-fixture.ts
  • mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts
  • mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts
  • mobile/src/session/MobileNativeChatView.streaming-rows.test.ts
  • mobile/src/session/MobileNativeChatView.tsx
  • mobile/src/session/MobileNativeChatView.turn-status.test.ts
  • mobile/src/session/MobileNativeChatView.waiting-rows.test.ts
  • mobile/src/session/__mocks__/mobile-prompt-controller.ts
  • mobile/src/session/mobile-dictation-agent-chat-start.test.tsx
  • mobile/src/session/mobile-native-chat-controller-contract.ts
  • mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
  • mobile/src/session/mobile-native-chat-reading-place.test.tsx
  • mobile/src/session/mobile-native-chat-reading-place.ts
  • mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx
  • mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-launch-draft.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-queued-hold.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-message-send-draft-lifetime.test.ts
  • mobile/src/session/use-mobile-native-chat-controller.test.ts
  • mobile/src/session/use-mobile-native-chat-controller.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

) : (
// Key by chat to reset rows; persist disclosures above windowed rows. Block flow avoids relaying out every row for each streamed token.
<div key={rowKeys.scope}>
<div key={rowKeys.scope} {...mobileNativeChatRowBoxProps}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '350,410p' mobile/src/session/MobileNativeChatPageList.tsx
rg -n 'waitingRows|useMobileNativeChatTurnDisclosure|emptyStateView' mobile/src/session/MobileNativeChatView.tsx mobile/src/session/*turn* mobile/src/session/MobileNativeChatPageList.tsx

Repository: stablyai/orca

Length of output: 5240


Script executed:

sed -n '1,340p' mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
printf '\n--- MobileNativeChatView ---\n'
sed -n '220,350p' mobile/src/session/MobileNativeChatView.tsx
printf '\n--- PageList props and setup ---\n'
sed -n '1,130p' mobile/src/session/MobileNativeChatPageList.tsx

Repository: stablyai/orca

Length of output: 20622


Script executed:

rg -n -F -- 'nativeChatMessagesWaitingBehindLiveTurn' src mobile
rg -n -F -- 'waitingRows' mobile/src/session/use-mobile-native-chat-turn-disclosure*.test* mobile/src/session/MobileNativeChatView.waiting-rows.test.ts
printf '\n--- helper ---\n'
helper=$(rg -l -F -- 'export function nativeChatMessagesWaitingBehindLiveTurn' src mobile | head -n 1)
if [ -n "$helper" ]; then
  sed -n '1,260p' "$helper"
fi
printf '\n--- disclosure tests around waiting cases ---\n'
sed -n '1,230p' mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx
printf '\n--- page-list reading branches ---\n'
rg -n 'mobileNativeChatReadingPlace|readingPlace|drawnRows|liveRows|rows.length|notePlace|drawAndFetch|restore' mobile/src/session/MobileNativeChatPageList.tsx

Repository: stablyai/orca

Length of output: 14329


Script executed:

printf '%s\n' '--- MobileNativeChatView data producer ---'
sed -n '1,230p' mobile/src/session/MobileNativeChatView.tsx
printf '%s\n' '--- projection and draw order definitions ---'
rg -n 'export function projectStructuredAgentSessionMessages|function projectStructuredAgentSessionMessages|export function nativeChatRowsInDrawOrder|function nativeChatRowsInDrawOrder' src/shared mobile/src/session
for f in $(rg -l -F -- 'export function projectStructuredAgentSessionMessages' src/shared mobile/src/session); do sed -n '1,280p' "$f"; done
for f in $(rg -l -F -- 'export function nativeChatRowsInDrawOrder' src/shared mobile/src/session); do sed -n '1,220p' "$f"; done
printf '%s\n' '--- page-list reading/restoration block ---'
sed -n '145,325p' mobile/src/session/MobileNativeChatPageList.tsx

Repository: stablyai/orca

Length of output: 29923


Script executed:

printf '%s\n' '--- transient data producer ---'
rg -n -F -- 'export function buildMobileNativeChatTransientData' mobile/src/session
f=$(rg -l -F -- 'export function buildMobileNativeChatTransientData' mobile/src/session | head -n 1)
[ -n "$f" ] && sed -n '1,280p' "$f"
printf '%s\n' '--- MobileNativeChatView call sites ---'
rg -n 'MobileNativeChatView' mobile/src/session mobile/src | head -n 120
printf '%s\n' '--- turn journal and messages wiring ---'
rg -n 'turnJournal=|turnJournal:|messages=|<MobileNativeChatView|buildMobileNativeChatTransientData' mobile/src/session --glob '*.tsx' --glob '*.ts' | head -n 240

Repository: stablyai/orca

Length of output: 29309


Script executed:

printf '%s\n' '--- overlay call site ---'
sed -n '100,175p' mobile/src/session/MobileNativeChatOverlay.tsx
printf '%s\n' '--- waiting-row integration test ---'
sed -n '1,230p' mobile/src/session/MobileNativeChatView.waiting-rows.test.ts
printf '%s\n' '--- session message/journal declarations ---'
rg -n 'messages:|nativeChatTurnJournal|nativeChatTurnJournal:' mobile/src/session/use-mobile-native-chat-session.ts mobile/src/session/MobileNativeChatOverlay.tsx mobile/src/session --glob '*.ts' --glob '*.tsx' | head -n 220

Repository: stablyai/orca

Length of output: 36939


Script executed:

printf '%s\n' '--- remaining overlay props ---'
sed -n '135,235p' mobile/src/session/MobileNativeChatOverlay.tsx
printf '%s\n' '--- controller pending wiring ---'
rg -n 'pending=|pending:' mobile/src/session/use-mobile-native-chat-controller.ts mobile/src/session/MobileNativeChatOverlay.tsx mobile/src/session --glob '*.ts' --glob '*.tsx' | head -n 180
sed -n '80,175p' mobile/src/session/use-mobile-native-chat-controller.ts
printf '%s\n' '--- opening-send predicate ---'
rg -n -F -- 'export function structuredAgentSessionOpeningSendIn' src/shared
f=$(rg -l -F -- 'export function structuredAgentSessionOpeningSendIn' src/shared | head -n 1)
[ -n "$f" ] && sed -n '1,240p' "$f"
printf '%s\n' '--- pending echo source ---'
rg -n 'MobileNativeChatPendingItem|build.*pending|pending.*messages|pendingEcho|pendingItems' mobile/src/session --glob '*.ts' --glob '*.tsx' | head -n 220

Repository: stablyai/orca

Length of output: 20178


Script executed:

printf '%s\n' '--- mobile native chat drafts state ---'
sed -n '1,330p' mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.ts
printf '%s\n' '--- draft pending types and send transitions ---'
rg -n 'acceptSend|holdUnconfirmedSend|pendingKey|pending:' mobile/src/session/native-chat/composer mobile/src/session/use-mobile-structured-agent-state.ts mobile/src/session/use-mobile-structured-send-with-outcome.ts mobile/src/session/use-mobile-native-chat-controller.ts
printf '%s\n' '--- structured send/turn handover references ---'
rg -n 'handedOverAt|dispatchState.*pending|acceptedSequence|chatPending' mobile/src/session src/shared --glob '*.ts' --glob '*.tsx' | head -n 260

Repository: stablyai/orca

Length of output: 28823


Script executed:

printf '%s\n' '--- controller structured/session bindings ---'
sed -n '230,300p' mobile/src/session/use-mobile-native-chat-controller.ts
sed -n '1,130p' mobile/src/session/use-mobile-structured-agent-state.ts
printf '%s\n' '--- native session stream state ---'
sed -n '1,120p' mobile/src/session/use-mobile-native-chat-session.ts
sed -n '240,300p' mobile/src/session/use-mobile-native-chat-session.ts
printf '%s\n' '--- pending retirement contract ---'
sed -n '1,220p' mobile/src/session/native-chat/mobile-native-chat-pending-retirement.ts

Repository: stablyai/orca

Length of output: 22307


Script executed:

printf '%s\n' '--- reading place implementation ---'
sed -n '1,190p' mobile/src/session/mobile-native-chat-reading-place.ts
printf '%s\n' '--- footer implementation ---'
sed -n '1,100p' mobile/src/session/mobile-native-chat-list-footer.tsx
printf '%s\n' '--- page-list invocation and props ---'
rg -n 'MobileNativeChatRowList|MobileNativeChatPageList|emptyStateView' mobile/src/session/MobileNativeChatRowList.tsx mobile/src/session/MobileNativeChatPageList.tsx mobile/src/session/MobileNativeChatView.tsx

Repository: stablyai/orca

Length of output: 9029


Script executed:

sed -n '1,110p' mobile/src/session/MobileNativeChatRowList.tsx

Repository: stablyai/orca

Length of output: 2422


Keep the marked row box when waiting rows are present.

During an opening-send handover, chatPending can be the only transient row while the transcript is still empty. The waiting helper moves that row to turns.waitingRows, so rows.length === 0 is true while the footer still renders a tagged row.

The current branch omits the marked row box. mobileNativeChatReadingPlace then finds no rows, so resize restoration cannot preserve the reader’s position.

Suggested fix
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
@@
   const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem)
+  const hasWaitingRows = turns.waitingRows.length > 0
@@
-                  {...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
+                  {...{
+                    tail,
+                    rowKeys,
+                    renderItem,
+                    footer,
+                    emptyStateView,
+                    hasWaitingRows,
+                    onScroll,
+                    layoutKey
+                  }}

diff --git a/mobile/src/session/MobileNativeChatRowList.tsx b/mobile/src/session/MobileNativeChatRowList.tsx
@@
   | 'emptyStateView'
+  | 'hasWaitingRows'
@@
-        {...{ header, footer, emptyStateView, onScroll, onNearTop }}
+        {...{ header, footer, emptyStateView, hasWaitingRows, onScroll, onNearTop }}

diff --git a/mobile/src/session/MobileNativeChatPageList.tsx b/mobile/src/session/MobileNativeChatPageList.tsx
@@
   emptyStateView: ReactElement | null
+  hasWaitingRows: boolean
@@
   emptyStateView,
+  hasWaitingRows,
@@
-        {rows.length === 0 ? (
+        {rows.length === 0 && !hasWaitingRows ? (

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick comments: 1
mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts: 1

113-144: Trivial · Performance & Scalability · Quick win

Add a production-boundary render assertion.

The test uses local Screen and Composer components and calls owner?.setComposerText('typed') directly. It does not render the production controller-to-overlay-to-view path.

The production path passes controller.setChatComposerText through MobileNativeChatOverlay to MobileNativeChatView. A draft subscription added to that controller or view can reintroduce screen redraws while this test still passes. The existing parity test also mocks MobileNativeChatDraftComposer and does not count renders.

Add a controller/view integration assertion that types through onChangeText and fails when the production owner rerenders per keystroke. This is a material, nonduplicative coverage improvement, not evidence of a current production failure.


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 961ea824-7807-4fd9-97e0-32c275f5d8f9
Commits

Reviewing files that changed from the base of the PR and between 9cd08fe and 1774724.

Files selected for processing: 2
  • mobile/src/session/native-chat/composer/mobile-native-chat-draft-store.test.ts
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts
Files with no reviewable changes: 1
  • mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

@AmoabaKelvin
AmoabaKelvin merged commit b1d1200 into main Oct 27, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: in orca mobile the scrolling up for a chat and the typing a prompt is getting very lag

1 participant