Opening the keyboard was late on 82% of frames on the Pixel while the
transcript itself cost 0.25ms of each -- the cost was everywhere else.
imePadding() sat on the activity's root box, so every frame of the IME
animation resized the entire tree: measured on the emulator (with the
new app-root timers) as a full re-measure (4.1ms), re-place (2.5ms) and
re-record (1.0ms) of everything, ~34 frames per open, while the newly
added recomposition counters read zero -- pure layout traversal, no
recomposition to fix.
So the keyboard now touches only what actually moves. The session
screen's composer (status row, suggestions, attachments, text field,
buttons) is a bottom-aligned overlay on its own layer, translated by
the IME inset read inside the graphicsLayer block -- a keyboard frame
invalidates layer properties only. The transcript box reserves the
overlay's measured height plus imePadding, and that modifier is the
whole of what the keyboard re-measures: the box's own size never
changes, so the header and everything above it are untouched. The
overlay is opaque for the one frame between it growing and the
reservation catching up. imePadding moved off the activity root onto
AppRoot's other screens, which keep the old arrangement -- none of
them has a keyboard open over anything that scrolls at 120Hz.
Same five-open protocol on the emulator, before and after: the app
root is now measured zero times (was 168), per-frame app work
7.6ms -> 1.9ms (transcript measure 1.2 + place 0.5 + record 0.2),
draw-phase p90 9.6ms -> 5.0ms, waited p50 2.6ms -> 0.4ms. What is
left per frame is the transcript's own one-box remeasure, whose
children skip measurement because their width is unchanged.
Verified the states the overlay could have broken: keyboard over a
long and a two-message conversation (content hangs from the composer
in both), a three-line draft growing the composer upward with the
reserve following, slash suggestions stacking above the field, and
the closed state identical to before. The app-root timers and the
recomposition counters stay in: they are the difference between this
report saying "draw is high" and saying where.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Their b3cebd0 simplifies TranscriptScroll's retained window; this branch
deleted the file -- the lazy list is the window now. Deletion wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt
The plain Column held every loaded row and reimplemented what a lazy
list is -- windowing (retained ranges, stand-in spacers), height
bookkeeping (a heights map and prefix-summed tops), scroll anchoring,
and restore -- and every constraint kept intersecting that surface
somewhere new: the retained window was a fresh way to flicker, the
spacers hit the Constraints height limit, the IME re-measured the
world, and the restore needed pending anchors threaded through layout.
The reason a lazy list was abandoned no longer holds. It was dropped
when an item was a whole message, and a message can be twenty-five
screens tall -- composing one mid-fling is a hundred-millisecond frame.
The block splitting built later (for draw granularity) is the missing
piece: with one item per markdown *block*, entering composition costs
laying out a paragraph, and the parse is already cached by the same
warm() that always ran. So the transcript is now a
LazyColumn(reverseLayout) over TranscriptUnits -- settled replies
flattened to block items, everything else one item, the live reply kept
whole because its text changes per delta.
What each constraint rests on now, all verified on the emulator against
a real 1200-event imported transcript at --delay 120:
- Following the newest message, paging in history, and the keyboard are
the reversed layout itself: item zero is the bottom, an arriving
message extends the pinned end, a page lands past every visible
index, and an IME resize keeps the anchored item against the
composer. A short conversation stacks from the bottom.
- No item enters unready: pages are warmed before the fold lands (the
opening page now folds a scratch copy off-thread first), so heights
are real on first measure and scrolling back is cache hits -- zero
markdown parses on the composing thread across a full page-back
through all 1200 events.
- Restore resolves the saved seq to a unit index and snaps before the
draw gate lifts; anchors gained a unit ordinal (ScrollAnchor grew a
third field, read compatibly) so a position inside a forty-block
reply survives.
- Unloaded history is a spinner item at the far end while moreHistory
holds; paging triggers on estimated pixels ahead (units remaining at
the typical visible unit size), since a lazy list has not measured
what it never composed.
- The tap-half expansion anchoring survives, but through
requestScrollToItem: a raw dispatchRawDelta from onSizeChanged forces
remeasure inside the measure pass and crashes
("performMeasureAndLayout called during measure").
Deleted: TranscriptScroll.kt entirely (1000 lines of window, spacers,
tops, anchors, seeding). ParsedReplies gained a parts cache so the
per-fold flatten never re-scans a settled message, and clear() now
empties all three maps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three faults, all in how the outer bound behaved, and all of them mine.
**It was a destination.** Rows were stood up towards it a couple at a time
until the window was the full sixteen screens, whether or not anybody was
going to read them. Two rows is not a bounded amount of work -- a row here
runs to twenty-five screens of markdown -- so the transcript was laying
text out continuously in the background, and that is what the 70ms
measurements were. The bound is now a limit: the window grows because the
reader moved, and never speculatively. `standUpSome`, `growing` and the
frame loop that drove them are gone.
**It moved in two-screen steps**, which is the boundary that could be felt.
Crossing one moved the limit two screens at once and cut a chunk off the
trailing edge in a single frame; coming back the other way needed all of it
again, so oscillating around a boundary rebuilt the same rows repeatedly --
measured in the last report as twenty-two rows dropped and immediately
wanted back, which is also where the flicker was coming from. Recomputed
every time, the trailing edge retreats a row at a time and there is no
boundary left. The step existed because this used to be a scan of every
row; it has been a binary search since the last commit.
**It was measured against the viewport as it is now**, so the IME animation
shrank it on every one of its thirty frames -- trimming the far edge,
disposing rows and recomposing the list, thirty times, for a keyboard. How
much to keep alive is not a question the keyboard has any business
changing: it is about how far the reader might scroll, and they can scroll
just as far with it open. It is sized against the tallest the viewport has
been.
The bound also came down from eight screens to five. Eight was chosen when
keeping a row alive was believed to be free; the report now says plainly
that it is not -- three quarters of the draw phase is framework bookkeeping
that grows with live nodes rather than with what is on screen.
Measured on the emulator, eighty swipes oscillating across what used to be
a boundary: measurements of the transcript went from 51 at 8.3ms mean and
70.3ms worst to 11 at 2.3ms mean and 4.3ms worst, and rows composed from
270 to 10. Three keyboard cycles leave the transcript at 0.36ms of the
frame's draw phase. Three open/scroll/close cycles report nothing unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, and the middle one is why the last commit's "zero flickers"
was worth nothing.
**The detector was measuring the wrong thing.** `covered()` compared what
is on screen against `retained` -- which is the *intent*. Those came apart
the moment the window started being recomputed during layout, because it is
then updated before the frame draws: the check passed while the screen was
still showing the spacers from the composition before. It now compares
against what the list actually composed, recorded as it composes it. The
first honest run found the open path still failing, and the counters named
a branch of my own that did nothing at all -- position lost, scroll
container reporting a measured position from a *different* session, so
neither arm ran and the window was left empty. The whole transcript then
composed as a single spacer. The test is no longer "is there a position to
trust" but "is the window empty", which is the question that was actually
being asked.
**The scan was the lag.** Putting the recompute where the layout is made it
correct and put a linear pass over every row into every frame with it --
0.5ms to 1.1ms of placement at 190 rows, the same O(rows)-per-frame mistake
this file exists to undo, reintroduced by its own fix. The tops are
ascending by construction, so it is a binary search.
**The button now does the subtraction.** The draw phase carries Compose's
measurement as well as its recording, so "draw is high" never said which of
three things was high, and the split was being worked out by hand in a
conversation every time a report arrived. It reports the per-frame split
directly: the transcript's own measure, placement and recording, and what
is left, which is the framework's bookkeeping after a layout.
The immediate window went from two screens to three, and from six rows to
twelve. What the list has composed is always one frame behind what the
window says -- the window is recomputed in layout and the rows it names are
built by the next composition -- so the margin has to cover a frame of
movement as well as the gap between recomputations. Measured down from
misses of one and two rows.
Verified: three cycles of open, fling up through a page load, fling back,
close, on a 92-row session, plus reopens of a short one. Zero, with the
detector that no longer flatters itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Can't represent a width of 0 and height of 273238 in Constraints`, from
`Modifier.height` during measure, which took the app down on opening one
particular session. Compose packs a Constraints into a single Long, and at
a width of zero that leaves eighteen bits for the height -- so 262,143px is
the ceiling and a taller spacer throws.
Collapsing every stood-down row into one spacer is what made that
reachable. It is the change that stopped the per-frame cost growing with
the conversation, and it also turned "the history above the window" into a
single fixed height -- which for a long conversation read at the newest end
is the whole transcript. The one that crashed was 273,238px.
Split rather than clamped: the height is load-bearing. It is what keeps the
transcript's total the same as the rows it stands in for, so shortening it
would move everything under the reader -- a silent wrong answer in place of
a loud one. The gaps the arrangement inserts between the pieces come out of
the total for the same reason.
The cap is 100,000px rather than the 262,143 that would just fit, because
the limit depends on how many bits the width took: a spacer sized against
today's screen width is a crash waiting for a wider one.
Verified by forcing the split -- built with the cap at 5,000px, a run came
out as ten spacers with the transcript intact and nothing reporting an
unbuilt row -- then restored. Testing it only at the real cap would have
tested the branch that cannot fail on any conversation reachable here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"It still flickers sometimes" is not something a fix can be tested against,
so the first change is a counter. Drawing is the one place that knows both
what is on screen and what was built, so it asks: `covered()` compares the
rows in the viewport against the window and records how far short it fell
and which way. That turned a symptom into a number, and the number found
four separate causes -- three of which I would not have guessed, and one of
which I had already "fixed" twice.
- Opening a session recomputed the window before the scroll container had
measured anything. `maxValue` is zero then, which reads as the reader
being at the oldest end, so the window landed a whole transcript away
from where the session was about to open.
- A page of history recomputed it from a scroll position one layout out
of date -- stale by exactly the height of the page that had just
arrived. The reader's position is now carried across the change as a
row rather than as a pixel, and resolved through the row that *holds*
that seq, because a regroup can fold the row it named into another.
- The window was widened to cover the screen only when it was recomputed,
which was every two screens of movement. A fling covers a screen in a
frame or two, so it outran the window and arrived at rows that were
still spacers. Standing rows up a few per frame made that worse rather
than causing it: after a seed the built window is two screens wide and
grows two rows a frame. What is near the screen is now widened every
frame; only the outer bound is lazy.
- Both were measured in pixels, and a pixel budget cannot know how many
rows it covers until the rows have been measured -- which is never, on
the frame a session opens. The margins are now a number of screens *or*
a number of rows, whichever is larger.
The recompute moved to the transcript's placement, which is the one moment
both halves are current: the rows have just been measured and so has the
scroll container. Everywhere else it ran could be right about one and stale
about the other. It writes only when the answer changes, so a frame where
nothing moved costs one scan and no recomposition.
Two supporting fixes. `covered()` also repairs, so however the window goes
stale the damage is one frame rather than until the next scroll. And the
scroll position is now keyed per session -- `rememberScrollState()` is not
keyed, so a second session opened without leaving the screen inherited the
first one's offset and, worse for the window, its `maxValue`.
Verified on the emulator: flings up and down, three scroll-and-restore
cycles on a 92-row session and three reopens of a short one -- zero, where
before each reopen cost one to three. Placement stayed at 0.8ms.
Also here: crashes are recorded and travel out through the debug button, so
"it crashes opening that chat" arrives with a stack next time; the report
goes to the log as well as the clipboard, so a session driving the app over
adb can read it; and `trace-draw.sh` captures a labelled frame breakdown,
with a note that it must be run against the phone -- on this emulator two
thirds of a frame is `dequeueBuffer` and Compose's own draw is 0.40ms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all of them the same shape: work done because something was
asked a question at the wrong moment.
The keyboard. The layout modifier passed `minHeight = viewportSize` down
to its child, so every frame of the IME animation changed the child's
constraints -- and changed constraints are exactly what defeats the
early-return in `MeasurePassDelegate.remeasure`. The whole transcript was
re-measured on the way up, measured on a Pixel 9 Pro XL as 250
measurements averaging 2.8ms with a 54.5ms worst. The minimum is applied
to what this node reports now, not to what it asks its child for, so the
child early-returns; the content is placed against the bottom to keep the
anchoring the minimum existed for. It only ever bites on a conversation
shorter than the screen, which was never the case paying for it.
The flicker. `retained` starts empty, so on the composition that
introduces the rows every one of them is outside the window, the whole
transcript collapses to a single spacer, and it draws blank for a frame.
Seeded now -- at the newest end on open, and around the anchor on a
restore. The restore case is the one that bites: `placed()` jumps the view
during placement, and a window recomputed there only schedules a
recomposition, so the destination would draw as spacer for a frame or two
after drawing ungates. Seeded by pixels rather than by a row count,
because a run of tool calls is eight rows and less than half a screen.
The block layers. Every paragraph of every reply had a layer, which was
right when whole rows were re-recording constantly and one row's display
list was 36,982px tall. Re-recording is rare now -- 65 whole rows in fifty
seconds of reading -- and a layer costs a layout node and a display list
held for the life of the row, against the node count the per-frame cost
scales with. They go to the message still arriving, which is the only one
whose drawing is invalidated often enough to want the granularity.
Verified on the emulator against the case none of this was written for: a
two-message conversation far shorter than the viewport still hangs from
the composer, with the keyboard both up (messages at y1756-1940, composer
2109) and down (936-1120 against 1289).
Mechanism for the block layers and the hole in the restore seeding both
from the ai-app-2-6d session; the layers are not re-positioned per frame
as I had it, they are baked into the row's display list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things committed in 9052e5f and 94b1509 were never wired up. The row
count was tracked and nothing observed it, so the fix for the gap when
sending was inert; `standUpSome` and `growing` were written and nothing
called them, so the window still jumped in one step. Both are connected
now, which is what the last two commit messages already claimed.
The third thing is what the counters were pointing at all along. The
frame's draw phase sat at a flat 7.7ms -- p99 only 1.8x p50, so not
shaping, which is spiky -- while the transcript's own recording ran 43
times in 3638 frames and accounted for a fortieth of it. A stood-down row
drew nothing but was still a layout node, and the framework's per-frame
bookkeeping after a scroll walks live nodes rather than visible ones. So
the cost grew with the conversation instead of with the screen, which is
exactly the step the reader felt at each page loaded.
The rows outside the window are now two spacers rather than one per row,
which they can be because the window is a range: what is not in it is one
run before and one run after. Live nodes are bounded by the window now.
The gaps between collapsed rows are added into the spacer, because
`spacedBy` puts a gap between children and a run drawn as one child is
short by all of them -- which would not read as a spacing bug, it would
move everything under the reader.
Measure and place are timed separately now. The draw phase carries
Compose's measurement, so "draw is high while nothing is recorded" could
not say which half, and the two have different fixes.
Diagnosis from the ai-app-2-6d session: foundation already places scrolled
content with `placeRelativeWithLayer`, so the scroll is a layer transform
today and re-placing children was never the cost -- the node-count walk
is, and collapsing the runs is the experiment that settles it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The counters now say which half is left. Re-recording is rare -- 157 row
display lists in a minute of scrolling, 407ms in total -- while the
frame's draw phase sits at 7.9ms. Since that phase also carries Compose's
own measurement, what is left is laying text out: shaping glyphs, on the
thread drawing the frame, and none of it timed by anything here.
The window moved in one step, so every step boundary shaped two screens
of markdown inside a single frame, a page landing did the same, and
opening a session did seventeen screens of it in one. It now walks
towards its target a couple of rows a frame. That does not make the work
cheaper and is not meant to: it stops it arriving together, which is the
difference between a frame that is late and a frame that is missed by
ten.
What is on screen is never amortised. The visible range goes up in the
frame it is needed whatever else is pending, and only the margin being
read *towards* is spread out -- so this cannot show anybody a gap, which
is the failure the last two changes in this area both had.
Diagnosis and the ordering from the ai-app-2-6d session, whose test this
follows: records small and draw high means shaping rather than recording.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults from making the retained window lazy, both of them mine.
A screen of blank between the last message and the box it was typed in.
The window was recomputed when the view had moved far enough, and a
message arriving does not move the view -- so the new row fell outside
the window and stood in as a spacer of its guessed height. The version
number the check compares against is only bumped by the recompute it
guards, so asking before refreshing meant never noticing. It refreshes
first now, and the window also watches how many rows there are, because
a row arriving is the case it exists to catch and the one that does not
announce itself through the scroll position.
And the keyboard, which was the most expensive thing on the screen. The
visible height came from a `BoxWithConstraints` wrapped around the
transcript -- that is a `SubcomposeLayout`, and the IME animation changes
the visible height on every frame of its slide, so the entire transcript
was being subcomposed again for each of them. The same number read in
the layout phase, from the scroll container's own measurement, makes it a
relayout instead, and the rows keep the measurements they already have.
Checked on the emulator: the newest message sits against the composer
with the keyboard up, and there is no gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same mistake as the draw-phase culling, moved into another phase.
Each row held a `derivedStateOf` over the scroll position to decide
whether it stayed built. That reads correctly and costs the same shape:
every one of those derived states is invalidated by every scroll frame
and has to be re-evaluated to learn whether its answer changed, so the
per-frame work grew with the number of loaded rows again -- this time
landing in the recomposition pass, which the platform reports as the
frame's animation phase. On a Pixel 9 Pro XL at 153 rows that was 11.6ms
at the median, against an 8.3ms frame, and it was the largest term left.
The window is now one range that every row reads, recomputed once a
frame from one observer. And recomputed lazily: every row reads it, so
every change to it disturbs all of them, which is affordable once every
couple of screens and is not affordable at row boundaries, where a fling
would cross one every few frames. The window is eight screens either
side and it moves in steps of two, so the margin absorbs the staleness.
Worth naming as a pattern, since this is the third time: a per-row
answer to a question about the scroll position is O(rows) per frame
wherever it is evaluated -- in draw, in a derived state, anywhere. The
question has one answer and it belongs in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scrolling up emptied the screen and only reopening the session brought
it back. The guess for an unmeasured row's height was re-derived from a
running average each time it was wanted, and that average moves as rows
are measured -- so the height a spacer had been *built* at stopped
matching the height the running totals were adding up, and the two drifted
apart. Once they differed by more than the retain window every row failed
the distance test at once, and a transcript of nothing but spacers has
nothing left to measure and so nothing to correct itself with.
Two changes, and the second is the one that matters. A guess is now made
once per row and kept, so it cannot drift from what was built with it.
And which rows stay built is decided as a *range* rather than by each row
testing itself: the row nearest the viewport is in that range by
construction, whatever the arithmetic says, so the worst a mistake here
can do is build too few rows or too many. A blank transcript is no longer
a state this can reach.
That is the shape worth keeping from the bug. Every row answering
independently meant one wrong number could stand all of them down
together, and the failure was silent, self-sustaining, and looked exactly
like the screen having nothing to show.
Checked by scrolling sixty swipes to the oldest loaded end and back --
the content holds throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layers took care of the steady state -- the transcript's content
re-recorded fifteen times in a thirty-second scroll, and the frame's
draw phase sits at 3.5ms. What is left is entirely spikes, and they are
pages landing: one of those fifteen recordings took 99.9ms on its own,
with the frame after it unable to start.
The cause was a rule that read as caution and was not. A row with no
measured height was retained whatever its distance, on the grounds that
it had just been paged in and was about to be looked at -- but "no
measured height" describes the *whole page*, not the near edge of it, so
eight hundred events' worth of markdown was shaped inside the frame the
page arrived in.
A row that has not been measured now stands in at the average of those
that have, so it is placed and judged by distance like every other row
and is built when the reader comes near it. The average rather than a
constant because these run from a one-line note to a screenful, and the
average row in a conversation is a fair guess at the next one. Being
wrong is cheap here and self-correcting: an estimate is only ever used
above the viewport, and this layout hangs from its far end, so a
correction up there moves nothing on screen.
Checked by scrolling twenty-five swipes into history and back on a real
transcript -- rows stand up as they are reached, and the position does
not shift as the guesses are replaced by measurements.
Second half of the diagnosis from the ai-app-2-6d session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The piece of a lazy list this had not rebuilt was the render node per
item. Without one, a row's glyphs are recorded into its parent's display
list, and that list is re-recorded every frame the parent is invalidated
-- which, while the list is scrolling, is every frame. With one, the row
is recorded once and afterwards moved by a transform, and the render
thread culls the ones off screen itself.
The draw-phase culling it replaces could not have won, and the two are
mutually exclusive rather than complementary: `onScreen` and `RowWindow`
read the scroll position from inside every row's and every block's
drawing, and reading a scroll position during draw invalidates the
drawing it is in. So the machinery for deciding which rows need not be
drawn was re-recording all of them, every frame, to decide it. Keeping
both would have bought the cost of the first and none of the second.
Measured on the emulator over a thirty-swipe scroll: **one** row display
list recorded across 481 frames, against 1,544 recordings for the same
gesture before, and the frame's draw phase down from 4.2ms to 2.9ms at
the median.
Two things this also corrects. `LAYOUT_MEASURE_DURATION` is the *View*
hierarchy's pass, and Compose is one view -- `AndroidComposeView.
dispatchDraw` calls `measureAndLayout()` before it records, so all of
Compose's own measurement, text shaping above all, is reported inside
DRAW_DURATION. Every "layout is 0.0ms, so nothing is being re-measured"
reading in this file's history was reading a bucket that never contained
it. And the retain window is now load-bearing for a second reason: a
retained display list costs memory, so bounding what is retained bounds
that too.
Found by the ai-app-2-6d session reading the render reports against this
code; the diagnosis and the ordering are theirs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A page of history landing recomposed every loaded row and everything
inside it -- 701 compositions for 148 rows in one of Iris's scrolls,
which is four rebuilds of the whole visible transcript, markdown and
all. That is what her `waited` at the 90th percentile was: the frame
could not start because the thread was rebuilding rows whose content had
not changed.
Nothing was stopping Compose skipping them except that it could not
prove it was safe to. Stability is inferred from a class's fields and a
`List` field makes it assume the worst, so `TranscriptItem`,
`TranscriptRow` and `ParsedReplies` were all treated as things that
might change underneath a composable at any moment. They are not: a row
is rebuilt from the transcript rather than edited, two rows describing
the same events are equal, and the parse cache is keyed on the text it
parsed. Saying so is the whole change.
Measured on the emulator over the same scroll: 139 row compositions, and
**15** of them rebuilt the message inside. The other 124 skipped
straight past the markdown, which is where the cost was. The
transcript's own draw came down with it, from 3.1ms mean to 1.2ms.
The promise these annotations make has to stay true -- nothing described
by them is mutated after it is built. It is not today, and the note on
`TranscriptRow` says so where somebody adding a field will read it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frame is now failing to *start* rather than taking too long once it
has: 21ms of `waited` at the 90th percentile against a median frame of
8.8ms that is inside budget. What is holding it up arrives with a page
of history -- 1.5 seconds of markdown parsed in a twelve second scroll,
all of it work nobody is waiting for.
Off the composing thread was the right call and it is not the same as
free. The default dispatcher sizes itself to the machine, which is right
for work somebody is waiting on: a page's worth of parses takes every
core, and the thread that draws the frame queues behind one of them. Two
threads, and a yield between messages, leaves the phone somewhere to run
the frame.
Nothing here makes the parsing faster, and it should not: the whole
point of doing it ahead is that its duration does not matter. What
matters is that it stops being in the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iris's readings made the shape unarguable: smooth with one page loaded,
a step worse at the next, worse again at the one after, and 62.7ms at
the median by 390 rows -- with layout at 0.1ms and the GPU at 1.8ms the
whole time. The cost was following what was *loaded* rather than what
was on screen, and keeping every row built is the only thing in here
that does that.
So the window is bounded. Eight screens either side stay fully built,
which is about sixteen times what a lazy list keeps: everything somebody
has just read is still there, and only a deliberate journey back through
the conversation pays to rebuild anything. That was the point of
retaining rows and it survives; what does not survive is retaining all
of them.
A row outside the window is replaced by a spacer of the height it was
last measured at, so the transcript's total height is unchanged and
nothing under the reader moves. A row that has never been measured has
no height to stand in for it and is kept whatever its distance, which is
exactly the row that has just been paged in.
Each row decides for itself, in a composable of its own, from a derived
state -- so a row hears about the scroll only when its own answer
changes. Read from the list's body instead, every row would recompose
whenever any row crossed the edge.
On the emulator, same transcript and same gesture, the frame's draw
phase goes from 2.7ms at the median to 0.4ms. Iris's own timing reading
from before this says where the rest of her frame goes: the transcript's
whole draw is 3.0ms mean against a 4.2ms median draw phase, so the
median frame was already close to budget and what is left is the tail --
20ms of waiting and 2.3 seconds of parsing in bursts, both of which
arrive with a page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The counters did their job and then ran out: 4,090 rows drawn against
94,815 skipped, the tallest drawn block down from 36,982px to 1,765px,
and drawing still took 30.9ms at the median. Almost nothing is being
recorded and recording is still the expensive phase, so the next
question is not "how much" but "where" -- and there are only two answers
left. Either the little that is drawn is somehow costly, or the time is
not inside the transcript at all and every count above is beside the
point.
So the draw is timed at three levels that nest: the whole transcript,
one row, one block. If the transcript's own figure is most of the
frame's draw, the cost is ours and the rows and blocks say which. If it
is a fraction of it, the frame is being spent somewhere this has not
been looking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reply's display list holds every glyph of it and is re-recorded
whenever drawing is invalidated, so one long message costs as much to
draw as a hundred short ones and skipping the rows around it cannot help
while it is the one on screen. That was the whole of the remaining draw
cost: 97% of rows correctly skipped, and the tallest one still drawn was
36,982px -- about twenty-five screens in a single message.
So a message is cut into its top-level blocks and each is drawn, or not,
on its own. The cut comes from the parser's own boundaries rather than
from a line scanner looking for blank lines, which is what makes it
safe: a heading, a table, a fenced block and a list are each one node
whatever is inside them, so a loose list does not become five one-item
lists and a fence is never split down the middle. Checked against a real
reply whose list items are separated by blank lines -- it still draws as
one list with its bullets aligned, which is the case a blank-line split
gets wrong.
It bounds parsing too, which was the other symptom in the same reading:
one message took 1.4 seconds to parse as a single unit, and a block is a
paragraph.
The row and the message each know half of where a block is, so they meet
at an interface declared where it is used: the list supplies the row's
position, the message supplies the block's offset inside it, and drawing
a message does not have to know it is inside a transcript.
What this cannot divide is a single node, and a long fenced code block
is one -- so the report now also carries the tallest *drawn block*,
which is the number that says whether splitting bounded anything. This
emulator's tallest row is one such fence, which is why its own figures
do not move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Drawing costs too much" and "drawing was skipped and still costs too
much" need opposite fixes, and a millisecond figure cannot tell them
apart. So the report now carries how many rows were drawn, how many were
skipped, and the height of the tallest one that was drawn.
The first reading answers it. Skipping works -- 854 rows drawn against
24,078 skipped, so 97% of the transcript is correctly not being recorded
-- and the tallest row that *is* drawn is 36,982px. One assistant message
about twenty-five screens tall, whose display list holds every glyph of
it, re-recorded whenever drawing is invalidated. A row that size is a
hundred short rows as far as the draw phase is concerned, and no amount
of skipping its neighbours helps while it is the one on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iris's Pixel 9 Pro XL said which half was costing, and it was not the
half either of us was looking at. With 138 rows loaded: layout 0.0ms at
the median, GPU 1.9ms, and **draw 13.6ms** against a 120Hz budget of
8.3ms. Nothing was being re-measured and the phone's rasteriser was
idle; the UI thread was recording draw commands for a transcript that
was almost entirely off screen.
Retaining rows was right and stays. What does not follow the same rule
is drawing: the draw pass walks the whole tree, so a list that keeps
every row alive records every row every frame, and that cost grows with
each page of history -- which is exactly what "worse afterwards" was.
Composition and measurement are what must not be thrown away, because
they are what has to be rebuilt from nothing when the reader comes back.
A display list is rebuilt from a layout that is still there.
So each row skips its own draw when it is off screen, by more than a
screen's margin either side. The check reads the scroll position from
the draw phase, so moving the list invalidates drawing and nothing else,
and it is a lookup rather than a sum -- the running totals are rebuilt at
most once a frame and only after something has actually changed height,
since adding them up per row per lookup would have made the fix
quadratic in the thing it was fixing.
Also reports the three frame phases that were missing, which is why the
phases on that reading did not add up to the total: the time a frame
spent waiting for the UI thread to be free, handling input, and running
animations. About 15ms of the 30.6 was in that gap and unattributed.
On the emulator, same transcript, before and after: draw p50 3.9ms ->
2.5ms, p90 7.3ms -> 4.2ms, p99 48.6ms -> 5.2ms. The saving there is
small because only 60 rows were loaded; it is proportional to how much
is off screen, and on the phone that was 95,000px of content against a
1,474px viewport.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A speedometer left of the usage chart, because it is the same kind of
thing about the app that the chart is about the conversation. It copies
rather than opens: a screenful of timings read on the phone is a
screenful nobody can act on, and what it produces is a message to
whoever is looking at the code.
It carries both halves of the question, because "the scroll is laggy"
has two causes and one appearance. From the platform, each frame's cost
split into its phases -- if measuring and drawing are small and the
total is large, the time is going into rasterising and no amount of
doing less work per row will move it. From the app, counts and timings
of the work the transcript actually does: rows composed, replies parsed
on the composing thread rather than ahead of it, runs grouped.
Counts rather than frame times are the point of the second half. This
emulator scrolls at the same 21ms median as the stock Settings app, so
every app-level cost here is under the floor of what it can measure and
no number taken in it says anything about a 120Hz phone. How many times
a row was composed is the same number on any machine, and it is the one
that says whether the work follows what is on screen or everything ever
loaded.
Pressing it empties both, so two presses measure two separate stretches
of scrolling rather than one and then the same one again.
The first reading from the emulator already says something: measure and
layout are 0.0ms at the median, so nothing is being re-measured, and the
cost is 3.9ms of recording the draw against 10.9ms of GPU. It also shows
60 loaded rows composing 177 times across three page loads -- every row
recomposing whenever a page lands -- which is the next thing to look at
if the phone says the work is ours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`onPlaced` on the transcript content is the one callback left that would
run every frame, and on all but the two frames of a restore it looks at
a null and returns. Attaching it only while a position is waiting says
that in the modifier chain rather than in a branch inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for: more space between the header icons, and a box tall enough to
fill the header rather than sitting inside it. 48dp does both -- the marks
stand 31dp apart and 31dp from the screen edge, measured on the emulator,
where the 40dp square had them 23dp apart and 27dp from it.
It is also the platform's minimum touch target, which the previous size
was short of, and it is taller than any header's text: the session
header's row now takes its height from the button and needs no vertical
padding of its own, for the same reason the rows add no gap between two
buttons. The ring around the mark is still the only spacing rule; every
number here moved because it did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Holding every row alive turned two per-row callbacks from a cost paid by
whatever was on screen into a cost paid by everything ever loaded, which
is why loading one more page of history made the whole transcript lag
and each page after made it worse. Both fire for every registered node
on every layout pass:
- an `onGloballyPositioned` per row, kept so a tap could be told which
half of the row it landed in
- an `onGloballyPositioned` per clickable inside a row, for the same
reason, so a tool group paid several
Neither is needed. One gesture detector on the whole list records where
a touch went down in the content's own coordinates, and the row's own
start is added up from heights when somebody actually taps -- so the
transcript keeps a height per row, which changes when the row does, in
place of a position per row, which is wrong the moment anything scrolls.
Positions for the saved-scroll anchor come from the same sum, and the
anchor is applied once per layout from the content rather than once per
row.
The reply cap is gone at Iris's request; she wasn't seeing it and does
not want it for now. It was doing measurable work -- p50 rises from 16ms
to 20ms on the emulator with it removed -- so it is worth knowing where
to look if long history feels heavy.
That 4ms is the only figure here I trust. This emulator's own noise
between identical repeats is larger than the difference the callback
change makes (identical builds measured 3.5% and 9.3% of frames over
budget), and its p50 of 16ms is already past a 120Hz budget, so it
cannot rank any of this the way the phone will. The callbacks are gone
because they are O(rows) per frame by construction and the list no
longer bounds how many rows there are -- not because a number here says
so.
Checked on the emulator against a real 1,200-event transcript: tapping a
collapsed row expands it with its top edge held exactly still, and a
scroll position comes back pixel-identical after leaving the session and
returning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every gap around a header icon now comes out of the button's own padding:
one ring to the screen edge, two where a button meets its neighbour. The
box was the size of the mark (28dp) and the separation was bolted on
beside it, so on the session header the two marks stood 31dp apart while
the outer one was 14dp from the edge of the screen -- a pair that acts on
one screen reading as two unrelated marks, one of them falling off it.
Measured on the emulator at 40dp: 23dp between the marks, 27dp to the
edge, and the same ring above and below.
The mark was also not square, which is why an arrow looked taller than it
was wide. `Glyph` is a `Text`, and it inherited the body style's 24sp line
height around a 17sp mark; this font's ascent and descent add up to
exactly one em, so a line height of the point size is the square the glyph
draws in. That leading is also what a button's padding had to be measured
through.
40dp is Material's own icon-button state layer, and it is what the
pressed-state ripple draws -- at 28dp that circle was inscribed in the
mark's corners, and beside a title it arrived at the first letter. The
touch target comes up with it, from well under the platform's 48dp
minimum to within 8dp of it; going the rest of the way would put the marks
back 31dp apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transcript is a plain Column scrolled in reverse instead of a
LazyColumn. Nothing about Compose was re-measuring text that had already
been drawn -- a node that is still alive and whose constraints have not
changed skips measurement outright, in MeasurePassDelegate.remeasure.
What was throwing that away was disposal: a lazy list drops a row the
moment it leaves the viewport, and the markdown tree, the measured lines
and the cached paragraph go with it. Keeping the rows is the fix, and it
is what a browser-based client does that we were not.
Two scroll corrections go with it, each of which had a comment
explaining a way it had been seen to fire at the wrong moment. The
content now hangs from its newest end, so a page of older history
extends the far end and moves nothing on screen, and an arriving message
extends the end the viewport is already pinned to. Following the newest
message is no longer an effect that notices and corrects; it is where
the content is. The same goes for the keyboard opening, which was the
case that used to get missed.
Paging asks its question in pixels of scroll -- how far can the reader
keep going before they run out -- which is what it was always about.
Rows were the wrong unit twice: a fixed count of them is a distance only
by accident, and counting screenfuls of rows fixed the size of that
mistake without fixing its kind.
A saved position is resolved to the row that now holds that seq before
the layout is asked to put it back. The events behind a row regroup
between the save and the reopen, so the seq that was a row's first is
often no longer any row's first, and handing the layout the saved seq
named a row that did not exist -- the position was never applied and the
session opened at the newest end.
Verified on the emulator against a real 1,200-event transcript: the
position survives leaving and reopening, jump-to-latest arrives and
stays followed, and the newest message holds its place against the
composer as the keyboard opens and the draft grows. Scrolling measures
the same as the lazy version did (2.6% vs 2.0% janky, p50 16ms, p90
21ms, no slow UI-thread frames either way) -- the cap and the
off-thread parse had already taken that cost out, so this change is
about what the list can no longer do wrong rather than about frames.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iris approved the cap and set the one exception: never the latest
response. That one is being read as it arrives, often still arriving, and
putting "show the rest" under the turn somebody is waiting for hides the
answer they are waiting for. Everything behind it is history, which is
what this is for.
The limit is on the **source**, not on the height, and that is the whole
point. Clipping a laid-out row to a height saves nothing -- Compose
measures the text and throws the overflow away, so the line-breaking has
already happened -- while cutting the string before it is parsed stops
the work being done at all, the parse included. Four thousand characters
is about two screenfuls, with a thousand of slack so that a reply barely
over the limit is not given a control worth nothing to anybody.
The cut is at a line ending, because a markdown source cut mid-line is a
different document: half a heading marker, a list item with no bullet, a
link whose closing bracket was dropped. A fence left open is closed,
which is the one case a line boundary does not cover -- an unterminated
``` swallows the rest of the reply into a code block, so the truncation
would change how the part still on screen is *drawn* rather than only how
much of it there is, and a reader cannot tell that from the reply
genuinely having been code. Verified against a reply whose cut lands
inside a 400-line Rust block: the block renders as a block, closes, and
the control sits below it in ordinary text.
`markdownIn` now names both forms, because which one a row draws is not
decided there -- so pressing the control is a cache hit rather than a
50ms parse on the frame it happens in, and neither form is a key that no
row ever looks up.
Measured on the emulator, now that it renders on the GPU and its frame
numbers mean something -- two flings over replies of the same size in the
same session, one capped and one not:
uncapped 62,310 capped 54,990 (4,000 drawn)
janky (legacy) 58.20% 11.33%
slow UI-thread frames 5 1
p50 23ms 16ms
p90 30ms 24ms
The modern "Janky frames" figure is 4.04% against 3.59% -- near identical,
because both stay inside the compositor's deadline on this emulator. The
win is UI-thread work, which is what was aimed at, and the legacy metric
is the one that counts it.
Expanded is remembered by the screen, beside the other expansion sets, so
scrolling away and back does not shut something deliberately opened.
Known and not fixed: the floating jump-to-latest control is centred at the
bottom of the list and overlaps this one's label whenever a capped reply's
foot lands in that band. It is the overlay's pre-existing disregard for
content -- long replies have always had text under it -- but a control
there is worse than prose, and it is now common rather than incidental.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five-hour window's reset time is absent between blocks, because the
window is anchored to the block it started in and there is nothing to reset
until one is running. Measured against a live response: the reset came back
as exactly five hours after work resumed, and the weekly windows in the same
response carried the identical microsecond, so both are computed from one
now() at request time. The weeklies always have a reset because a week is
always running -- which is why only the five-hour row looked wrong.
`remainingUntil` returned null for that and for a timestamp it could not
parse, so the session bar announced "reset time unknown" about a machine
behaving perfectly, on the one row somebody reads before starting something
big. The usage dialog, looking at the same field, drew nothing at all and
printed a raw ISO string when a parse did fail. One missing value, two
rules, and neither of them right.
`WindowEnd` names the three answers and both callers go through it. A window
that is not running shows its percentage and no countdown, in the bar as
well as the dialog; an unreadable timestamp says so in words rather than
showing itself.
Looked at both on the emulator, the second by making the server drop the
field: 15% with "4h 43m left" when a block is running, "13%" alone when none
is, and the dialog's five-hour row with no reset line beside weeklies that
have one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places said the history cushion's evidence was a swipe that moved
nothing for 689ms. That reading came from `ui-trace show` on a row taller
than the viewport, which reports clipped bounds and so prints "nothing
moved" for a list that is scrolling fine -- and it was taken on the software
renderer besides. The finding it supported is sound and has a better
witness: counted at the server, ten swipes asked for ten pages before the
change and three after, which does not depend on how anything renders.
Also records what the screen actually costs now, measured on the GPU
emulator: 5.2-5.9% janky frames and 0-2 slow UI-thread frames flinging fast,
against 3.3% for the stock Settings app on the same device. With the note
that matters for repeating it -- let the screen settle before resetting
gfxinfo, because the first seconds after opening a session are every row
being composed for the first time and read three times worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now that a scroll anchor is a seq, the restore knows exactly how far back
it has to reach, so it asks for that span in one request instead of
walking there a page at a time. ai-app-2 suggested it; the arithmetic is
theirs. `read_window` counts lines and a transcript numbers them one per
seq, so the distance to the anchor is the number of events to ask for --
and were seqs ever sparse, that difference is larger than the count,
which overshoots into older history rather than stopping short.
Capped at [RESTORE_PAGE_MAX], and the loop already there is what makes
the cap safe: a span past it comes back in several requests rather than
one, which is what every restore did until now. The bytes are the same
either way -- every row between the anchor and the newest end has to be
loaded for the list to be able to count to it -- so this only trades
round trips against response size.
Measured on a 16,133-event session, restoring to seq 2000 (14,133 events
back, the extreme case): **19 requests before, 5 after.** The realistic
case, a couple of thousand events back, is 4 before and 2 after. At
`--delay 150` the deep one puts the row on screen at 5.7s and the
moderate one at 2.5s, and in both the row does not move once it lands.
Also: `RUST_LOG` did nothing. `with_env_filter("info")` is a fixed
directive that never reads the environment, so the per-request page
diagnostics AGENTS.md tells you to turn on with `RUST_LOG=ai_server=debug`
printed nothing at all -- which reads as the code you are instrumenting
being wrong rather than as the switch being disconnected. It is a
fallback now, so the default is still `info`. Those diagnostics are what
the request counts above were measured with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three entries under "Things that have bitten": the whole-file read behind
every page, the fact that a page is events and a screen is rows with no
fixed ratio between them, and the withContext that wrapped the fetch and
left the work done with the result outside it. Each carries the number it
was measured at, since the shape of all three is that the cost grows with
the conversation while the answer stays one screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all of them the same complaint: scrolling back through a long
session stalls.
**The fold was on the main thread.** Only `fetchTranscript` was inside
`withContext(Dispatchers.IO)`; the fold loop that turns a page into rows ran
on the caller's dispatcher, which is Main. `foldEvent` returns a new list
per event, so a page is that many copies of a list growing to that length --
about three hundred thousand element copies -- run in the middle of the
scroll that asked for it. Affordable at 80 events per page and not at 800.
**`warm` scanned the whole transcript on the calling thread.** Only
`replies.warm` was off it; the `markdownIn` split that decides *what* to
parse ran before the hop, over every assistant message loaded, on every
page. The scan grew with the conversation while the work it found stayed one
page's worth.
**The cushion was eight rows, which is not a distance.** A row is anything
from one line to a page: on a tool-heavy transcript eight rows is less than
one screen, so the reader reached the end of what was loaded on every swipe
and waited a round trip standing there. It is three screenfuls now, measured
from what is actually on screen. On the emulator against a 24,000-event
transcript that is 3 page fetches for 10 swipes rather than 10.
Also a spinner while a chat loads. Nothing is drawn while the newest page is
in flight or a saved position is being put back, and a blank page is what
this screen otherwise means by "there is nothing here" -- so the state that
does not know needed its own appearance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A command, a tool's output and a code block in a reply are the one thing on
this screen that is not somebody's prose, and they now say so: Mocha's
Crust, which sits below Base, so the same colour is one clear step down both
on the page where a reply is drawn and on the card where a tool call is.
The renderer's code background was `surfaceVariant`, which is exactly a
card's own fill -- a fenced block inside a tool call had no background at
all, and one in a reply read as a step *up* out of the page.
Tool output takes the monospace face with it. It is column-aligned far more
often than it is prose -- a listing, a diff, a table of numbers -- and a
proportional font silently destroys the alignment that carried the meaning.
`RawBlock` is a composable rather than a modifier because the inset is part
of it: monospace text against the edge of a tinted block reads as clipping.
A call with neither a subject nor any other field draws nothing at all
rather than an empty tinted rectangle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every page-back re-read and re-parsed the whole transcript and then threw
away all but the window. That is the cost that grows with the conversation
rather than with the answer: measured on a 21 MB, 24,000-event transcript,
one page took ~500ms of server time to return 620 KB, and it took the same
500ms whichever page was asked for. A phone scrolling up pays that per
page, and every stream reconnect pays it again to discover there is nothing
new.
Sequence numbers only increase, so the boundary of a range is a bisection.
`Indexed` locates the lines without reading them, finds the edge by parsing
one line per halving, and parses only what is going to be returned. Same
page, 232ms including the 120ms `--delay` -- so ~110ms, of which ~20ms is
the file scan and the rest is serialising the 620 KB that was always going
to be sent.
`catch_up` gets it too, and there the case that looks least interesting is
the one that mattered: a subscriber with no cursor asks for the whole
conversation and is handed the last CATCH_UP_LIMIT events of it.
The file is still read whole, which is a deliberate stop -- finding the
tail without reading forwards means a chunked backwards reader, and
locating a line is not what the half-second was going to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ai-app-2 found the reason a session Iris had scrolled in took a while to
open, and it is a defect in what I shipped this morning. A `ScrollAnchor`
stored the list's own row key. That key looks stable and is not: a tool
row is named after its run, `joinPages` gives a run the name its *newest*
half carries, and the newest half is whatever the newest page happened to
start with. The newest page is the last eighty events, so the name is
fixed for an idle session -- which is why this passed on the emulator --
and changes the moment the session says anything. An active session
therefore renamed its tool runs on every reopen, the anchor was never
found, and the restore paged backwards until there was no history left,
i.e. to the first event of the conversation.
So the anchor is a sequence number now, taken from the oldest event
behind the row. That is the server's own numbering: assigned once, never
moved, the same on every device. The restore finds the last row starting
at or before it, so it still lands correctly when grouping has changed
underneath it -- a run folding differently, two halves of a reply
becoming one message -- and the page-back loop gains an exact stop,
because `oldestSeq` walks strictly backwards and a row always matches
once the window passes the anchor. It cannot run to seq 1 any more.
`TranscriptRow` now carries `startSeq` beside `key`, and the two are
documented against each other: `key` is the list's identity and a display
decision, `startSeq` is a place in the conversation. Anything that has to
point at a place and find it later uses the second.
Two more found while testing this, both the same shape -- a listener
waiting for a scroll to *end* never runs when the list moves inside one
frame:
- The anchor was read from `rows`, which a `LaunchedEffect(listState)`
captures from the first composition, where it is empty. Every scroll
saved a null anchor, indistinguishable from being left at the newest
end, so the position was silently never recorded at all. It reads the
live state now, as the paging code beside it already warned it must.
The anchor is also written from the settled *position* rather than from
the scroll flag, because Jump to latest snaps within one frame: it left
the old position recorded, so pressing the control that means "take me
to the end" and coming back put the reader where they had been.
- Jump to latest did not set `followTail` either, which is the same hole
in the sibling and predates today: the view landed at the bottom with
following switched off, and the next message did not bring it along.
The button states what it means now. `followTail` itself stays on the
scroll settle, deliberately -- a keyed list moves its own anchor when a
row arrives, so the position reports itself as scrolled back for a
frame every time a message lands, which is the whole reason that value
is remembered rather than read.
Verified against an echo session grown to 1,327 events between saving the
position and reopening, so the newest window slid and the runs were
renamed: the same row is on screen before and after, it appears 419ms in
and never moves, and a 200-piece streamed reply still follows the bottom
after a jump.
The threading of `loadOlderPage`'s fold is ai-app-2's, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things Iris asked for, all about the transcript screen holding still
around whoever is reading it.
A tool call opened on its own stayed open when a second call in the same
run turns it into a group. Watching a Bash call and having the session
make another one used to shut the card being read and fold it behind
"Called 2 tools" -- the reader lost their place because something else
happened. The transition is noticed once, at the moment a run first
becomes a group; after that the group's own toggle owns it, so shutting a
group whose inner call is still expanded does not re-open it.
The compaction clock is taken from the `compacting` status event's own
timestamp rather than from this device noticing one, so it survives
leaving the session and coming back -- it used to disappear, because the
only thing that knew when the compaction started was a screen that had
been disposed. The server timestamps every transcript line, so this is
still a measurement; it is compared against the phone's wall clock, which
is the same comparison a session's "last active" already makes.
Session settings are a dialog over the session instead of a screen below
it. Two controls did not warrant a page transition and a back stack, and
the thing they change was hidden while they were on screen. Captions are
gone -- each control is a labelled noun -- and "Notify me" is
"Notifications" with a bell beside it (`md-bell`, added to the committed
Nerd Fonts subset). Failures keep their words, since those are what a
reader cannot work out by looking.
Tool groups are rounded like every other card, their foot bar is the same
height as their heading (both derived from the heading's own line height,
so the pair cannot drift), and the calls inside are a connected stack:
square where they face a neighbour, rounded on the outside, with a small
gap so the join reads as a join.
Scroll position is persistent on the device, per session, keyed by the
row rather than by an index -- an index means nothing across a reopen,
where the transcript is fetched newest-first. Reopening pages backwards
until that row is loaded *and* has something older behind it, because the
oldest loaded row is a half-row that grows when the page behind it
arrives; anchoring into one landed a screen and a half out. The list
draws nothing until the position lands, so there is no frame in which the
transcript is somewhere other than where it was left.
Two things found on the way. `snapshotFlow`'s first emission is the state
before anybody has touched the list, and reading it as a scroll that had
just ended at the newest end wiped every saved position on the way in.
And backwards pages now ask for 800 events rather than 80: ai-app-2
measured a real transcript at 2,426 events for seven assistant messages,
so a page of eighty is a fifth of one row and filling the lookahead took
about thirty sequential round trips -- seconds of a list that will not
move, over the tunnel.
`/tools [n] [gap]` in the echo driver takes seconds between calls, which
is what makes a run grow slowly enough for somebody to have opened one of
its calls first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--throwaway-sessions, on by default in a debug build. Every session a
server started with it spawns is marked throwaway in the config, and a
marked session's process is stopped when the server exits or is
signalled, rather than left running for the next start to adopt.
Leaving processes running is the design and it is right for the sessions
somebody is using. It is exactly wrong for the ones a test made: those
leave a claude behind that every later server adopts, and nothing ever
says they are there -- twelve accumulated on this machine in a day, each
holding a conversation open.
The flag marks; the mark decides. What a server was told at startup
governs only the sessions it spawns, and the mark is the session's own,
so a session spawned deliberately keeps running whichever server is up
when one exits, and a throwaway one is cleaned away even by a server
started without the flag.
process::wait_gone does the waiting on the way out, because
process::stop leaves its SIGKILL on a tokio timer and a runtime that is
shutting down never runs it -- which is how the original shutdown_all
leaked the processes it reported stopping.
Its test found a second thing, in the same field the last commit was
about: a zombie read as Alive. /proc/<pid>/stat keeps the entry, with
the same pid and the same start time, until the exit status is
collected, so a process that had plainly finished answered "still
there" -- and Alive is the word that makes Exited unsayable, so the
session shows unknown, its Start button never appears, and Stop says
there is nothing to stop. stat_of reads the state field alongside the
start time now.
Exercised against a real server: a keeper spawned with the flag off
survives its server's exit and is adopted by the next one, a session
spawned with it on is stopped on SIGTERM within 20ms, and the
"left N running" line counts what is actually still out there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the ai-app-2 session, which used it to find a clock bug the tests did
not have. A `claude_cli` provider pointed at `#!/bin/sh` / `cat > /dev/null`
behaves the way the lifecycle code cares about -- holds the fifo open, records
a real pid, writes nothing, dies on a signal -- so adopt, stop, restart and
start are drivable without a real `--resume` and without spending a turn on
somebody's account.
Written down beside `debug-transcript.sh` because the two answer different
questions and the wrong one is expensive: this for whether a process is
running, that for what the transcript draws.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restarting the server relaunched a driver for every session in the
config, and ClaudeDriver::launch starts a process when there is none to
adopt -- so a session somebody had deliberately stopped came back at the
next rebuild, and the Idle its new driver announced stamped the session
as active at that moment. On the phone that read as every session idle
and "just now" after every restart, with the list sorted by that time in
an order that meant nothing.
A launch now says why it is happening. Launching::Restart takes charge
of the processes still running and leaves every other session as it
found it; Launching::Asked -- a spawn, a Start, a message -- starts one
where there is none. A session with no process therefore has no driver:
DriverCell is an option rather than a driver whose requests go nowhere,
and LiveSession::ask reports what could not happen instead of sending
into a dead fifo.
Two clocks that moved on their own, both the same lie in the same field
that Transcript::last_activity exists to prevent:
- The status a launch has to correct is written into the transcript at
the time of the last thing the session actually did. A backend killed
mid-turn leaves a transcript saying Running, which has to become
Exited -- but this server noticing is not the session doing something.
- A session that has never done anything reports when it was created. Its
transcript is empty, since a driver announcing the state it starts in
is not news, so it is the one session with no line to read a time off
and the clock was the fallback.
Exercised end to end against a real server: a stand-in CLI adopted
across a restart keeps its status and its time, a stopped session stays
stopped with no process started, a process killed while the backend was
down reports exited stamped at the last thing the session did, and Start
brings it back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.
**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:
scrolling=true atNewest=false followTail=true
history: START last=20 total=28
history: page of 80 events -> rows now 36
countChanged count=36 followTail=true scrolling=true
>>> scrollToItem(0) SNAP
It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.
**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.
Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.
Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last commit left renaming out on the grounds that the name is
persisted and listed whether or not a process hears about it. That was
wrong, and Iris said so: Claude Code keeps its own copy of the name,
that copy is what its session picker shows and what other agents read
when they list sessions, and a session is only ever *given* a name at
birth -- every later start is a `--resume`, which passes no `--name`.
So a rename that reached no process left the two lists disagreeing
permanently, with this app's the only one that had moved. The cost of a
resume buys the one thing renaming is for.
It stays `rename_session` rather than becoming a command like the rest,
because the name is persisted and listed as well as forwarded and that
is one operation. The save happens first and the lock is dropped before
the telling, so a failure to start reports that the telling failed
rather than the rename, which by then has already happened.
`LiveSession::run_command` went with it. It read `shared.status` and
that read is exactly what a just-started session cannot be judged by, so
every caller now goes through the manager -- which is also what the four
tests that used it were standing in for.
Verified against a stand-in CLI that echoes its stdin: a session
reporting `exited` was renamed, the process started with `--resume`, and
the CLI received `/rename after the restart` on stdin. The list shows
the new name and the session reports idle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same reasoning as the message path a commit ago, and the same objection
to leaving it out: a command is something somebody asked the session to
do, and answering "its process has exited" hands back the work of
starting one. `/compact` on a stopped session is the case that shows it
-- what is being asked for is exactly what a stopped session needs
before it is useful again.
`POST /sessions/{id}/command` and `/compact` now go through
`SessionManager::run_command`. A rename is deliberately not one of them:
it is persisted and listed whether or not a process ever hears about it,
so starting a CLI to tell it a name would be spending a resume on
nothing. It stays a forward to a process that happens to be there.
A command needs one thing a message did not. `Commands::submit` refuses
on `Exited`, and a driver that has just started a process announces
`Idle` through the sink rather than writing it -- so a command judged
against the session's own status would be refused by the word the start
had just replaced, in a window narrow enough that only a test reliably
hits it. `start_if_exited` returning `Exited` is what says a process was
started, so the status the command is judged against comes from there
rather than from a re-read the pump may not have caught up with. The
test fails without it.
`LiveSession::compact` went with this: `/compact` the route and
"/compact" the typed command were two ways to the same command, and now
there is one.
Verified over the API against a stand-in CLI: with the session reporting
`exited`, both `/clear` and `POST /compact` started the process and were
delivered -- transcript order `idle`, `commandSent`, `running`, `idle`,
with no "this session's process has exited" anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refusing was work handed back: read the status word, find the other
button, press it, type the message again. Sending plainly means "do this
now", and `--resume` puts the new process on the same conversation, so
nothing about the message changes -- only whether there was anything
there to read it.
`POST /sessions/{id}/message` now goes through the manager, which starts
a process first when the session is known to have exited. Only on
`exited`: `unknown` has a process that may well be reading its fifo, and
starting a second CLI on that guess is the fault `session::process`
exists to prevent, so the message goes to the driver as it always did.
The Start button and this ask one function, `start_if_exited`, and want
opposite answers from it -- "there is already a process" is a refusal
worth showing to somebody who pressed Start, and nothing at all to a
message being sent. Deciding it in one place under the one write lock is
also what keeps two requests that arrive together from starting two
CLIs.
Verified over the API and on the emulator: with the session reporting
`exited` and the composer showing a play button, typing a message and
pressing Send started the process, delivered the message and ran the
turn -- transcript order `idle`, `userMessage`, `running`, `idle` -- and
the button became a stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session adopted at a backend start keeps the transcript's last status,
so one whose process had been reported gone and was then found again
read as `exited` while its CLI was running. `exited` is the word that
draws the phone's Start button and lets `start_session` build a driver,
so Start was accepted every time it was pressed -- and since starting
replaces the driver without retiring the old one, each press left
another reader on the same process. Every line the CLI wrote was then
translated once per reader: three presses put three interleaved copies
of one reply on screen, which is what it was reported as.
So `exited` is now checked against `session::process`, the one authority
on whether a process exists, in `launch` and again in `start_session`. A
record that is not known to be dead makes it false, and what replaces it
is `unknown` -- there is a process, and nothing here has heard from it,
which is the answer `status_of_unlaunched` already gave to the same
question. The correction goes out through the sink rather than into the
manager's view alone, or the list and the session screen would disagree
about it in the way this same button did a commit ago.
A driver that `start_session` replaces now gets `Driver::detach`, which
already existed for the backend going away and is the whole of what a
driver whose process has exited is owed.
On the phone the process button is disabled while its own request is in
flight, so a second press cannot be decided against a status the first
has not changed yet. That is a courtesy rather than the fix; the server
refuses it either way, because a phone that has lost the stream cannot
be relied on to know.
Verified against a stand-in CLI, with the state forced by hand: before,
three Starts returned 204 and left four readers on one process and the
status still `exited`; after, the session reports `unknown` on both
surfaces and all three are refused. Then driven on the emulator --
Stop, Start, Stop, Start alternated correctly with one process at a
time, and the list, the transcript and the record all agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scrolling was laggy, and measuring said where the time went. Instrumenting
the transcript's main-thread work on the emulator, against `/stream 200`:
markdown parsing ran fifty-eight times in three seconds -- once per streamed
delta, each one re-parsing the whole message the reply had grown into -- for
49-78ms of main-thread work per three seconds, with single parses reaching
7.3ms. That is most of a frame at 60Hz and more than a whole one at 120.
Everything else the transcript does per event was under a tenth of it.
So only the first parse stays on the composing thread. That one has to: the
renderer's asynchronous path draws an empty loading slot until its result
arrives, which measures a row at nothing before it is measured at its real
height, and the whole transcript above it collapses and springs back. Every
parse after the first is the same row growing, and there is a previous parse
to keep drawing until the new one lands -- so those go to a background
thread and no frame is ever without a height. What is on screen stays a real
prefix of the reply rather than a guess at it; it is simply one parse behind.
The same measurement found `loaded` costing an ArrayList copy per event, and
nothing reading it. It recorded every event the screen had ever seen against
the possibility that a page arriving in front of them would need the events
themselves to stitch on -- but `joinPages` heals the boundary from the folded
rows and has since it was written, so this was a list that only ever grew.
Checked on the emulator with ui-trace at 1kHz. Streaming at the newest end:
the row's bottom edge holds at y=1940 while it grows upward, and the header,
status row and composer do not move for six seconds. Scrolled back with a
reply streaming: nothing moves at all, 0 of 45 elements over five seconds.
Scrolling a mixed transcript: rows keep a constant height as they translate,
so none of them arrives blank and fills in afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which AVD this checkout means, creating it, booting it headless and refusing
to start one the machine has no room for is the same sequence in ai-app,
ai-app-2 and dev-updater. It now lives once, in ~/repos/emulator-tools, and
this script is what is actually specific to this project: a build, an install
and a launch.
Three copies of "boot an emulator" was three places for the memory check none
of them had -- starting one at 2.8 GB available invoked the OOM killer, and
what it took first was another session's emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The session list reads the manager's status; the session screen replays the
transcript. Correcting a relaunched session's stale `exited` by writing the
manager's view directly left those two saying different things about one
session -- which showed up as a stop button that turned into a play button a
moment after the screen opened, and a status row that disagreed with the row
it was opened from.
So the correction goes through the event sink instead, from the driver that
started the process: `EchoDriver::new` and `LlamaDriver::attached` already
announce the state they start in, and `ClaudeDriver` was the one starting a
process silently. It says idle only when it started one -- adopting says
nothing, since a process that was already running may be mid-turn. Coming
from the driver also orders it against the exit `follow` reports for a
process that dies immediately, which a status written from the manager could
not be.
Verified against a stand-in CLI: stop, restart the backend, and the list and
the transcript's last status both say idle, with the relaunch recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer's second button now says what pressing it would do to the
process behind the session, in one place that is always there: an orange
pause while a turn is running (interrupt, the process stays), a red stop
when it is not (end the process), a green play when it has exited (start it
again on the same conversation). Send is disabled while there is nothing to
send, rather than pressable and silent.
Behind it, two routes. `stop` signals the recorded process and says nothing
else -- the driver's own reader already reports a death correctly, and
announcing it here would be a guess ahead of the measurement. `start`
replaces the driver and nothing else, so the transcript, the pump and every
open phone's stream stay where they were and there is still one writer of
the transcript; it is refused unless the session is known to have exited,
since starting on `Unknown` is the two-CLIs-on-one-conversation fault.
That last rule found a bug in the launch path: a relaunched session took its
status from the transcript, so one whose process had died before a backend
restart reported `exited` while the launch had just started a new process --
which refuses every command and offers a phone the chance to start a second
CLI on a live conversation. A launch that leaves a process running now says
idle.
The icon font moves to the Mono face, where every glyph is one em square, so
two icon buttons are the same width without either being told one; the
proportional advances ran 0.46 to 0.92 em and Send came out visibly wider
than Stop. GLYPH_SIZE comes down to match, since a glyph that fills its em
draws bigger at the same point size.
Verified against a stand-in CLI on the emulator: idle -> stop -> exited ->
start -> idle, a turn interrupted from the pause button, and both buttons
measured at 171x105 device pixels.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything that opens now behaves alike. Touch a row's upper half and its
top edge holds, so it opens and closes downwards; touch the lower half and
the bottom edge holds, which is what the list does on its own. A group's
heading and the bar at its foot fall in the halves they already occupy, so
they keep the behaviour they had, and a single tool call -- one card, with
no bar -- gets the same choice for the first time: tapping low on an open
Bash card now shuts it downwards exactly as a group's bar does.
That makes the position of the tap the one mechanism, and RowEdge goes
away with the pair of hardcoded ends it existed to name. Controls report
where they were touched in root coordinates, which is all a control can
know -- a group is one row with a control at each end and calls in the
middle, and only the row knows where its own ends are -- and the row turns
that into an edge.
`clickableAt` is built on `clickable` rather than replacing it, so the
ripple and the click action assistive technology reads are unchanged; the
down position is observed on the initial pointer pass and nothing is
consumed.
Verified with ui-trace: on a collapsed group, a tap at y=1370 holds the
heading and one at y=1450 lets the row grow upward instead. On the same
nested call inside an open group, opening it from the group's upper half
holds the heading at 565 and from the lower half moves it to 296. ktfmt,
lint and 85 tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The correction ran in a coroutine, so it landed a frame or more after the
layout it was correcting: the wrong position was drawn once and then fixed,
which reads as a flick and gets worse the faster the screen refreshes. That
is a race with the display rather than a bug that can be tuned out, so the
fix is not a shorter delay but a different phase.
It now happens in the layout phase. `Modifier.holdTopEdge` learns the row's
new height from the measurement that produced it and asks the list to shift
by exactly that much, before anything is drawn. `requestScrollToItem` is
the form that may be asked for during layout; `dispatchRawDelta` is not --
it calls forceRemeasure and dies with "performMeasureAndLayout called
during measure layout", which cost one crash to establish.
The arming flag and the per-row height are deliberately not snapshot state.
Both are written from layout, where a snapshot write that composition reads
would schedule another recomposition -- another frame, which is the thing
being removed.
This also drops the machinery the previous attempt needed: no waiting on a
size change, no timeout, no marking the rows above to find one that could
still report the move. A row measures itself, so a row that shrinks out of
the viewport is no longer a special case.
Verified with ui-trace sampling at ~1kHz, where a single bad frame would
show as ten to twenty samples: expanding and collapsing from a heading are
each one step from old position to held position with nothing in between,
collapsing from the foot bar holds the four rows below it, a drag 120ms
after a tap is left alone, and scrolling back stays put for six seconds.
ktfmt, lint and 85 tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reinstates the reverted downward-opening rows with the two defects that
made the first attempt worse than what it replaced.
The correction waited on the row's top edge and could wait up to half a
second for it to move. A top edge also moves when the reader scrolls, so a
correction still pending would wake on their drag, read the scroll distance
as the row's growth, and undo it -- the transcript jumping on every expand
and refusing to scroll back at all. It now waits on the row's *size*, which
nothing but a resize changes.
The second is why collapsing a group taller than the screen did nothing at
all. Such a group is the list's own anchor item, so as it shrinks it slides
down behind its anchored bottom edge and out of the viewport, and its size
reads as null -- which `withTimeoutOrNull` cannot tell from the null that
means the wait expired. The case most needing the correction was the one
silently skipped. The wait now answers a value that a timeout cannot, and
the distance is read off any row from the pressed one upwards, all of which
move by exactly the row's growth.
Verified on the emulator with ui-trace (~/.local/bin), which samples the
accessibility tree at 60Hz and reports node bounds in device pixels:
expanding and collapsing from a heading hold it to the pixel, collapsing
from the foot bar holds all four rows below it, a drag 120ms after a tap is
left alone, and scrolling back stays put for six seconds. ktfmt, lint and
85 tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit f4d4c82. The anchoring it added made the transcript
jump on every expand and collapse, and left the list snapping back to the
bottom when somebody scrolled up, which is worse than the upward-opening
it was meant to fix.
Two things to look at when this is retried. `LazyListItemInfo.offset` in a
`reverseLayout` list is not obviously the coordinate space this assumed,
so `offset + size` may have been measuring the bottom edge -- the one the
list already holds -- rather than the top. And the anchoring scroll ran in
a coroutine that could still be pending when the reader started dragging;
`scrollBy` takes the default mutation priority, so it cancels that drag.
Verify the next attempt with `uiautomator dump` -- node bounds in device
pixels, before and after a toggle -- rather than by eye from screenshots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping a group's heading used to send that heading up off the top of the
screen and fill the space above it, so the calls appeared on the far side
of the control that produced them. The transcript is laid out from the
bottom, so every row's bottom edge is what the list holds still and all
growth goes upward.
The rule now is that the end the reader pressed is the end that must not
move. A heading anchors the top, so the row opens downwards under it; the
bar at the foot of an open group anchors the bottom, so shutting it from
there leaves what follows the group where it is -- which is what already
happened, but by accident of the layout rather than on purpose, and would
have been lost the moment anything else changed.
Bottom is the list's own behaviour and costs nothing. Top is measured
rather than calculated: only the layout knows how tall an open group is,
so `toggleAnchored` reads where the top edge was, lets the change land,
and scrolls by however far it moved.
Applied to every row that opens, not just groups -- a lone tool call and a
peer message are the same gesture, and one of them opening the other way
would be the odder for it.
The notification stream now has three places to land instead of two, decided
in one function. Nothing at all for the session on screen, as before. A
banner over the app while the app is up. Android's drawer otherwise. Never
two of them for one moment: a drawer filling up behind an app that showed
you each one is a drawer nobody reads.
The banners queue, one per session replacing that session's own -- the rule
the drawer already followed, and for the same reason. Each can be tapped,
which opens the session by the same path a tapped notification takes;
pushed off either side; or left alone, in which case the bar across its foot
retires it. The bar and the retiring are one value rather than a bar beside
a timer, so a banner cannot outlive the countdown drawn under it. They clear
when the app goes away, since a claim that a session wants somebody *now*
does not survive an absence -- and the drawer has the job back by then.
Which of the three applies needs no flag anybody keeps level. The session on
screen is registered by the one composable that draws one, and "the app is
up" is the queue being collected, which happens exactly while it is.
Also: tapping a model or permission button while its own menu is open now
closes it. A non-focusable popup does not swallow the press that dismisses
it, so the same finger was reopening what it had just closed -- measured at
3ms between the two, which is what the guard is sized against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session whose loaded rows were shorter than the viewport drew them
against the top of the list, leaving a gap between the newest message and
the box you type in -- and nothing to scroll, because there was no
overflow. Opening the keyboard shrank the viewport enough for the content
to overflow it and the list snapped down, which made a placement fault
look like a scrolling one.
`reverseLayout` defaults the arrangement to `Bottom` on its own, but
naming `spacedBy` replaces that default with `spacedBy`'s own, which is
`Top`. The arrangement is only consulted when the content does not fill
the viewport, which is why this sat here since 2026-08-28 without being
seen: it needs a session that loads less than a screenful, and a page of
tool calls collapsing to one "Called 80 tools" row is how a long
conversation manages that.
Three things about the session screen.
A notification is no longer posted about the session in front of you: the
transcript is already saying it, and one that was posted before you opened
it is cancelled, since a row in the drawer for the conversation on screen
is the same duplication. Bound to RESUMED rather than STARTED, so a session
left on this screen behind another app still reports.
The model and permission menus opened 142px clear of the buttons that
opened them -- the status bar's height, exactly. Compose measures the
anchor in window coordinates, which for an edge-to-edge activity is the
whole display, but asks whether the menu fits inside the visible frame,
which is that less the system bars; sitting just above a control near the
bottom then reads as an overflow and Material3 parks the menu near the
bottom of the visible frame instead. Turning clipping off makes both
questions about the same window.
The model picker now offers "default". The button has always been able to
say it -- that is what a session with no model of its own reads as -- but
the list could not, so choosing any model was a one-way trip. It is the
Claude CLI's own word for "whatever is configured", which its set_model
accepts, so it is a request rather than a name invented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A restarted server has been told nothing, so a Claude session that has not
taken a turn since reported its context as unknown -- which was true, and
useless, since the CLI had written the figure down at the time and it was
sitting in the session's file the whole while.
It now reads it from there at load, over the session's transport, in the
background: the same three input fields the import list already reads, so
it is a measurement rather than a guess. Only when nothing else has
answered, and only for a provider that keeps such a file.
A clear needs no special case even though it makes the last usage in a
file stale, because clearing gives the CLI a new session id -- so the
lookup lands on a file with no usage yet and answers unknown, which is
what it is.
An AskUserQuestion arrived in the middle of a run of tool calls and was
folded into the collapsed card with them, so the one row where somebody
was asked something -- and the answer they gave -- was hidden behind
"Called 6 tools" like any other grep.
It now starts a run of its own and ends the one before it, which needs no
change to the grouping: a run of one is drawn as itself. The calls around
it become a group before and a group after, so where the work stopped to
ask is legible from the shape of the transcript without opening anything.
Echo's `/ask` now runs three ordinary calls on each side of the question,
because that is the shape this has to be looked at in and there was no
way to produce it.
The number on the status row was a running total of tokens spent, so it
could only ever climb: a session compacted from 128k down to 10k, or
cleared outright, went on reporting the larger figure, and disagreed with
the divider directly above it saying what the compaction had recovered.
It now reports what the model is holding -- prompt plus both cache
figures -- folded through `driver::context_after`, which is the one rule
the pump, the transcript and the phone all use: a turn sets it, a
compaction replaces it with what the compaction measured, and a clear
leaves it unmeasured. Unmeasured says so in words, because an empty
context and one nobody has counted used to look identical.
Taken from the turn's last assistant message rather than its `result`:
measured against CLI 2.1.237, a two-message turn reported a cache read of
40,211, being 14,259 and 25,952 -- the same conversation counted twice,
and no size the model ever held.
A `/clear` that did nothing, traced to the end. There was no race to lose:
the driver sees every line it writes and every line that comes back, so it
always knew. What it knew was being asked of the wrong thing.
Two views of "is a turn running" had grown apart. The driver's moves the
instant it writes a line; `SessionStatus` moves when output is *recorded*.
Messages ask the driver -- which is why they behave -- and commands asked
the status, which for a command is stale for its whole round trip: a
command's reply carries no assistant text, so nothing proved a turn had
started and the recorded status stayed idle from the moment it went out
until the moment it came back. A second command in that window went
straight out too, landing inside the turn the first one had started, where
the CLI reads it as text instead of running it. Nothing anywhere says so:
a command read as a message looks like a message.
So `Commands` asks `Driver::between_turns()` now, and asks again when it
releases a held one -- the recorded idle that woke it is a moment in the
past by then. `local_command` says `Running` when it writes, which is both
true and what makes the next idle a change worth recording; without it the
idle at the end of a command was equal to the idle before it, and nothing
behind it was ever released.
The other half was a turn nobody here started. The CLI picks the
conversation back up on its own -- measured: a backgrounded `sleep`
finished nine seconds after the turn's result and it began again unprompted
-- and it announces that with a `system/init` about a second and a half
before its first assistant text. We had been ignoring that line and
learning about the turn from the text, so for that second and a half the
session read as idle. It is a turn now, told apart from the `init` at
startup by the translator already having a session id, and from our own
`/clear` by `running` already being true.
Measured against the real CLI, not argued: two `/clear`s sent back to back
on one connection now record `commandSent`, `running`, `commandQueued`,
`cleared`, `idle`, `commandSent`, `cleared` -- held, then run, in order,
both of them. Before this the second was swallowed. The self-started turn
shows as `running` eleven seconds after the previous turn's idle, which is
the window a command used to disappear into.
Also measured on the way, and worth writing down: a message written into a
running turn is *folded into it* -- one `result`, `num_turns: 2`, both
things answered -- so an idle after one is honest and there was nothing to
fix there. A command written when the CLI is genuinely between turns is
executed even ten milliseconds after the result, so the boundary itself was
never the problem.
Tapping a notification landed on whatever the app was last showing. It now
opens the session it named. The id rides in the intent's data rather than an
extra, because PendingIntent identity is Intent.filterEquals -- with an extra
every session's notification would share one PendingIntent and every tap would
open whichever session was notified last. MainActivity sorts the aiapp:// URI
by host, so enrollment and this are one entry point rather than two.
The notification carries only an id, so the session is fetched before there is
a screen; a fetch that fails says so and offers to try again, since somebody
deliberately tapped and an app that opens to the list explains nothing.
That made session-to-session navigation reachable for the first time, and it
crashed: SessionScreen remembers a transcript and an event stream, and without
a key Compose kept both across the change and merged two conversations into
duplicate list keys. Keyed on the session id.
The two transcript dividers now say only what they are, centred between two
rules: "Compacted <bullet> 128,402 -> 9,617 tok" in blue, and "Context cleared"
in red. The rules stay the ordinary divider colour -- they are framing, and the
words are what carries the meaning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Investigating a `/clear` that did nothing. What I could measure says the
basic path is sound: the CLI honours `/clear` in stream-json mode -- it
emits `conversation_reset`, opens a fresh session id, and the model then
answers "NO CONTEXT" to a question about something it was told a moment
before -- and a `/clear` sent into a running turn is queued here and applied
at the boundary, with the model losing context, in two reproductions.
What the investigation did find is a command that can wait forever. Held
commands drain at the next idle, and a session whose process is gone has no
next idle, so `/clear` sent to one sat in the queue with a waiting bubble on
the phone that nothing could resolve and nothing anywhere saying why. The
*message* path has always answered this case -- a message to the same
session reports the exit at once -- which is what made the silence visible:
one session answered one and swallowed the other. A command owes the same
answer, since what makes it unanswerable is the same fact.
`Unknown` still waits. It means nobody could find out whether the process is
there and it resolves itself, so refusing on it would turn "we don't know"
into "it's gone".
`local_command` gets the `closed` check `send_user_message` has had all
along, for the window between the status being read and the line being
written -- a line into a fifo nothing is reading goes nowhere and looks
exactly like one that arrived.
Attachments now draw under the message text rather than above it: what
somebody wrote is what the bubble is, and it keeps the first line of every
bubble in the same place down the transcript whether or not there is an
image in it.
joinPages healed a tool call split across a page boundary but not a
message split across one, so a long reply came back as two rows with a
paragraph break through the middle of a sentence -- visible on any
session whose replies are longer than an eighty-event page.
Same cause, same cure, and the rule was already written down one member
of the set: `foldEvent` never leaves two assistant messages adjacent
inside a page, since deltas accumulate into the message before them, so
two meeting at a join are always halves of one reply.
The newer half keeps its identity for the reason adoptRun gives -- it is
the row already on screen. It grows by what the older half brings, which
is safe at this join and nowhere else: the join is at the oldest end of
what is loaded, so the growth extends off the top, away from the row the
list anchors to.
Two things that made scrolling back feel like work.
The jump-to-newest button animated. An animated scroll travels the whole
transcript, so the further back somebody has read the longer the press
takes -- the one control whose cost grows with how much there is to
skip, which is backwards. It goes straight there now.
History loaded a page per gesture, and a page is eighty *events*. Eighty
events are routinely one row: a reply arrives as hundreds of text deltas
that fold into a single message. So a page could land and leave the far
end exactly where it was -- and since the far end moving is what asks
for the next page, nothing did. The list then only loaded when somebody
dragged it again, which is what "it only loads when you touch the top"
was. It now keeps fetching until there are rows behind the reader again,
and starts doing that a cushion before the end rather than at it.
Measured on a session of five very long replies, about two thousand
events: reaching the oldest message used to stall at every drag; it now
takes flings alone, and the jump back to the newest end is one frame.
Sending an image was broken in the way that is hardest to see from the
phone: a camera photo is twelve megapixels and several megabytes, the Claude
API resizes anything past 1568px on its long edge before looking at it and
refuses far larger outright, so the picture was uploaded whole over the
tunnel to be thrown away or rejected at the other end.
Shrunk on the phone, to a limit the server states. Which number it is comes
from the provider's *kind* -- `DriverKind::max_image_edge`, reported on the
session row -- because that is where a provider's requirements are known,
and a phone carrying its own copy of them would be a second place to update
when one changes. `None` where nothing cares, rather than a large number:
"no limit" and "a limit that happens to be big" are different answers and
only one of them stays true. Doing it before the upload rather than after is
the point -- the expensive part on a phone is the tunnel, not the decode --
and an image already inside the limit is uploaded byte for byte rather than
being round-tripped through JPEG for nothing.
EXIF orientation is applied while scaling. The camera writes which way up
the picture is into a tag rather than into the pixels, and re-encoding drops
it, so a portrait photo would have arrived at the model on its side with
nothing anywhere saying so.
**What is attached is now visible before it is sent**, in a row directly
above the box it will be sent from: the count on the "+" button said how
many and never which, so the only way to find out what you had picked was to
send it. It scrolls sideways rather than shrinking, and tapping one takes it
back off -- an image picked by mistake could otherwise only be dealt with by
sending it. The tile is outlined as well as filled, because most of what
gets attached here is a screenshot of a dark app and a cropped one is
near-black: without an edge the only thing on screen saying an image was
attached was the cross drawn on top of nothing.
**And the picture is inside the bubble that sent it.** Attachments used to
be their own `Image` events emitted just before the message, which drew
somebody's screenshot as a row floating above the bubble and left the phone
deciding from adjacency alone which message an image belonged to -- a thing
the sender knew and could simply say. `UserMessage`, `MessageQueued` and
`MessageTaken` carry the refs now, so a waiting message keeps its picture
for as long as the turn runs, and a replay puts it back in the same place.
Verified on a real claude-cli session rather than an echo one, since the
limit only exists for that kind: a 3000x4000 image arrived as 1176x1568
JPEG -- long edge exactly the limit, aspect ratio intact -- and haiku
answered "AI Sessions displays idle Photo", which is what the picture was.
No error, and the transcript records the message with `images` on it.
Two separate defects, both of which moved the list under the reader.
The first: a row that grows drags the view toward the newest end. The
list is laid out from the bottom, so it anchors on the first visible
item's *bottom* edge -- and a reply streaming in extends that row
upwards, pushing everything already on screen with it. Measured against
a reply streamed in four hundred pieces: scrolling back one screen and
waiting six seconds ended at the very bottom, forty lines further on
than where it was left. So the transcript now only changes while the
reader is at the newest end; anything arriving before then waits in
order and lands when they return. Status, tokens and the model still
update live, because none of those are drawn in the list and freezing
them would trade a jumping transcript for a status row that lies.
The second: every markdown row was measured at nothing before it was
measured at its real height. The renderer's `content: String` overload
parses in a coroutine and draws an empty loading slot until it finishes,
so a row composes with no height and springs open a frame later. Seen
with five replies on screen at once, all blank, the whole conversation
shrunk to a single screen. Parsing in the composition costs a few
milliseconds on the main thread and is worth it: no scroll anchoring can
survive a row that lies about its height first.
`/stream N` in the echo driver is what made the first one reproducible
-- `/slow` emits a line a second, and the growth has to be continuous
for the anchor row to drag.
Verified on the emulator: scrolled back through a whole 400-piece
stream, the transcript region is pixel-identical across ten seconds
while the status row goes from working to idle; returning to the bottom
brings the backlog in one go. Checked the tool-call rig too, which this
change had no reason to touch -- paging back still works and every group
still reads "Called 8 tools".
`run-android.sh` derives its AVD from the checkout, so two clones of this
repo run two emulators, and with both attached a bare `adb` call stops
working. The failures do not say so: `adb shell` and `adb get-state` fail
with `more than one device/emulator`, and `adb shell pm list packages`
comes back empty -- which reads as the app having been uninstalled, and
sent dev-updater's session looking for a wipe that had not happened. Its
enrolment script mis-diagnosed the same ambiguity as "no emulator is
running", whose advice is to start a third.
The enrolment line in this file was itself a bare `adb shell`, so it was
the instruction that would have produced the confusion.
A group of adjacent tool calls was identified by its first call, and the
list is keyed by that identity. But a run can gain members at *either*
end -- a new call arriving beside it, or a page of history arriving in
front of it -- so its first member is not a name, it is a description
that changes. Every time it changed, the row was a different row as far
as the list was concerned: the anchor went with it, and the transcript
stepped under whoever was reading.
Each call now carries the run it belongs to, decided once when it is
folded in and never recomputed, and the row keys on that. A lone call
that gains a neighbour becomes a group *without* changing identity,
which the old key got wrong in the other direction too -- one row was
replaced by another rather than updated.
`joinPages` hands the arriving older calls the name of the run they are
joining, rather than renaming that run after them. The obvious way round
is the wrong one: the newer half is the part already on screen, so
naming the joined run after the older half renames the row the reader is
looking at, which is the whole failure this is meant to remove.
Checked against the same twelve-`/tools 8` rig, whose page boundary falls
inside the second group: every group still reads eight, so the grouping
is unchanged -- what changed is that none of their identities move.
Toward the standing rule for this screen, which is that it may only move
when the reader is at the newest end and something new arrives.
Code is not a literal. Green is what this palette colours a literal, so
painting a whole fenced block green said the block *was* one -- and it
disagreed with the syntax highlighter a tool call's input already gets,
where green means a string and peach means a number. Code blocks and inline
spans now take the ordinary text colour; the monospace face and the tinted
background are what say "this is code", which is the part colour was not
doing. `codeColor` goes with it, since nothing else wanted a colour for
code. Where a literal really does appear inside code, the thing that should
colour it is a highlighter reading the code, not a rule about the container.
`run-android.sh` derives its AVD name from the checkout instead of defaulting
to a machine-wide `tdep`. That default made the emulator the one thing here
that cannot be worked on in parallel: two clones of this repo meant asking
whoever had it, waiting, and handing it back, and installing onto a running
one takes the foreground from whatever they were looking at. Derived rather
than written down, so neither clone names the other's, and `AVD_NAME=` still
overrides for sharing one deliberately.
Also: two things reported as markdown defects yesterday were not defects,
and are worth recording so nobody fixes them twice. The table is not
clipped -- it scrolls horizontally, which the renderer does whenever the
columns are wider than the screen; a screenshot of one looks exactly like a
clipped table, and swiping it shows the rest. The paragraph that appeared to
break around an inline code span was an artifact of how the test text was
sent through the echo driver, not of the renderer: sent as one message it
flows correctly.
Three readings that were each a part presented as the whole.
**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.
**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.
The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.
**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.
Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.