7a48f8ff1fe8462c27f235214840222791e278fa
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7a48f8ff1f |
Split the newest reply once its turn ends, and make the UI harness reusable
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> |
||
|
|
917eb9a7b3 |
Draw an opened peer message as list units, and give a peer note its own key
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>
|
||
|
|
80aaf286c2 |
Draw a peer message a block at a time, and parse it off the drawing thread
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> |
||
|
|
465645cefb |
Draw a peer message above the turn it started, not below it
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> |
||
|
|
fe6a36bde4 |
Page back at all, and merge the run the boundary fell through
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. |
||
|
|
82401cd887 |
Select any of the transcript, and take a queued message back
Two things a reader could not do to what is on screen.
**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.
**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.
The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.
Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
|
||
|
|
b172c464ea |
ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here. |