6d12388063ac702871ea92f6f5a33812fbf396f4
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
c9d74b63f2 | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
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.
|
||
|
|
8257030280 |
Never let a dropped event stream close the app
Both screens that follow a stream retried an `ApiException` and let everything else through, and `Sse.run` opened its connection on a line outside the `try` that maps failures onto that type. So a failure at open time, or anything the framing did not expect, reached the top of the app and closed it -- from a screen whose own comment says failures there are deliberately quiet, because the listing already carries every state the stream would have brought. The open moves inside the guarded region, and both loops now retry on any exception while rethrowing `CancellationException`, which is the screen leaving rather than a failure -- swallowing that one would leave the loop reconnecting to a stream nobody is watching. This is hardening on the path that runs when a screen with a stream opens, not a diagnosed fix: an import list loading against a server missing the events route, and against 121 real transcripts, does not crash here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c159fa1e1 |
Run imports and deletes on the server, and say so on an event
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
|
||
|
|
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. |