Selecting a sentence highlighted every word of it except the ones in
backticks. An inline span's background is part of the text's own drawing
and the selection rectangle is drawn underneath it, so an opaque chip hid
the selection completely -- and there is no way to draw it over instead,
since the order is the text node's. The chip's fill is 60% now: measured on
the emulator, unselected it is #161622 against a #1E1E2E page, so it is
still a clear step down, and selected it moves to #3C344F, which is the
whole point.
This is what Iris's screenshot was showing. A fenced block was never
affected -- its background is on the box around the text rather than on
spans, so the selection lands on top of it, which is why it looked fine
when I went looking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified on the emulator with all three gestures on an opened peer message:
a tap on its words shuts it, a press held past the long-press timeout
selects from them, and the tap that puts that selection away does only
that. The earlier note in TODO.md saying markdown inside these cards could
not be selected was a coordinate error on my part -- the presses were
landing in the card's bottom padding, below the last line -- and it is
removed rather than carried forward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A markdown paragraph took every tap that landed on its glyphs, so an opened
peer message or memory note could be shut anywhere except on the text --
which is most of it, and reads as a card that has stopped working. Measured
on the emulator: with a handler on the text the tap did nothing at all, and
with the handler removed the same tap shut the card. The words now do the
shutting, through a composition local, since the renderer composes those
paragraphs out of its own component table and there is nothing between the
card and them to pass a parameter through. The link handler is bounded by
the long-press timeout, so holding to select is not a tap.
The other half is the tap that puts a selection away, which used to shut
whatever card the words were in. The container clears the selection from
that same press, milliseconds before the card reads it, so the answer is
taken at composition instead -- what was true when the reader touched the
screen.
Selection colours are the app's own. Material's 40% of primary is a tint of
whatever is behind it, and over the near-black a code block sits on it
composited to a smudge, so selecting a line of code looked like nothing had
happened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dev.snipme:highlights 1.1.0 found comments before it knew the language and
paired /* with */ by ordinal, so `//` in any URL commented out the rest of
its line, every Rust `#[derive(...)]` greyed out as a comment, a `#` inside
a Kotlin string swallowed the line, and `x '*/a/*'` in shell produced a span
whose end preceded its start -- the one that crashed a card holding
`-path '*/.git/*'`. None of that could be post-processed away, because
comments won over strings before the language was known.
Highlighter.kt is one left-to-right scanner: at each position it is in a
line comment, a block comment, a string, or ordinary code, and every span is
emitted by advancing an index, so spans cannot overlap, arrive out of order
or run backwards. Languages.kt is a `Rules` row per language -- comment
tokens, block comment and whether it nests, the string forms, what opens an
attribute, and the keyword set -- so a new language is a table entry. The
keyword lists came from the library's SyntaxTokens.kt (Apache-2.0, noted at
the table) so nothing that is coloured today turns plain, and RON, TOML,
fish and JSON are coloured for the first time.
HighlighterTest.kt is a new JVM unit test source set -- 24 cases, the
library's mistakes kept as regressions, plus a sweep asserting no span
escapes the code for any language on unterminated and empty input.
AGENTS.md's app line now runs :androidApp:testDebugUnitTest.
Measured on the ai-app emulator, debug build, a ~200-line Kotlin fence sent
into a sandbox session:
before code highlighted: 1, 101.9ms total, 101.9ms mean, 101.9ms worst
after code highlighted: 1, 15.0ms total, 15.0ms mean, 15.0ms worst
and a second fence in the same run took 13.9ms, so that is the steady cost
rather than class loading. stream-bench.sh after the change:
code highlighted: 1, 12.1ms total, 12.1ms mean, 12.1ms worst
markdown reparsed while streaming: 1329, 2130.7ms total, 1.6ms mean, 8.7ms worst
record: one block: 131, 11.4ms total, 0.1ms mean, 0.4ms worst
draw phase 1.21ms per frame, the transcript 0.23ms of it
transcript-bench.sh after: draw phase 1.10ms per frame, the transcript
0.49ms (place 0.48), worst place 4.3ms -- unchanged within run-to-run noise,
as expected, since the scan happens in `warm` and not while drawing.
Looked at on the emulator: a URL inside a Kotlin string, a Rust attribute
with a lifetime and a raw string, a shell line with globs and `$#`, a RON
fence and a TOML fence all colour correctly; a Bash tool card still colours
its command; a plain Python fence -- which this change had no reason to
touch -- looks as it did; an unknown language stays plain; and a fence is
plain while it streams and colours when it freezes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bash output arrived with its escape sequences in it, so a coloured diff or
test run was line noise around the thing being read. The sequences that
decide how text looks are spans now and every other one is dropped, with a
carriage return honoured the way a terminal honours it so a progress bar
shows its final state rather than every state it passed through.
A rightward drag anywhere on a session, spawn or settings screen steps back,
following the finger so it can be abandoned. It loses every argument: a
child that consumes horizontal drags -- a wide fence, a table, a selection
-- has already taken the gesture before this sees it.
Changing the model or the permission mode of a session with nothing running
was refused, in words about the driver, while the config had already taken
the value that its next start will use. Both now announce the stored setting
instead, through one function, since which of the pair it is does not change
the rule.
The model-switch warning no longer fires after a clear: the server reports
the context as unmeasured rather than zero afterwards, and the fallback
reading counted the whole conversation still on screen.
An image loading shows a spinner in the space it is about to fill, in the
transcript and in the composer's attachments alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking an option marked nothing until the answer had crossed the tunnel,
been recorded and come back as an event, so the card sat unchanged for most
of a second after a tap. What the reader has picked is now the card's own
state and shows at once; a Submit at the foot sends every question the tool
is waiting on, greyed until all of them have an answer and a spinner while
the request is out.
Several questions are paged rather than stacked, with the count and a pair
of arrows on the right, because three questions with four described options
each is several screens and the reader scrolls past the one they are
answering to reach the button that sends it.
A permission ask keeps its single tap -- two bare words are not worth a
submit step -- and marks what was pressed until the request settles, so the
mark either stands on the recorded answer or goes away with the failure.
Chevron draws all four directions from one description of the shape, since
the pager needed two more of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.
A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.
The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.
A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The highlights library finds comments before it knows the language and
pairs quotes and comment delimiters by ordinal; measured on 2026-09-03,
that greys out every Rust attribute, comments out the rest of a line at a
URL or a # inside a string, and opens a bogus string at an apostrophe.
Upstream's only fix since 1.1.0 is unreleased and covers just the
reversed range we already drop. The plan replaces it with one sequential
scanner and a table of languages, adding RON, TOML, fish and JSON, which
real transcripts use and the library never knew.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rendering doc's running log of finished work is folded into the
architecture section and the rest dropped: what remains is why the code
is the way it is, the harness, and what is next.
The highlights bug is bigger than the backwards span we already drop.
The library pairs every `/*` with an `*/` by ordinal position and uses
the same delimiters for every language, so a shell glob opens a comment
and `//` in any URL comments out the rest of its line, taking the
keywords and strings inside it with it. highlights-repro.sh asks the
library directly, outside the app, since none of this is visible on a
phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warming covers a settled message, so it did not reach the block a reply is
still writing: that one was re-lexed at every delta, on the composing
thread. Measured streaming a two-hundred-line Kotlin fence -- 211 lexes,
13.7 seconds across the turn, the worst 177ms -- for colours on text being
replaced as fast as they were computed.
A live reply's last segment now draws its code plain and takes its colours
when the block freezes, which for a finished fence is as soon as the next
block starts. The settle-time lex then happens in warm, off the drawing
thread. Same fixture after: one lex of 374ms in warm, draw phase 1.51ms to
1.01ms per frame while streaming, and the fence coloured on screen once the
reply settles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the measurements for the previous commit turned up.
Highlighting a fence cost 174ms for a two-hundred-line Kotlin block, and
the lazy list charged it again every time that block scrolled back into
composition -- six times in one bench run, with the scroll's draw phase at
1.29ms per frame. So it is warmed and cached where parses already are:
`highlight` is a plain function taking no colour from the theme, `warm`
fills `ParsedReplies.highlighted` from `fences(parse)` off the drawing
thread, and `fenceContent` extracts the code here rather than through the
library's composable, so the string warmed is the string drawn.
And the anchor restore asked for a span counted in events, which goes
negative when the anchor's row is the oldest half-row and was coerced to
one -- a request per delta, six hundred round trips walking one reply back
a word at a time with the spinner up. It asks for a page of rows now.
Clean pairs, fresh sessions each side, same gestures. Fence scroll
(transcript-bench.sh): draw phase 0.74ms per frame before highlighting
existed, 0.77ms after, no lexing in the window either side. Streaming
forty linked items (stream-bench.sh): 2412ms of reparsing before, 674ms
after; mean 5.0ms to 1.4ms, worst 8.9ms to 7.9ms. Bullet glyphs, fence
colours, the image links and the reference link checked on the emulator;
lint clean on AGP 9.4.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Items 1-5 of TRANSCRIPT_RENDERING.md's list, plus the AGP 9.4.0 bump from 7.
MarkdownRoot provides the renderer's locals itself instead of calling its
Markdown() composable; fences and indented blocks go through CodeFence.kt,
which shares the tool-input highlighter and a fence-language alias table;
an image in a paragraph is a link carrying its alt text, so every paragraph
is now platform text; LiveParse freezes the finished items of the tail list
so a forty-item list streams as forty paragraphs would.
Measured before, on the emulator (report from transcript-bench.sh over the
200-line fence fixture): draw phase 0.72ms per frame, transcript 0.36ms.
stream-bench.sh (new) streaming forty linked bullets on the old build:
markdown reparsed while streaming 483, 3.9ms mean, 11.6ms worst; record:
one block worst 1.6ms. The after runs, the on-screen check of the glyphs
and lint are recorded as owed in the doc's "What is next".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.
A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.
TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.
The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.
ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three costs left in the renderer's composition layer, taken one at a time:
The text leaf. Every paragraph went through the renderer's text composable,
which exists to place inline images and charged each text for the
possibility: a placement callback, a derived map of inline content, a
semantics group and a size animation. A paragraph with no image -- nearly
all of them -- now goes straight to BasicText, with the renderer's own rule
for a style that names no colour. One with an image keeps the old path.
The table. The renderer decided "spread or scroll" with a BoxWithConstraints,
a subcomposition. LinkedTable does it with one layout modifier placed after
the horizontal scroll: fillMaxWidth fixes the minimum to the room, the scroll
passes that minimum through while lifting the maximum, and the modifier sizes
the rows to the larger of the room and the columns' floor. Rows no longer
need a width handed to them or a row index from a composition local.
The live reply. Every delta reparsed the whole message off-thread; for a
long reply that was tens of milliseconds hundreds of times, every core busy
while the frame's thread waited. LiveParse freezes every top-level block that
a later block has started after -- markdown's block rules make that safe --
and reparses only the tail. Each piece is keyed by where it starts in the
message, so a block keeps its composition when it freezes.
Verified on the emulator: the block-kind fixture draws the same with links
opening from a paragraph and a bullet and plain text opening nothing; a
six-column table still scrolls sideways; a 58-word mixed stream of list,
fence, table and quote drew every block as it arrived, 47 tail reparses at
1.7ms mean against whole-message parses before. ktfmt, build and lint clean
but for the AGP 9.4.0 notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A settled reply used to be cut into block *strings*, each parsed on its own
and each a unit of the lazy list; the live reply split the same way with a
whole-message parse per delta on top. Now a message is parsed once, and a
Piece addresses a top-level block of that tree -- or one item of a
top-level list, which was the one block still unbounded: a list of forty
sources was one item composed whole in the frame it scrolled into. Units,
the live reply's column and peer messages all draw from the same parse,
so warm parses each message once instead of once per block, a delta costs
one background parse instead of two, and a reference definition at the
foot of a message resolves again because nothing is parsed apart from it.
The renderer keeps parsing and providing its environment; MarkdownRoot
wraps that around a piece, and a whole block still goes through its
dispatch with our component table. List items are drawn here, with the
renderer's own paddings so a split list looks like an unsplit one, and
lists inside quotes come to the same code through the table -- the marker
is drawn in one place, which is what a styled bullet would need later.
Found on the way: a heading's words are a child of the heading node, and
the inline builder draws nothing for a node type it does not know, so the
span-link path had been drawing headings empty. LinkedHeading hands it the
content child.
Lint: profileable's shell attribute scoped to API 29 where it exists, and
recordFrames renamed to the composable convention. What remains is the
AGP 9.4.0 notice.
Verified on the emulator against a fixture of every block kind (headings,
nested and ordered lists with a start number, task items, a quote holding
a list, a fence, a rule, a table with a linked cell, a setext heading), a
forty-item list which the render report now shows as per-item units, a
reply streamed live (34 deltas: 34 background reparses, one warm at
settle, no crash), and the older link fixture. ktfmt, build and lint run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Table rows are now ours: each cell is a LinkedText, so a link in a cell is a
span with a string annotation and one tap detector per cell rather than the
layout node Compose builds for every LinkAnnotation -- the cost the paragraph
change removed everywhere else. The renderer's outer table (width, sideways
scroll, corners, dividers) stays; the row and cell were the only parts it
offered no slot for. Cells still wrap and align to the top, with the same
semantics the renderer gave them.
Found while checking it: the hit test took the layout's nearest caret as the
glyph under the finger, so a tap on the right half of any link glyph named
the character after it and opened nothing. That was flaky in paragraphs
already; it now checks the glyph on either side of the caret.
Verified on the emulator: a linked cell and an autolink cell open their
addresses, a plain cell opens nothing, a six-column table still scrolls
sideways, and a link-free table draws as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
modes: [release, debug], release first as the default; build-apk.sh
already takes the mode as its last argument, which is how Dev Updater
passes it. enroll: goes through server/enroll-link.sh rather than the
binary directly, because the command is handed the app's mode too and
because the server may have been built in either profile -- the wrapper
ignores the word and takes whichever ai-server exists, release first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Prints the enrollment URI, one line on stdout, and exits; the running
server adopts the token the first time that device presents it, via the
spool wg-app-link's enroll module now provides (submodule bumped to
d35c880). This is the server half of enrolling through Dev Updater: its
coming per-component Enroll button runs this command and opens whatever
it prints on the phone, which is what a reinstall -- a signing change, a
new phone -- needs when nobody is at the terminal the QR is printed on.
Verified against the sandbox server: minted while it ran, first request
with the token served and the token moved into config.ron, spool empty,
second request served as an ordinary token. 108 tests, clippy clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dev Updater is gaining build modes declared per component as complete
command lines, so the variant is a positional word and nothing else.
Release stays the default and the only one that signs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The phone has been running the debug build: build-apk.sh assembled it, and
nothing in the app or its report said which build a frame time came from.
A debuggable build runs Compose at a fraction of release speed, so the
tuning so far was measured against the wrong number. On the emulator, the
same fixture and gestures: measure 1.4ms mean / 14.7ms worst on debug,
0.8ms / 6.6ms on release.
build-apk.sh now assembles the release variant, signed with a key it
generates once under ~/.config/ai-app (beside the pinned CA, outside any
checkout). The report's header names the build. The one native library is
declared kept-with-symbols so packaging stops warning about an NDK the
build does not need.
Also: ui-sandbox.sh keep now keeps the config too. The server appends
spawned sessions and enrolled tokens to it, so regenerating it left the
transcripts on disk and the registry empty.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
System Tracing on the phone only records an app's own trace sections when
the app is debuggable or profileable, and the phone runs a build that is
neither once release builds exist. profileable shell="true" admits shell
profilers and nothing else. runtime-tracing names composables inside those
sections and is inert until a trace is recording.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Compose materializes every LinkAnnotation as its own layout node -- a
clipped, focusable, hoverable, clickable box laid out against the text --
and the markdown renderer emits one per link. Measured on the emulator
against the same paragraphs with each link replaced by its label and URL
as plain words: the linked version cost five times the worst measure
(26.3ms vs 5.2ms) and 1.7x the place time, with less text on screen. On
the phone that was the bump at a reply's list of sources.
The paragraph, text and heading components now go through LinkedText,
which builds the renderer's annotated string with an annotator that
styles a link as a span carrying its address, and hit-tests taps against
the text layout itself. After: place back at the link-free level (worst
3.1ms), worst measure halved. Table cells and reference links keep the
renderer's path; the table draws its own cells.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The scroll-up freeze-then-skip was the history pager fighting the transcript's
own storage. A streamed reply is stored one token per event -- hundreds of
AssistantText events for one message -- but a page was counted in events, so
on a delta-heavy conversation a page was a fraction of one row: the opening
80-event load was less than a screen, "scroll up a bit" hit the unloaded
boundary at once, and each page the client did fetch cost a 400-event fold
(hundreds of thousands of list copies) that landed as one jarring insertion.
The server now joins each run of consecutive AssistantText deltas into the one
event the client's fold makes of it, and counts a page's limit in these
coalesced rows -- so a page is a page of the screen whatever the delta density.
Measured against a 3,500-event / 100-row echo session on the emulator: a raw
limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns
five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms
and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100
row-events instead of 3,500 token-events. No duplicate keys; the first reply
still reconstructs whole from token zero, so healSplitMessage welds the raw
newest window to the coalesced older pages exactly as before.
Coalescing is opt-in per request (`?coalesce=true`) and applied only to older
pages (`before` set): the newest window keeps real seqs because the live stream
resumes from the newest seq the phone applied, and a coalesced newest event
would hide the deltas after its first seq and replay them. The anchor-restore
path also stays raw -- it counts events to reach a known seq, which a page
measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore
span and its cushion stay in events.
Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping
sessions, so a fixture that costs minutes to build (a long delta-heavy
transcript) survives a server rebuild.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bryan's report after the last change read no worse in the numbers -- medians
unchanged, worst stall halved -- but scrolling felt bumpier while messages
loaded, and instrumenting the split paths found the feeling's likely source:
work the counters never saw.
The live reply's block split (BlockedMarkdown) recomputed on the composing
thread at every delta -- a whole-message parse of the growing text, 815 of
them and 2.9 seconds inside one streamed reply, a few milliseconds per delta
on the thread that draws, plus a cache entry per partial text that nothing
reads again. It now works the way parsedMarkdown already did one file over:
first split inline so the row has its height, every later one off-thread,
drawing one split behind, cached nowhere. Emulator, same streamed fixture:
anim p90 7.3 -> 3.4ms, p99 9.3 -> 4.7ms.
The settle moment had the same shape: nothing warms live deltas, so the
just-finished reply's split parse ran inside the flatten, uncounted, in a
frame. The flatten now splits a reply only when ParsedReplies.splitReady
says warm() has made its parses; the session screen warms the one cold row
off-thread and re-flattens (warmedTick), so the whole-to-blocks swap always
composes against ready parses. Readiness is an explicit mark set by warm()
rather than a peek into the blocks cache, because a message with memory
notes is warmed as its parts -- inferred readiness left it unsplittable
forever and re-warmed on every fold.
Also: user slices shrink to ~1000 chars (about one viewport, so a slice
composing mid-fling costs a few milliseconds, not sixty), and the split and
flatten paths are all timed -- "units flattened", "markdown split into
blocks", "message cut into parts", "user message cut into slices", "blocks
split while streaming" -- so the next "it feels bumpier" report names its
cause instead of hiding it in anim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The occasional bump left in an otherwise smooth transcript was the last
unbounded item: a pasted log in a user bubble is one Text whose layout runs
in the frame the row scrolls into -- 93,808px on the fixture, reported from
the phone as a 112ms worst measure. Per frame it was already cheap (one
node); the cost was entirely the entry.
A message past USER_SPLIT_CHARS is now cut at line starts into slices of
roughly 2,500 characters, each its own list unit. Lines lay out
independently, so slices that own whole lines stack back into exactly the
lines the single Text drew; the threshold is also what guarantees the
bubble was at full width, which the slices must share to read as one card.
Measured on the emulator, same fixture and gestures: worst transcript
measure 57.6ms -> 12.6ms. Fill continuity across slice seams and uniform
63px line pitch verified from full-resolution screenshots; a short message
keeps the ordinary wrapping bubble.
The corner-and-padding geometry that lets one visual card be several list
items now lives once, in Modifier.cardPiece -- Bryan asked for exactly this
generalization so future row types are cheap to add. An opened peer message
and a long user message are its two users; a new sliced kind needs only a
unit type, a flatten branch, and a body wrapped in cardPiece.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The transcript's remaining lag was the newest assistant reply: transcriptUnits
kept the last row whole -- right while it streams (splitting a changing text
is a parse per delta), wrong forever after, so a session that ends on a long
reply drew it as one lazy-list item with every node alive. On a Pixel 9 Pro
XL that was 13.8ms of draw phase a frame, 79% of it the framework's own
per-node bookkeeping, against a 34,996px item.
An AssistantMsg now carries `settled`, folded from the status event that ends
its turn (status changes are transcript events with seqs, so replay settles
the same way), and cleared if a delta ever grows the message again. A settled
newest reply splits like every other. Folding it -- rather than reading the
screen's status -- routes the resplit through the held-events gate, so it can
only happen at the newest end while pinned, never under a reader. The
"session is working" predicate now lives once, in sessionWorking().
Measured on the emulator, same session and gestures, a 43KB reply as the
last row: draw phase 3.92ms -> 1.20ms per frame, framework share 3.07ms
(78%) -> 0.54ms (45%), worst single measure 82.5ms -> 9.1ms. The report's
"on screen" line went from one 60,674px AssistantMsg to five blocks of
95-846px. A live streamed turn settles and splits the moment it goes idle.
The harness half, asked for by Bryan: ui-sandbox.sh now derives its port and
root from the checkout name (two checkouts' sandboxes cannot reach each
other), keeps its token in ~/.config/ai-app/sandbox-token and salvages
enrolled device tokens across restarts (enrol the emulator once, ever), and
gained the driving verbs every UI session was re-inventing in /tmp: spawn,
send (text or @file), api. transcript-bench.sh is the standard
scroll-and-report measurement. AGENTS.md documents all of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A peer message opened was still one item of the transcript list, so every
block of it was composed, measured, placed and kept *alive* while any part
of it was on screen -- and the framework's own per-frame cost grows with how
many nodes are alive rather than how many are drawn. Measured on the
emulator, scrolling the same stretch with a 43KB message shut, opened, and
opened after this change:
shut opened opened, split
draw phase per frame 0.81ms 3.85ms 1.22ms
of that, the framework 0.39ms 3.15ms 0.42ms
frame total, median 16.9ms 24.3ms 21.1ms
So an opened message now costs about what a shut one does. That is the
shape the report from the phone had -- 11.80ms of draw phase with 79% of it
outside anything this app times -- which no counter here could attribute,
because "2 units visible" says one of them is enormous without saying which.
It says which now: the transcript section of the report names every visible
unit and its height, which is what found this.
The card is cut up rather than given up. A filled Material card is elevation
zero (`FilledCardTokens.ContainerElevation` is `Level0`), so there is no
shadow for a seam to show through: each piece paints the same fill, rounds
only the corners at the ends of the message, and keeps the 12dp inset the
card's own column had. Opening and shutting still hold the edge the reader
pressed, and now without a correction -- the list is keyed, so it holds the
item it is anchored on wherever the new ones land.
The crash this turned up is the more serious half. `placePeerNote` gives a
note the seq of the turn it started so it sorts above the reply it caused,
and argued the seq was free because it belongs to a status change and a
status draws no row. True, and about the wrong collision: two messages that
arrive during one turn are stamped with the same turn, so they became two
rows with one key and `LazyColumn` threw -- the app dying in the middle of
somebody reading. Two agents writing to a session mid-turn is an ordinary
afternoon. A note now keeps its own arrival seq as its identity while `seq`
stays the position it sorts at, and which value a row is keyed by moved onto
`TranscriptItem` itself, which also removes the `as? ToolRun` branch that
was doing the same job in `TranscriptRow.Single`.
`transcriptUnits` now says which two units collided if it ever happens
again. All the framework's message carries is the key, and when that key is
a seq it names neither row; two lines here answered in one run what had
taken an afternoon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message from another agent was the one markdown in the app still rendered
whole: one parse and one display list for the entire thing. Every settled
reply has been cut into blocks since the transcript was made lazy, and
`warm` has been making those parses ahead on a background thread -- but it
filtered for assistant replies alone, so the longest message a transcript
holds was also the only one parsed on the thread that draws.
Measured on the emulator against a 43KB peer message, opening it: 177ms in
`markdown parsed while composing`, against none afterwards and 156 blocks
already ready. What is left is the card being a single list item, so all 156
blocks are still measured, placed and recorded at once -- 118ms of placement
in that same frame.
The `when` in `warm` is now the rule rather than a filter: every row that
draws markdown belongs in it.
Blocks are spaced by the transcript's own BLOCK_SPACING rather than the
renderer's internal padding, which moves a heading about 6px (2.3dp) closer
to the paragraph above it. The message's total height is unchanged, and it
now matches every reply in the transcript.
While here: FrameStats was remembered per session screen and DebugStats is a
global emptied only by the copy button, so the two halves of a render report
covered different stretches of time -- and `drawAccounting` divides one by
the other. A report copied after visiting two sessions claimed 36.8 seconds
of placement inside a 13.5 second window, and clamped "everything else" to
0.00ms (0%), which reads as a screen whose entire cost is this app's code.
One FrameStats for the app, so both halves mean "since this was last copied".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live Claude Code path only learns a turn was another agent's when
the turn ends -- the whole of the message arrives as an `origin` object
on the `result` -- so the note was appended after everything it caused,
and the transcript showed the answer above the question.
It cannot be recorded in place: by the time anyone knows, the reply is
already written, and the transcript is append-only. So the event carries
where it belongs instead. `PeerMessage` gains `turnStart`, the seq of
the status that opened its turn, stamped by the pump -- the only thing
that knows a seq and the only thing that sees every driver's turns. The
phone gives the note that seq, so it sorts into place rather than being
drawn out of order at the end. A status draws no row, so there is
nothing for it to collide with and the list stays sorted, which the
scroll anchor and paging both depend on.
Absent where there is nothing to correct: a message replayed out of a
session file by `import` is already in the right place, and one that
opened no turn has no turn to sit above. Both stay where they arrive.
The echo driver gets `/peer-turn` for the live shape, beside `/peer` for
the in-place one. Verified on the emulator both ways, live and on
replay, plus an ordinary `/tools` turn to confirm the run grouping the
insertion cuts through is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: opening the keyboard lags more than it used to, and the scroll
area lags behind the rest of the UI vertically until the keyboard is fully
up.
Both come from the shape of the previous commit's fix rather than from what
it was fixing. Coercing the stuck-open animated inset to zero is right, but
it was written as a bottom padding computed in `SessionScreen`'s body --
`padding(bottom = ... + imeInsets.getBottom(this).toDp())` -- and reading
the inset there subscribes the whole composable to a value the platform
rewrites every frame of the keyboard's animation. That is exactly what the
comment above the box says the arrangement exists to avoid: the transcript
box was meant to be the whole of what a keyboard frame re-measures, with
nothing recomposed at all.
Measured on the emulator with the debug button's counters, over one
keyboard open on an idle session: `session screen recomposed` 16 before,
1 after -- the one being `isImeVisible` flipping, which is the recomposition
the guard actually needs. The per-frame layout work either side is
unchanged (17 measures of the transcript, ~0.6ms each), because that is the
work the keyboard is supposed to cost.
The second symptom is the same cause seen from the other end. The composer
is moved by a `graphicsLayer` block, which re-reads the inset in the draw
phase of the frame it changed; the transcript's padding was reading it in
composition, so the two only stayed together while that recomposition kept
landing inside the frame. `imePadding` reads it in the layout phase of the
same frame, which is where it was before and where the composer can be
followed from by construction.
`isImeVisible` still does the correcting -- the modifier is dropped rather
than the inset zeroed, which is the same coercion by a different route, so
a callback starved of its `onEnd` still cannot leave the composer floating.
Verified by tracking the two against each other frame by frame, from a
screen recording rather than from uiautomator, whose bounds do not update
per frame for a layer translation: the purple outline of the message field
and the last message bubble both move -820px over the ~150ms the keyboard
takes, and are within the 2px measurement floor of each other on every one
of the ten frames in between. Format, compile and lint are clean.
Replaces the connection throttle from the previous commit, which was a
workaround: it made a batch slower without changing that N sessions
meant N ssh connections, and the cap it picked was a guess at somebody
else's sshd config.
The batch now goes out as a single invocation. import::delete takes the
whole list, the remote script loops over the ids and prints one
`<id>\t<state>` line each, and the route settles every row from its own
line. So a batch of any size is one connection and cannot exceed
MaxStartups however many rows are selected -- and it is faster, since
it stopped paying a handshake per session.
Each id still reports on its own: deleted, missing, or failed, kept
apart because only "failed" is worth retrying. Ids the machine never
mentioned -- a connection that dropped part-way -- are reported as
unknown rather than defaulting to either answer, and a malformed id
fails only itself.
Verified end-to-end against a fake ssh that counts connections: a
9-session batch used one, every row settled, both copies of a session
recorded under two project directories went, and the id that was not
there failed with a message saying so rather than a connection error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: closing the keyboard on purpose, while a reply was streaming,
left the composer floating above the bottom of the screen for the rest of
the session -- a bar of background colour under it, nothing that closed it.
The composer's position and the transcript's bottom padding are both driven
by the raw, animated `WindowInsets.ime` value, read inside a `graphicsLayer`
block specifically so a keyboard frame invalidates layer properties only
rather than recomposing the whole screen (see the layout note above it).
That value is carried by a `WindowInsetsAnimationCallback`, and a callback
interrupted mid-flight leaves whatever it was carrying frozen at its last
value with nothing left to correct it -- no further keyboard movement is
coming to fire the callback again. A streaming reply invalidates the view
every frame, which is exactly the condition known to starve a running
callback of its `onEnd`, and that is the "actively responding sessions"
correlate in the report.
`WindowInsets.isImeVisible` doesn't share that failure mode: it is set once,
from the platform's own start/end of the transition, over a different path
(`onApplyWindowInsets` rather than the animation callback) -- so it cannot
get stuck mid-animation the way the interpolated value can. Read once per
keyboard toggle and used to force both the composer's translation and the
transcript's reserved padding back to exactly zero the moment the platform
says the keyboard is gone, whatever the animated value still claims.
Checked on the emulator with an actively streaming echo session: opened the
keyboard, closed it with the system back gesture while the reply kept
growing, and the composer settled flush at the bottom with the transcript
filling the freed space, both immediately and after the keyboard was
reopened and closed again.
Deleting or importing several Claude Code sessions fired one ssh
process per item, all in the same tick. A large enough batch opened
more connections than the remote sshd's default MaxStartups tolerates
before it starts randomly refusing, so some rows failed with
"Connection closed by ... port 2222" -- not a real delete failure,
just too many handshakes landing at once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A
working directory is settled when the process is spawned -- the CLI is
launched with it as its cwd and there is no control request that changes one
-- so this records the new one and ends the process that is in the old one.
It does not start a replacement, and the field says so in a line beside it:
a session with no process starts on the next message or on Start, which is
this app's rule for that everywhere else, and "usually restarts" is a worse
control than "always stops".
The path is checked against the session's own machine and refused if it is
not there. The spawn path corrects instead of refusing, because it is
resuming a directory the *machine* recorded and that can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here would surface much later as a session that would not
start, with nothing pointing at the typo. The refusal names the machine and
the path, and is drawn under the field it is about.
Nothing of Claude Code's own is moved, and that is measured rather than
assumed: on CLI 2.1.237, `claude --resume <id>` finds a session from any
working directory -- an id that does not exist answers "No conversation
found with session ID", and a real one resumed from an unrelated directory
did not. So the conversation continues in the new place with nothing
relocated. Doing otherwise would mean reproducing a rule this app cannot
see the whole of; PLAN.md records what that rule is, for whoever tries.
Found while checking it: `SessionInfo.cwd` came from the snapshot a session
launched with, so a moved session went on reporting its *old* directory for
as long as its process lived -- a dialog showing a directory the next launch
would not use, with nothing saying so. It is read from the config where the
row is built now, the same way `setup_name` already was, and for the reason
already written above `setup_name`: only the manager holds the config, and
both of these change under a running session.
Checked end to end on the emulator against a session whose process really
does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the
dialog showed the new path immediately rather than after a restart, and a
directory that is not there and a relative path were both refused with the
session left exactly as it was.
Peer messages were only ever produced by the *import* path, reading them out
of the CLI's own session file. A message another agent sent to a session
this server was driving appeared nowhere, so the session started working on
something nobody on the phone had asked for and there was nothing on screen
to explain it.
Measured rather than guessed, because the obvious place to look for it is
empty: a real cross-session message sent to a real `--input-format
stream-json` session on CLI 2.1.237 produces **no `user` record**, and
nothing in the partial-message stream mentions it either. The whole of it
arrives as an `origin` object on the turn's `result`, in exactly the shape
the session file records -- so `import::peer_message` now reads both, one
function for one wire format. Two copies would drift the first time a field
is renamed, and the half that drifted would go on producing nothing, which
is indistinguishable from nobody having sent anything.
The cost is the position: the note lands after the reply it caused rather
than above it, because at no earlier point in the turn does the CLI say why
the turn started. Taken deliberately over the alternative -- a second reader
tailing the CLI's own session file for the one record stdout does not carry,
which is two sources of truth for one conversation and a poll per live
session. Recorded in PLAN.md so that if the CLI ever announces the injection
where it happens, the next reader knows to move it there.
Both halves tested: the real record shape, and an ordinary result carrying
no `origin` -- which is the half that decides whether the check is a check.
Four ordinary results on a real session's stdout had none between them.
Two defects on the same path, the second found while trying to reproduce
the first. Both are invisible against a loopback server and both show up at
`--delay 150`, which is what a phone over the tunnel actually costs.
**A run of tool calls came back as two groups.** `joinPages` heals three
things across a page boundary -- a message cut in half, a call separated
from its result, and the *run* a group is named after -- but the third only
ran on the path where a split call had been found. A boundary landing
cleanly between two finished calls, which is most of them, went straight to
concatenation and left the older page's calls under the name they were
folded with. On screen, one run of twelve drawn as "Called 7 tools" and
"Called 5 tools", with the seam wherever the reader happened to have paged.
The two early returns were an optimisation on a list the size of one page,
and what they saved was the work.
**And nothing older loaded at all.** The history pager fires on the first
layout, before a single event has arrived: `moreHistory` starts true, so the
spinner is in the list, so `visibleItemsInfo` is not empty, and with no
units loaded the room ahead adds up to zero. It then asked for the events
`before = 0` -- the ones before the first one, which is none -- and an empty
page is precisely how this code is told it has reached the start of the
conversation. So `moreHistory` latched false, racing the opening page's own
write of true, and a session that lost the race stopped one page from its
newest end with no spinner and nothing on screen to say why. Guarded inside
`loadOlderPage`, because it is a fact about the question rather than about
who asked: the post-open fetch reaches it too, on the path where the opening
page failed and left `oldestSeq` unset.
Checked both ways round on the emulator, with the boundary placed on
purpose (the opening page is 80 events, so it is a matter of counting back
from the newest): 7 + 5 without the join fix, one group of 12 with it. And
the case the change had no reason to touch still holds -- a boundary that
*does* split a call, which is the path that always worked, and one through a
streamed reply, which `healSplitMessage` owns and this does not go near.
A message written into the tail of a turn is read the moment that turn's
`result` lands: the session reports idle and is running again in the same
breath. The phone that sent it got "finished" in between -- seconds before
anything it asked for had been done, which is the notification arriving to
say the opposite of what is happening.
`notification_for` now takes how many messages the session has been given
and not started reading, and a turn ending with any of them waiting is not
an ending. The count is kept in `pump`, from the recorded events, because
that is the one place that sees all of them in transcript order: a
`messageQueued` up, and the `userMessage` that resolves it or a
`messageDropped` down. Asking the driver instead would answer about the
moment the question was asked rather than the moment the status was
written, which is the same class of mistake as reading a session's status
to decide what a queue contains.
It deliberately does not suppress *awaiting input*. A question is worth
interrupting somebody for whatever is queued behind it -- the queue is
precisely what will not move until it is answered.
Tested both halves: the decision on the number, and the number itself,
where an echo turn that reads its queued message before going idle still
announces its finish. That last is the case a suppression written slightly
wrong silences, and it is the common one.