Commit Graph
312 Commits
Author SHA1 Message Date
iris 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.
2026-08-31 22:56:23 -04:00
iris a1eedd7a78 Don't call a turn finished with a message still waiting behind it
A message written into the tail of a turn is read the moment that turn's
`result` lands: the session reports idle and is running again in the same
breath. The phone that sent it got "finished" in between -- seconds before
anything it asked for had been done, which is the notification arriving to
say the opposite of what is happening.

`notification_for` now takes how many messages the session has been given
and not started reading, and a turn ending with any of them waiting is not
an ending. The count is kept in `pump`, from the recorded events, because
that is the one place that sees all of them in transcript order: a
`messageQueued` up, and the `userMessage` that resolves it or a
`messageDropped` down. Asking the driver instead would answer about the
moment the question was asked rather than the moment the status was
written, which is the same class of mistake as reading a session's status
to decide what a queue contains.

It deliberately does not suppress *awaiting input*. A question is worth
interrupting somebody for whatever is queued behind it -- the queue is
precisely what will not move until it is answered.

Tested both halves: the decision on the number, and the number itself,
where an echo turn that reads its queued message before going idle still
announces its finish. That last is the case a suppression written slightly
wrong silences, and it is the common one.
2026-08-31 22:43:24 -04:00
iris 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.
2026-08-31 22:41:04 -04:00
iris 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.
2026-08-31 22:33:31 -04:00
iris b7277a5a04 Let go of the bubble for a message the session died before reading
`Queue::close` reports the messages a dead process never read -- they
reached no transcript, so that error is the only place they are ever
mentioned -- but it left each one drawn as a bubble waiting to be read, by
a session that no longer exists. Nothing would ever clear it: the
`UserMessage` that resolves a queued bubble is exactly what is not coming.
Seen on the emulator as a grey bubble sitting under its own error message,
still saying "tap to take it back", on a session reporting `exited`.

It now sends the `MessageDropped` the unqueue route introduced, one per
lost message, alongside the error. The error says what happened and the
drop is what ends it, which is the same division of labour as the rest of
this path.
2026-08-31 22:33:11 -04:00
iris c9d74b63f2 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-31 22:20:51 -04:00
iris 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.
2026-08-31 22:20:47 -04:00
irisandClaude Opus 5 7a8811aab3 Offer a session once when two project folders hold it, and never crash on a repeat
Resuming a Claude Code session from a different working directory makes the
CLI write a second transcript with the same id under that directory's
project folder. This machine has one: 160 KB under `-home-bob-repos-tdep-survey`
and a 614-byte stub under `-home-bob-repos-tdep`. Everything downstream
addresses a session by id -- `--resume` takes it, the delete glob resolves
it, the in-flight registry is keyed on it -- so two rows sharing an id are
two rows no operation can tell apart, and the phone keys its list on it, so
scrolling to them closed the app on Compose's duplicate-key throw.

The listing now keeps the copy with the most in it. Size rather than
recency, because the stub is often the newer of the two, and picking it
describes the session by the wrong size, the wrong cwd and the wrong title.
Deleting removes every copy rather than stopping at the first, which had
left the row to come back on the next listing after a delete that reported
success.

The phone's half is `uniqueItems`: every list keyed on a server-chosen id
goes through it, since none of them could rule the repeat out locally and a
data problem must not be able to close the app. Verified both ways against
the real duplicate -- the unguarded build reproduces the reported stack on
the same id, the guarded one scrolls the whole list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:12:49 -04:00
irisandClaude Opus 5 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>
2026-08-31 21:57:36 -04:00
irisandClaude Opus 5 778b2e3b04 Wrap table cells instead of cutting them off, and hand a batch over in one request
The renderer draws every table cell at one line with an ellipsis, so most
of a table was unreadable on a phone -- and an elided cell looks exactly
like a short one, so nothing said anything had been cut. Cells now take as
many lines as they need and align to the top of the row. Width is the other
half: a column narrows to 136dp and no further, and past that the table
scrolls sideways rather than squeezing. 136 is the widest floor that still
fits three columns across a phone, measured rather than picked; four and up
scroll, which is the right answer for genuinely too many columns.

The import screen used to send one request per selected row, so a handover
was only as atomic as the network: some rows started and the rest were
never asked for, and a row nobody asked for looks exactly like a row nobody
picked. `POST /setups/{id}/importable/delete` and `.../import` now take the
whole list, and every id is registered as in flight before the 202 goes
back. Only the registering is atomic -- the work settles per row, since six
deletes that all roll back together is not something a filesystem offers.

The echo driver grows `/table N`, with cells long enough to have been
truncated: a fixture of tidy one-word values renders fine whether or not
the bug is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:27:20 -04:00
iris 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.
2026-08-31 21:05:33 -04:00
iris 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.
2026-08-31 20:29:07 -04:00