Commit Graph
276 Commits
Author SHA1 Message Date
irisandClaude Opus 5 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>
2026-09-01 16:12:24 -04:00
irisandClaude Opus 5 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>
2026-09-01 15:20:24 -04:00
iris 6d12388063 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-01 01:20:46 -04:00
irisandClaude Opus 5 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>
2026-09-01 01:20:38 -04:00
iris 4add4fd176 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-01 00:51:21 -04:00
iris 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.
2026-09-01 00:51:15 -04:00
iris 7525fc925a Merge remote-tracking branch 'origin/main' 2026-09-01 00:32:56 -04:00
irisandClaude Opus 5 dce1799802 Delete a batch of imports in one command, not one connection each
Replaces the connection throttle from the previous commit, which was a
workaround: it made a batch slower without changing that N sessions
meant N ssh connections, and the cap it picked was a guess at somebody
else's sshd config.

The batch now goes out as a single invocation. import::delete takes the
whole list, the remote script loops over the ids and prints one
`<id>\t<state>` line each, and the route settles every row from its own
line. So a batch of any size is one connection and cannot exceed
MaxStartups however many rows are selected -- and it is faster, since
it stopped paying a handshake per session.

Each id still reports on its own: deleted, missing, or failed, kept
apart because only "failed" is worth retrying. Ids the machine never
mentioned -- a connection that dropped part-way -- are reported as
unknown rather than defaulting to either answer, and a malformed id
fails only itself.

Verified end-to-end against a fake ssh that counts connections: a
9-session batch used one, every row settled, both copies of a session
recorded under two project directories went, and the id that was not
there failed with a message saying so rather than a connection error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:32:52 -04:00
iris aaf475fb1f Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-01 00:26:20 -04:00
iris 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.
2026-09-01 00:26:19 -04:00
iris 7a1a462caf Merge remote-tracking branch 'origin/main' 2026-09-01 00:23:31 -04:00
irisandClaude Sonnet 5 927b638feb Cap concurrent ssh connections a batch opens at once
Deleting or importing several Claude Code sessions fired one ssh
process per item, all in the same tick. A large enough batch opened
more connections than the remote sshd's default MaxStartups tolerates
before it starts randomly refusing, so some rows failed with
"Connection closed by ... port 2222" -- not a real delete failure,
just too many handshakes landing at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 00:23:23 -04:00
iris 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.
2026-08-31 23:16:34 -04:00
iris 6236f0d5bd Show a message another agent sent, on a session this server is running
Peer messages were only ever produced by the *import* path, reading them out
of the CLI's own session file. A message another agent sent to a session
this server was driving appeared nowhere, so the session started working on
something nobody on the phone had asked for and there was nothing on screen
to explain it.

Measured rather than guessed, because the obvious place to look for it is
empty: a real cross-session message sent to a real `--input-format
stream-json` session on CLI 2.1.237 produces **no `user` record**, and
nothing in the partial-message stream mentions it either. The whole of it
arrives as an `origin` object on the turn's `result`, in exactly the shape
the session file records -- so `import::peer_message` now reads both, one
function for one wire format. Two copies would drift the first time a field
is renamed, and the half that drifted would go on producing nothing, which
is indistinguishable from nobody having sent anything.

The cost is the position: the note lands after the reply it caused rather
than above it, because at no earlier point in the turn does the CLI say why
the turn started. Taken deliberately over the alternative -- a second reader
tailing the CLI's own session file for the one record stdout does not carry,
which is two sources of truth for one conversation and a poll per live
session. Recorded in PLAN.md so that if the CLI ever announces the injection
where it happens, the next reader knows to move it there.

Both halves tested: the real record shape, and an ordinary result carrying
no `origin` -- which is the half that decides whether the check is a check.
Four ordinary results on a real session's stdout had none between them.
2026-08-31 23:02:49 -04:00
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