Commit Graph
8 Commits
Author SHA1 Message Date
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 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 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