A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.
On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.
Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).
Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.
The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.
The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.
Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.
Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.
Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.
Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.
Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.
transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.
Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A table is recognised by its delimiter row, the only line of one that cannot
be anything else, and its header is the line above -- the single place the
scanner looks ahead. Colouring every `|` instead would have marked the pipes
of a shell command written in a paragraph.
Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign
inside it and no whitespace, which leaves `<div>` alone; a bare `scheme://`
needs no closer, so where it ends is the decision -- the sentence's trailing
punctuation is given back, and so is a closing bracket unless one opened
inside the URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The token scanner asks what a character is; markdown's meaning is where it
sits, so a `#` opens a heading at the start of a line and is an ordinary
character three words in. `MarkdownSyntax.kt` reads structure a line at a
time and then each line's prose left to right, and `spansOf` is the one
entry point that hides which of the two scanners a language got.
Conservative wherever a guess would be invisible: emphasis needs a closer on
the same line with no space beside either marker, so the `*p = *q` of a C
fragment opens nothing; an underscore may not start or end inside a word;
and an indented code block is left plain, since four spaces after a blank
line and four after a bullet are the same line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing one `ScrollState` across the viewer's rows was not enough to make
them move together. `Modifier.horizontalScroll` is a node per row, and each
one coerces the shared offset into *its own* range -- its content width
less its viewport -- so a short line's range is zero and it stayed put
while the long line beside it moved. Each row also writes `maxValue` on the
shared state as it measures, so how far the file could be dragged at all
was decided by whichever row happened to measure last, and changed as the
list scrolled.
Both go away once every row is the same width. `FileLines` now carries the
longest line in columns, and the viewer turns that into one content width
from a single character's advance -- arithmetic rather than twenty thousand
measurements, because the face is monospace -- and gives it to every row. A
tab counts as eight columns and deliberately upwards: over-estimating
leaves a little empty space past the longest line, under-estimating puts
the end of that line out of reach. The width is capped well under what
`Constraints` can carry, so a minified file is a scroll that stops early
rather than a crash.
Reported by Iris on 2026-09-04. Checked on the emulator against the
generated 1 MB file, whose lines run from one character to sixty-eight:
the file now moves as a block, the offset survives scrolling vertically and
newly composed rows arrive at it, and the far end of the longest line is
reachable. Also checked on the two cases the change had no reason to touch
-- a file narrower than the screen, which still does not scroll at all, and
an empty one, whose zero content width draws its one numbered line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other half of EXPLORER.md: a folder button on the session header opens
the machine's filesystem, starting where the session works.
It draws **over** the session in the same `Box`, so the session under it
stays composed -- its event stream keeps flowing, its draft and scroll
position stay where they were, and coming back from a file costs nothing.
Back steps one level inside it (editor, viewer, directory, parent) and only
closes from where it opened; the platform gesture, the button and the swipe
all go through the one function, so they cannot mean different things.
The viewer is a `LazyColumn` of lines rather than one `Text`, because text
layout is linear in the text and a twenty-thousand-line file in a single
`Text` measures all of it to draw a screenful. Lines do not wrap and share
one horizontal scroll, so a logical line is a visual line and the gutter
cannot come to number the wrong text; the gutter's width is measured from
the digit count of the line count in the style it is drawn in. The editor
is a `BasicTextField` with a `VisualTransformation` carrying the scanner's
spans, which is the one Compose API that colours a field's own text rather
than replacing the field.
`fileLanguage` reads the same table `fenceLanguage` does, so a language
added for fences is a language added for files.
A file that changed on the machine while it was open here refuses to be
overwritten and asks, with what each of the three answers costs. That is
the ordinary case, not the exotic one: an agent editing the file somebody
is reading is what this whole feature is for.
The speedometer moves off the header into the session settings dialog,
where the session's other about-the-session controls are, and the folder
takes a place between the usage chart and the cog -- widest scope to
narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto
`ui-trace`'s new tap-by-label action in the same change, so the render
report is never unavailable and never pressed at a coordinate that has
stopped meaning anything; `app/bench-lib.sh` is what they share, and
`grep -n "tap [0-9]" app/*.sh` is the check.
Exercised on the emulator against the sandbox's new fixture tree, with a
screenshot or a ui-trace for each: the listing (dotfiles, directories
first, a symlink to a directory sorted with them, a name with a tab in it),
a highlighted file, binary, too big, a permission error, editing and
saving, the 409 and its Overwrite, back with unsaved edits, creating a name
that exists, creating one that does not and landing in the editor, an empty
directory, and `..` above the directory the session opened in.
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>