iris: a transcript row is a column of markdown blocks, so a streamed delta costs one block

A row was one TextEdit holding the whole message, so every delta
re-shaped every paragraph of a long reply through parley -- the one phase
where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2).

- client-core/src/markdown_blocks.rs: split a message into its top-level
  blocks with their source, through the same pulldown-cmark the renderer
  parses with so the two cannot disagree about where a block starts, plus
  common_prefix. Appending markdown can rewrite an earlier block (a
  trailing --- turns the paragraph above into a heading), so the fast
  path compares the prefix it keeps rather than assuming it -- with the
  test that says so.
- transcript-ui: a row is a Span of one TextEdit per block;
  RowBlocks::apply_delta replaces the block a delta lands in;
  TranscriptScreen keeps the tail row's blocks, seeded in build_tree as
  well as push_row (a screen opened onto a streaming reply took the
  rebuild path for its first delta otherwise, with nothing to say so).
- A block is the selection unit: Selection is keyed by (RowKey, u32),
  which is reading order at both levels, and the pointer-captured half of
  a drag resolves the block under the finger from its drawn box
  (Selection::locate) instead of from the row's extent.

Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
drives a real UiRenderState and asserts the draw count for a delta into a
100-paragraph (3,000+ char) reply equals the count for a one-paragraph
one. 30 either way; it read 630 against 30 twice on the way there.

Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms,
p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202
-> 293 frames in the same 21 seconds. Selection across blocks verified
with a real long-press drag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 17:33:37 -04:00
1 parent 167862ca1b
commit e1030d69f6
14 files changed
+871 -82

No files matched your search

+12 -9
View File
@@ -191,14 +191,17 @@ does not repeat it again by hand.
## What is not started at all
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a
renderer walks to lay out prose versus code versus a table.
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
AST is Compose rendering plumbing, not something to port as-is; a Rust
UI layer will want its own block parser or a crate for it, decided
alongside the framework choice in RUST.md.
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
into its *top-level* blocks -- heading, paragraph, fence, list, table,
quote -- with each block's own source, which is what a renderer needs to
lay out prose versus code and what lets a streamed delta re-lay out one
block instead of the message (docs/RUST.md's Task B). What it
deliberately does **not** build is the tree below that: nested list
items, table cells, inline spans. Inline styling is still the renderer's
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
for a full CommonMark AST is Compose rendering plumbing, not something
to port as-is.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
@@ -209,5 +212,5 @@ does not repeat it again by hand.
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`
(109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
this writing (2026-09-06).
+30
View File
@@ -8,6 +8,36 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
It makes one per top-level markdown block now -- heading, paragraph,
fenced code, list, table -- in a `Span::down`, because a streamed delta
into a single buffer re-shaped the whole message through parley on every
event. `client_core::markdown_blocks::split_blocks` does the splitting;
`row::RowBlocks::apply_delta` updates the block a delta lands in and
leaves the rest of the message's layout alone.
**The change to judge, since it is what a reader feels**:
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
so **a block, not a row, is the unit a selection steps in**. A drag still
runs from a reply into the tool output beneath it and copies as one
thing; what changed is that the row under the finger is filled in block by
block rather than all at once, which is if anything closer to what the
old shortcut in `Selection`'s module doc was apologising for. `register`
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
block of it (dropping only the first is how a freed widget gets left in
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
`Selection::locate(ui, render, pos_window)` is new: which block is under a
window position, with that block's own local position and size. The
list-level handler uses it for the pointer-captured half of a drag,
instead of computing a row-local position from `List::extent`.
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
the third is the per-block state a caller keeps only for the row a reply
is streaming into, and is `None` for a tool run, which never streams.
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
+17 -2
View File
@@ -643,8 +643,23 @@ do not duplicate it there.
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
- [ ] **Streaming a delta into a long message costs a full text layout of
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
- [x] **Streaming a delta into a long message costs a full text layout of
that message.** **Done 2026-09-06** -- a row is a column of one
`TextEdit` per markdown block (`client_core::markdown_blocks`,
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
keeps every earlier block's layout. A block is the selection unit now
(`Selection`'s `SelKey`); selection across blocks and rows still works,
checked on the emulator with a real long-press drag. Pass condition met
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
a delta into a 100-paragraph reply redraws the same widget count as one
into a one-paragraph reply (30 either way). Emulator stream phase, same
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
ends. **The phone is the measurement that decides it** -- these are
emulator numbers and only the ratio transfers.
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
the last row, but that row is the growing message, and replacing it
+66 -5
View File
@@ -107,17 +107,78 @@ Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
and printed a report that had never been run. It polls for the report's
own first line now.
### Bench, before Task B (emulator, 2026-09-06)
### Task B, closed 2026-09-06: a streamed delta costs one markdown block
A transcript row was one `TextEdit` holding the whole message, so every
delta re-shaped every paragraph of a long reply through parley -- the one
phase where iris trailed Compose on Iris's phone. A row is a **column of
one `TextEdit` per top-level markdown block** now, and a delta that lands
in the last block is one `set_with_spans` on that block.
- **`client-core/src/markdown_blocks.rs`** is the split: `split_blocks`
(top-level blocks with their source, via the same `pulldown-cmark` the
renderer parses with, so the two cannot disagree about where a block
starts) and `common_prefix`. Seven tests, including the one that says
the fast path must **compare** rather than assume: appending `---` under
a paragraph turns that paragraph into a heading, so an already
laid-out block is not always still what it was.
- **`iris/transcript-ui/src/row.rs`** builds the column and owns
`RowBlocks::apply_delta`; **`lib.rs`** keeps the *tail* row's blocks
(`TranscriptScreen::tail`) since that is the only row a delta reaches.
- **A block is the selection unit**, not a row: `Selection` is keyed by
`SelKey = (RowKey, u32)`, which compares in reading order at both
levels so every range query in that file is unchanged. The list-level
(pointer-captured) half of a drag resolves the block under the finger
from its drawn box (`Selection::locate`) instead of doing arithmetic
from the row's extent.
**Pass condition, met**: `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`
(`transcript-ui/src/lib.rs`) drives a real `UiRenderState` and asserts the
`Widget::draw` count for one delta into a 100-paragraph (3,000+ character)
reply equals the count for the same delta into a one-paragraph reply.
**30 either way.** It is a real test, not a tautology: it read **630
against 30** at three points on the way -- once because `Span`'s measure
pass redrew every child, and once because `build_tree` did not seed
`tail`, so the first delta after opening a screen took the rebuild path
with nothing on screen or in `take_rebuilds()` to say so.
Two things tried and dropped, so the next session does not redo them.
`Painter::measure` (a container asking a clean child for its size instead
of drawing it provisionally) fixed one of the 630s but the test passes
without it once the `tail` seeding is right, so it was removed rather than
kept on speculation. And the emulator's own numbers say the remaining
cost is not in the block split.
**Verified on the emulator** beyond the counter: the transcript draws its
blocks with their own spacing (heading, prose, fence), and
`ui-trace record --do "holddrag 300 700 700 1000 700 600"` logs
`iris selection: begin at row (3187, 0)` then `extend to row (3187, 1)`
with the highlight crossing from the heading into the code block -- a
selection that spans blocks, which is what the re-key had to keep.
### Bench, stream phase, before and after Task B (emulator, 2026-09-06)
`iris/android-app/build-apk.sh debug --abi x86_64 --features
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
same emulator does.
stream: 202 frames over 21.0s
late: 197 (97.5%)
total p50 61.5ms p90 211.7ms p99 342.6ms
worst 403.6ms
Same AVD, same fixture, same build flags, 20 minutes apart. Emulator
absolutes transfer nothing; the ratio does.
before after
stream: 202 frames over 21.0s stream: 293 frames over 21.0s
late: 197 (97.5%) late: 285 (97.3%)
p50 61.5ms p50 54.5ms (-11%)
p90 211.7ms p90 113.1ms (-47%)
p99 342.6ms p99 137.4ms (-60%)
worst 403.6ms worst 143.0ms (-65%)
The tail is where the whole-message re-layout lived, and it is where the
change shows: 91 more frames delivered in the same 21 seconds. The p50
moves least, which is consistent -- a delta into a *short* message never
cost much. **The phone number is Iris's to take**; nothing here is a
statement about her device.
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit