cd0229bed63d72090ca870e56d47035343df5331
107
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6bdec6e785 |
Let a session resume itself when its usage limit lifts
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>
|
||
|
|
4821a02bd3 |
Default thinking level for new sessions, and move the rigs out of AGENTS.md
`Config::default_effort` is what a session starts at when nothing chose one, applied in `spawn_session` rather than filled in by the spawn screen so it holds for an import and a bare API call too. It is set by the spawn screen's own picker, whose label says so: one control, where new sessions are made, rather than a settings page for a single value. Not on a provider, because providers are discovered and the next rediscovery would erase it; not on the phone, because a second device would then spawn at a level nobody there chose. `GET`/`POST /defaults` carry it as a struct, so the permission mode -- still hardcoded to `auto` on the spawn screen -- can move there later without a second route. Only drivers that read a level are given one: an echo session was storing a `--effort` it never passes to anything, which is a config file answering a question about itself wrongly. Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12 KB of it was rigs and reference measurements that only matter once you are running one. Those are the `ai-app-rigs` skill now -- the same text, still the only copy, read when the work touches it. 35,198 -> 20,813 chars. Verified on the emulator against the sandbox: the spawn screen pre-fills from the server, picking `low` spawned a session at `low` and left `/defaults` set to it, and an echo session spawned afterwards took no level at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1ff662c7c3 |
Let a session choose how hard it thinks
Output is about an eighth of what a session costs and thinking is nearly all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests of this account's own transcripts -- so the level is the largest saving available short of shortening the conversation itself. Shaped like the working directory rather than like the model: the CLI's only two setting control requests are `set_model` and `set_permission_mode` (checked against the 2.1.258 binary), so `--effort` is read when the process launches and cannot be asked of a running one. `set_session_effort` records the level and stops the process; the next message or Start launches one that has it. That is also why the picker is in the session settings dialog beside Move, and not on the bar beside the model and the mode, which take effect mid-turn. `None` is a level in its own right -- the CLI's own default -- so the picker can return to it, and a blank is normalized to it at the boundary rather than stored as a level the CLI would reject. Offered only where it means something: `DriverKind::takes_effort` reports the capability and the phone leaves the row out entirely, rather than the session-type branch this app does not have anywhere else. A llama session would otherwise get a control whose only effect is stopping its process. Verified on the emulator against the sandbox's fake CLI: the picker sets it, the server reports it, and an echo session's dialog is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c0214ece8 |
Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs |
||
|
|
127b25e60a |
Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
edc39c7371 |
Thin the app's comments
The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e3e02d55f7 |
Merge remote-tracking branch 'origin/main'
# Conflicts: # app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt |
||
|
|
a802522039 |
Keep visited transcripts on the phone
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> |
||
|
|
7997eeb7f8 |
Colour markdown's tables and the addresses written in it
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> |
||
|
|
68c5180260 |
Colour markdown, which is the one language that is not tokens
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> |
||
|
|
a401e6a7e3 |
Draw the line numbers beside the file, not inside it
The stretch is rendered on the box around the list, so everything in that box bent with it -- the numbers included. `Modifier.overscroll` wraps the effect's single node, so one effect cannot be rendered in two places and the numbers cannot be excluded while they live in the rows. They had to leave. They now sit beside the scrolling box: the rows leave a spacer where the numbers go and a `SubcomposeLayout` draws them there. That is the one arrangement that keeps them level with their lines, which is the thing a numbered listing may never get wrong. Which numbers exist *and* where each one goes both come from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during measurement, so it composes from the answer the list has just produced rather than from one it read a frame ago. A column translated by the scroll position could not do that: the translation would be a layout read and current while the set of numbers was a composition behind it, and during a fling the numbers would slide against their lines. Checked by sampling the screen at about 1kHz through a fling: 23,520 row observations over 552 frames, every one with its number at exactly its own top, no drift at any point. Also that the gutter holds its x while the text scrolls sideways, and that a short file and an empty one still draw. A consequence worth having: the numbers are no longer inside the `SelectionContainer`, so selecting part of a file and copying gives the code rather than the code with a number in front of every line. That follows from where they are now rather than from anything asked for, and a copy was not exercised. The stretch itself still cannot be seen from this VM, so whether the numbers now stay flat while the text bends is the thing to look at on the phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b08a71e64 |
Stretch the whole file at the ends, not one line
`Modifier.horizontalScroll` builds its own overscroll effect per node, so with a node per row only the line under the finger bent when the file was dragged past either end and the rest sat still beside it -- the same complaint as the offsets, one layer out. There is an overload that takes the effect instead of making one, and it leaves the rendering to the caller. So the viewer makes one effect, hands it to every row, and renders it once on a box around the list: the file bends as the block it scrolls as. This only works now that every row is the same width -- rows that disagreed about where the end was would disagree about when to stretch. **Not seen working.** Measured on the emulator here: over-dragging well past the end and capturing mid-gesture produces a frame with no stretch in it at all, and the list's own vertical overscroll does not appear either, so this VM cannot show the effect for any scrollable. Noted in ~/.claude/MACHINE.md so the next session does not spend the same half hour on it. What was checked here is everything either side: the scroll still reaches both ends, the position survives scrolling vertically, and nothing else moved. The stretch itself wants a look on the phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a074975d6f |
Scroll the whole file sideways, not each row by its own amount
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> |
||
|
|
2c12274285 |
Measure the explorer, and cap edit mode at what it can carry
Three numbers, taken on the emulator through the app's own render report and written into EXPLORER.md; the fixture tree the sandbox now builds is what they were taken against. The viewer's scan was on the main thread. Decision 8 said off it, and the first version did it in a `remember` inside the composition, which is not that -- 460ms of frozen screen on a 1 MiB file, long enough that the accessibility tree cannot be read, which is exactly what "the app has stopped" looks like from outside. It runs on Dispatchers.Default now, with a spinner where the file will be. Reading a megabyte is otherwise fine: the viewer is a row per line, and it opens and scrolls 28,660 of them. Edit mode needed a cap, and not the one the plan expected. The cost that matters is not the highlighting -- 40ms a keystroke at 128 kB, which is survivable -- it is Compose laying out one enormous text in the field: 2,027ms per frame at 128 kB, with typed characters dropped, and no response at all at 1 MiB. Switching highlighting off would have saved nothing, since every arrangement of a single text field pays it. So EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and above it the pencil is disabled with the reason in words beside it: a disabled control teaches what the thing can do but cannot say why it is off, and a reader who cannot edit a file they can plainly read would otherwise conclude the app is broken. `FileLines.of` is timed like everything else here, so the figure lands in the render report rather than needing a harness to ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9c4d33273b |
Merge branch 'main' of git.arirex.me:iris/ai-app
The drag-right-to-go-back gesture was removed on the other side while the file explorer was being built on this one, so the explorer's own use of it goes with it: back inside the explorer is the arrow and the platform's own gesture, which is what the rest of the app now has too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
db55ed4a8f |
The file explorer on the phone
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> |
||
|
|
8881a40919 |
Remove the drag-right-to-go-back gesture
The screen no longer follows a horizontal drag. Back is the arrow at the top left and the platform's own edge gesture, both unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3bb178363d |
Draw an inline code chip behind the text instead of under it
The chip was the renderer's span background, and a span's background is part of the text's own drawing: the text node paints the selection first and the glyphs over it, so an opaque chip covered the selection and selecting a sentence highlighted every word of it except the ones in backticks. The previous fix let the selection show through by taking the chip to 60% alpha, which is a compromise on both sides -- the chip is a weaker step down from the page, and selected it reached #3C344F where the words around it reached #776394. There is a place that is under both, and a fenced block was already in it: a modifier on the text rather than a style inside it. So `appendCodeChip` takes the code span from the renderer's inline builder, keeps its style and its space of padding either side but drops the background, and marks the range; `LinkedText` draws those ranges in a `drawBehind`. The chip is back to the full `rawSurface` fill (measured #11111B against a #1E1E2E page) and a selection over it now lands at #776394, the same as the rest of the sentence -- the fenced block's numbers exactly. The geometry is one box per line, from the bounding boxes of the run's first and last characters, taken as far as the line's `visibleEnd`. Not `getPathForRange`: that is the shape of a *selection*, which runs to the right edge of every line but the last, and a code span that wrapped left a full-width empty chip behind on the line above -- twice in one fixture. `visibleEnd` is the same rule the selection rectangle obeys, so the chip stops where the selection stops instead of sticking its padding space out past the end of a selected line. Checked on the emulator against a fixture with chips in a heading, three kinds of list item, a quote, a table cell and a link label, unselected and under Select All, and a link with a chip in its label still opens. Cost, against the same build without the change, streaming sixty paragraphs of three chips each: measure 755ms against 776ms, record 327ms against 321ms, transcript draw 0.22ms in both. |
||
|
|
aa6d9b256e |
Let a selection show through an inline code chip
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> |
||
|
|
acdf00ab1d |
Close a card by pressing its words, and let a selection be put away
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> |
||
|
|
359649bc73 | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
a2b11d516f |
Colour code with a scanner of our own instead of the library
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> |
||
|
|
a383c19dd5 |
Colour what a shell printed, swipe back, and let a stopped session take a setting
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> |
||
|
|
8dcd2cb708 |
Answer a question card as one act, and mark the press that made it
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> |
||
|
|
32b2a4871a |
Read a timeout in units, share one usage answer, and let a tilde mean home
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> |
||
|
|
b0b91ad28f |
Draw a fence plain while it is still being written
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> |
||
|
|
ab6a797941 |
Warm a fence's highlighting like a parse, and page the restore by rows
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> |
||
|
|
6892dc7caf |
Colour list markers by depth, highlight fences, draw images as links, and stream a list an item at a time
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> |
||
|
|
801618ba0e |
Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work
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> |
||
|
|
6180663f14 |
Attach any file, take shares from other apps, and survive a backwards highlight
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> |
||
|
|
4bc69e8f9c |
Draw text on the platform directly, own the table's outer box, and parse a live reply a block at a time
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> |
||
|
|
48bb7de304 |
Draw replies as pieces of one parse, and lists an item at a time
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> |
||
|
|
a9ab9b3e5a |
Draw table cells as span-linked text, and hit-test links by glyph
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> |
||
|
|
8750ff90cb |
Build the phone's APK as a signed release, and say so in the render report
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> |
||
|
|
486842e4aa |
Make the release build traceable: profileable, with composable names
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> |
||
|
|
f87c99c71b |
Draw a reply's links as text spans, not a node per link
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> |
||
|
|
63c0bb9e4a |
Page history by rows, coalescing a reply's deltas server-side
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> |
||
|
|
356dee65f1 |
Take every whole-message parse off the composing thread, and count the rest
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> |
||
|
|
333d2de92b |
Draw a long user message in slices, through a shared card-piece surface
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> |
||
|
|
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> |
||
|
|
6d12388063 | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
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> |
||
|
|
28902ac834 |
Read the keyboard's inset in the layout phase again, not in composition
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. |
||
|
|
858b4148ad |
Stop the composer sticking above the bottom of the screen after the keyboard closes
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. |
||
|
|
deb908034c |
Move a session to another working directory
`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.
|
||
|
|
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. |
||
|
|
bfaf5e6f38 |
Keep the image on screen when its tool call joins a group
A `Read` that returns an image is a row of one call, and the moment the session makes its next call the two become a group -- which is a different composable in a different part of the tree, so the old subtree goes and everything it remembered goes with it. The full-screen viewer was inside that subtree, so somebody looking at a screenshot was thrown back to the transcript because the session carried on working. A page of history landing does the same thing to the same row. What is open is a property of the screen rather than of whichever row happened to draw the thumbnail, so it is held there now and drawn beside the other two dialogs. Nothing that happens to rows can reach it. The cost is one fetch when it opens, since the thumbnail's decoded bitmap belongs to a row this no longer goes through. Paid deliberately rather than plumbed around: it is one request for a picture somebody asked to see, and the viewer draws the same two empty states the thumbnail does -- still coming, and never coming -- which it previously could not have, since it only ever opened on a bitmap already in hand. `/tools n gap` now puts a screenshot on its first call, so the case is reproducible rather than argued about: that command already existed to make a run *grow* while somebody watches, and the image is what made growing matter. Checked on the emulator with `/tools 3 30` -- opened the image on the lone call, and it was still open a minute later with the row by then inside a group of three, and back returned to the transcript rather than leaving the app. |
||
|
|
ed88bdb31f |
Mark the answer on the question, and close the notes that are not turns
Four things the transcript and the composer said badly. **An answered question threw away the question.** It collapsed into "Answered: Deny", which does not say that Allow was the alternative -- and whether a tool was allowed or refused is what a reader comes back to that row for. The options stay now and the one that was taken is marked, in the same purple border that says "picked" while the question is still open, so it is one appearance learned once rather than two renderings of one thing. The buttons are disabled rather than removed, and state their own border and label colour, because Material dims a disabled button's and that would have taken the mark with it. Both places got it: the question card, and the permission ask on a tool row, which had the same line. An answer typed into **Other** matches no option, so nothing could mark it. That one is still written out -- it is the state the marking cannot say. **Memory notes were open.** A `<cc-memory>` note is not part of what was said to the reader, it is a note about where a claim came from, and left open it breaks a reply in half around a card. Closed like a tool call and a peer message, with the file it came from still visible, since that is what somebody scanning for "why does it think that" is looking for. Open-ness is the screen's rather than the card's, so a note opened and scrolled past is still open on the way back. **Picking a slash command left its own suggestion up.** `/compact` is a whole command and a prefix of itself, so the list stayed with the one row already chosen -- something to dismiss, in front of the box it was about to be sent from. **A model switch warned when there was nothing to warn about.** The warning is that a cache is dropped, so it needs there to be one: a session whose process has exited has nothing holding a cache, and one reporting zero context is holding nothing. Where the figure is *unknown* the fallback is what it was -- whether anything has been said -- because unknown is not nothing, and an import nobody has measured yet is exactly where the conversation may be enormous. |