Commit Graph
255 Commits
Author SHA1 Message Date
iris a18a57e73e Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 14:38:38 -04:00
irisandClaude Opus 5 61af05c732 A backend start adopts, and starts nothing
Restarting the server relaunched a driver for every session in the
config, and ClaudeDriver::launch starts a process when there is none to
adopt -- so a session somebody had deliberately stopped came back at the
next rebuild, and the Idle its new driver announced stamped the session
as active at that moment. On the phone that read as every session idle
and "just now" after every restart, with the list sorted by that time in
an order that meant nothing.

A launch now says why it is happening. Launching::Restart takes charge
of the processes still running and leaves every other session as it
found it; Launching::Asked -- a spawn, a Start, a message -- starts one
where there is none. A session with no process therefore has no driver:
DriverCell is an option rather than a driver whose requests go nowhere,
and LiveSession::ask reports what could not happen instead of sending
into a dead fifo.

Two clocks that moved on their own, both the same lie in the same field
that Transcript::last_activity exists to prevent:

- The status a launch has to correct is written into the transcript at
  the time of the last thing the session actually did. A backend killed
  mid-turn leaves a transcript saying Running, which has to become
  Exited -- but this server noticing is not the session doing something.
- A session that has never done anything reports when it was created. Its
  transcript is empty, since a driver announcing the state it starts in
  is not news, so it is the one session with no line to read a time off
  and the clock was the fallback.

Exercised end to end against a real server: a stand-in CLI adopted
across a restart keeps its status and its time, a stopped session stays
stopped with no process started, a process killed while the backend was
down reports exited stamped at the last thing the session did, and Start
brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:38:31 -04:00
iris d981d63bcc Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 14:27:31 -04:00
irisandClaude Opus 5 fd616361eb Hold the transcript still under a scroll, and parse replies before drawing
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.

**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:

    scrolling=true atNewest=false followTail=true
    history: START last=20 total=28
    history: page of 80 events -> rows now 36
    countChanged count=36 followTail=true scrolling=true
    >>> scrollToItem(0)  SNAP

It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.

**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.

Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.

Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:27:26 -04:00
irisandClaude Opus 5 6119926a4d Start the process for a rename too -- the CLI keeps its own name
The last commit left renaming out on the grounds that the name is
persisted and listed whether or not a process hears about it. That was
wrong, and Iris said so: Claude Code keeps its own copy of the name,
that copy is what its session picker shows and what other agents read
when they list sessions, and a session is only ever *given* a name at
birth -- every later start is a `--resume`, which passes no `--name`.
So a rename that reached no process left the two lists disagreeing
permanently, with this app's the only one that had moved. The cost of a
resume buys the one thing renaming is for.

It stays `rename_session` rather than becoming a command like the rest,
because the name is persisted and listed as well as forwarded and that
is one operation. The save happens first and the lock is dropped before
the telling, so a failure to start reports that the telling failed
rather than the rename, which by then has already happened.

`LiveSession::run_command` went with it. It read `shared.status` and
that read is exactly what a just-started session cannot be judged by, so
every caller now goes through the manager -- which is also what the four
tests that used it were standing in for.

Verified against a stand-in CLI that echoes its stdin: a session
reporting `exited` was renamed, the process started with `--resume`, and
the CLI received `/rename after the restart` on stdin. The list shows
the new name and the session reports idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:16:29 -04:00
irisandClaude Opus 5 1a132b4de3 Start a stopped session's process for a command too
Same reasoning as the message path a commit ago, and the same objection
to leaving it out: a command is something somebody asked the session to
do, and answering "its process has exited" hands back the work of
starting one. `/compact` on a stopped session is the case that shows it
-- what is being asked for is exactly what a stopped session needs
before it is useful again.

`POST /sessions/{id}/command` and `/compact` now go through
`SessionManager::run_command`. A rename is deliberately not one of them:
it is persisted and listed whether or not a process ever hears about it,
so starting a CLI to tell it a name would be spending a resume on
nothing. It stays a forward to a process that happens to be there.

A command needs one thing a message did not. `Commands::submit` refuses
on `Exited`, and a driver that has just started a process announces
`Idle` through the sink rather than writing it -- so a command judged
against the session's own status would be refused by the word the start
had just replaced, in a window narrow enough that only a test reliably
hits it. `start_if_exited` returning `Exited` is what says a process was
started, so the status the command is judged against comes from there
rather than from a re-read the pump may not have caught up with. The
test fails without it.

`LiveSession::compact` went with this: `/compact` the route and
"/compact" the typed command were two ways to the same command, and now
there is one.

Verified over the API against a stand-in CLI: with the session reporting
`exited`, both `/clear` and `POST /compact` started the process and were
delivered -- transcript order `idle`, `commandSent`, `running`, `idle`,
with no "this session's process has exited" anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:06:59 -04:00
irisandClaude Opus 5 4a122f7b25 Start a stopped session's process when a message is sent to it
Refusing was work handed back: read the status word, find the other
button, press it, type the message again. Sending plainly means "do this
now", and `--resume` puts the new process on the same conversation, so
nothing about the message changes -- only whether there was anything
there to read it.

`POST /sessions/{id}/message` now goes through the manager, which starts
a process first when the session is known to have exited. Only on
`exited`: `unknown` has a process that may well be reading its fifo, and
starting a second CLI on that guess is the fault `session::process`
exists to prevent, so the message goes to the driver as it always did.

The Start button and this ask one function, `start_if_exited`, and want
opposite answers from it -- "there is already a process" is a refusal
worth showing to somebody who pressed Start, and nothing at all to a
message being sent. Deciding it in one place under the one write lock is
also what keeps two requests that arrive together from starting two
CLIs.

Verified over the API and on the emulator: with the session reporting
`exited` and the composer showing a play button, typing a message and
pressing Send started the process, delivered the message and ran the
turn -- transcript order `idle`, `userMessage`, `running`, `idle` -- and
the button became a stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:56:28 -04:00
iris 257f4c85c1 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 13:41:06 -04:00
irisandClaude Opus 5 50f9b956d9 Check "exited" against the process record before believing it
A session adopted at a backend start keeps the transcript's last status,
so one whose process had been reported gone and was then found again
read as `exited` while its CLI was running. `exited` is the word that
draws the phone's Start button and lets `start_session` build a driver,
so Start was accepted every time it was pressed -- and since starting
replaces the driver without retiring the old one, each press left
another reader on the same process. Every line the CLI wrote was then
translated once per reader: three presses put three interleaved copies
of one reply on screen, which is what it was reported as.

So `exited` is now checked against `session::process`, the one authority
on whether a process exists, in `launch` and again in `start_session`. A
record that is not known to be dead makes it false, and what replaces it
is `unknown` -- there is a process, and nothing here has heard from it,
which is the answer `status_of_unlaunched` already gave to the same
question. The correction goes out through the sink rather than into the
manager's view alone, or the list and the session screen would disagree
about it in the way this same button did a commit ago.

A driver that `start_session` replaces now gets `Driver::detach`, which
already existed for the backend going away and is the whole of what a
driver whose process has exited is owed.

On the phone the process button is disabled while its own request is in
flight, so a second press cannot be decided against a status the first
has not changed yet. That is a courtesy rather than the fix; the server
refuses it either way, because a phone that has lost the stream cannot
be relied on to know.

Verified against a stand-in CLI, with the state forced by hand: before,
three Starts returned 204 and left four readers on one process and the
status still `exited`; after, the session reports `unknown` on both
surfaces and all three are refused. Then driven on the emulator --
Stop, Start, Stop, Start alternated correctly with one process at a
time, and the list, the transcript and the record all agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:40:54 -04:00
iris 1ed6e29bc6 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 13:25:00 -04:00
irisandClaude Opus 5 62c22e38b7 Parse a reply's markdown once per composition, not once per delta
Scrolling was laggy, and measuring said where the time went. Instrumenting
the transcript's main-thread work on the emulator, against `/stream 200`:
markdown parsing ran fifty-eight times in three seconds -- once per streamed
delta, each one re-parsing the whole message the reply had grown into -- for
49-78ms of main-thread work per three seconds, with single parses reaching
7.3ms. That is most of a frame at 60Hz and more than a whole one at 120.
Everything else the transcript does per event was under a tenth of it.

So only the first parse stays on the composing thread. That one has to: the
renderer's asynchronous path draws an empty loading slot until its result
arrives, which measures a row at nothing before it is measured at its real
height, and the whole transcript above it collapses and springs back. Every
parse after the first is the same row growing, and there is a previous parse
to keep drawing until the new one lands -- so those go to a background
thread and no frame is ever without a height. What is on screen stays a real
prefix of the reply rather than a guess at it; it is simply one parse behind.

The same measurement found `loaded` costing an ArrayList copy per event, and
nothing reading it. It recorded every event the screen had ever seen against
the possibility that a page arriving in front of them would need the events
themselves to stitch on -- but `joinPages` heals the boundary from the folded
rows and has since it was written, so this was a list that only ever grew.

Checked on the emulator with ui-trace at 1kHz. Streaming at the newest end:
the row's bottom edge holds at y=1940 while it grows upward, and the header,
status row and composer do not move for six seconds. Scrolled back with a
reply streaming: nothing moves at all, 0 of 45 elements over five seconds.
Scrolling a mixed transcript: rows keep a constant height as they translate,
so none of them arrives blank and fills in afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:24:55 -04:00
irisandClaude Opus 5 558095520d Take the emulator half out of run-android.sh
Which AVD this checkout means, creating it, booting it headless and refusing
to start one the machine has no room for is the same sequence in ai-app,
ai-app-2 and dev-updater. It now lives once, in ~/repos/emulator-tools, and
this script is what is actually specific to this project: a build, an install
and a launch.

Three copies of "boot an emulator" was three places for the memory check none
of them had -- starting one at 2.8 GB available invoked the OOM killer, and
what it took first was another session's emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:09:36 -04:00
irisandClaude Opus 5 1d843f20f2 Let the driver say it started, so both screens agree
The session list reads the manager's status; the session screen replays the
transcript. Correcting a relaunched session's stale `exited` by writing the
manager's view directly left those two saying different things about one
session -- which showed up as a stop button that turned into a play button a
moment after the screen opened, and a status row that disagreed with the row
it was opened from.

So the correction goes through the event sink instead, from the driver that
started the process: `EchoDriver::new` and `LlamaDriver::attached` already
announce the state they start in, and `ClaudeDriver` was the one starting a
process silently. It says idle only when it started one -- adopting says
nothing, since a process that was already running may be mid-turn. Coming
from the driver also orders it against the exit `follow` reports for a
process that dies immediately, which a status written from the manager could
not be.

Verified against a stand-in CLI: stop, restart the backend, and the list and
the transcript's last status both say idle, with the relaunch recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:57:21 -04:00
iris a4ec8cfbd8 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 12:41:47 -04:00
irisandClaude Opus 5 6154cb1949 Stop and start a session's process from the composer
The composer's second button now says what pressing it would do to the
process behind the session, in one place that is always there: an orange
pause while a turn is running (interrupt, the process stays), a red stop
when it is not (end the process), a green play when it has exited (start it
again on the same conversation). Send is disabled while there is nothing to
send, rather than pressable and silent.

Behind it, two routes. `stop` signals the recorded process and says nothing
else -- the driver's own reader already reports a death correctly, and
announcing it here would be a guess ahead of the measurement. `start`
replaces the driver and nothing else, so the transcript, the pump and every
open phone's stream stay where they were and there is still one writer of
the transcript; it is refused unless the session is known to have exited,
since starting on `Unknown` is the two-CLIs-on-one-conversation fault.

That last rule found a bug in the launch path: a relaunched session took its
status from the transcript, so one whose process had died before a backend
restart reported `exited` while the launch had just started a new process --
which refuses every command and offers a phone the chance to start a second
CLI on a live conversation. A launch that leaves a process running now says
idle.

The icon font moves to the Mono face, where every glyph is one em square, so
two icon buttons are the same width without either being told one; the
proportional advances ran 0.46 to 0.92 em and Send came out visibly wider
than Stop. GLYPH_SIZE comes down to match, since a glyph that fills its em
draws bigger at the same point size.

Verified against a stand-in CLI on the emulator: idle -> stop -> exited ->
start -> idle, a turn interrupted from the pause button, and both buttons
measured at 171x105 device pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:41:36 -04:00
irisandClaude Opus 5 30ebf4e25c Keep still the end of a row nearest the tap, not the control pressed
Everything that opens now behaves alike. Touch a row's upper half and its
top edge holds, so it opens and closes downwards; touch the lower half and
the bottom edge holds, which is what the list does on its own. A group's
heading and the bar at its foot fall in the halves they already occupy, so
they keep the behaviour they had, and a single tool call -- one card, with
no bar -- gets the same choice for the first time: tapping low on an open
Bash card now shuts it downwards exactly as a group's bar does.

That makes the position of the tap the one mechanism, and RowEdge goes
away with the pair of hardcoded ends it existed to name. Controls report
where they were touched in root coordinates, which is all a control can
know -- a group is one row with a control at each end and calls in the
middle, and only the row knows where its own ends are -- and the row turns
that into an edge.

`clickableAt` is built on `clickable` rather than replacing it, so the
ripple and the click action assistive technology reads are unchanged; the
down position is observed on the initial pointer pass and nothing is
consumed.

Verified with ui-trace: on a collapsed group, a tap at y=1370 holds the
heading and one at y=1450 lets the row grow upward instead. On the same
nested call inside an open group, opening it from the group's upper half
holds the heading at 565 and from the lower half moves it to 296. ktfmt,
lint and 85 tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:29:41 -04:00
irisandClaude Opus 5 5eba6ec529 Hold a row's top edge during layout, so nothing is drawn out of place
The correction ran in a coroutine, so it landed a frame or more after the
layout it was correcting: the wrong position was drawn once and then fixed,
which reads as a flick and gets worse the faster the screen refreshes. That
is a race with the display rather than a bug that can be tuned out, so the
fix is not a shorter delay but a different phase.

It now happens in the layout phase. `Modifier.holdTopEdge` learns the row's
new height from the measurement that produced it and asks the list to shift
by exactly that much, before anything is drawn. `requestScrollToItem` is
the form that may be asked for during layout; `dispatchRawDelta` is not --
it calls forceRemeasure and dies with "performMeasureAndLayout called
during measure layout", which cost one crash to establish.

The arming flag and the per-row height are deliberately not snapshot state.
Both are written from layout, where a snapshot write that composition reads
would schedule another recomposition -- another frame, which is the thing
being removed.

This also drops the machinery the previous attempt needed: no waiting on a
size change, no timeout, no marking the rows above to find one that could
still report the move. A row measures itself, so a row that shrinks out of
the viewport is no longer a special case.

Verified with ui-trace sampling at ~1kHz, where a single bad frame would
show as ten to twenty samples: expanding and collapsing from a heading are
each one step from old position to held position with nothing in between,
collapsing from the foot bar holds the four rows below it, a drag 120ms
after a tap is left alone, and scrolling back stays put for six seconds.
ktfmt, lint and 85 tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:15:44 -04:00
irisandClaude Opus 5 cab21442d6 Open a row downwards, and hold the edge that was pressed
Reinstates the reverted downward-opening rows with the two defects that
made the first attempt worse than what it replaced.

The correction waited on the row's top edge and could wait up to half a
second for it to move. A top edge also moves when the reader scrolls, so a
correction still pending would wake on their drag, read the scroll distance
as the row's growth, and undo it -- the transcript jumping on every expand
and refusing to scroll back at all. It now waits on the row's *size*, which
nothing but a resize changes.

The second is why collapsing a group taller than the screen did nothing at
all. Such a group is the list's own anchor item, so as it shrinks it slides
down behind its anchored bottom edge and out of the viewport, and its size
reads as null -- which `withTimeoutOrNull` cannot tell from the null that
means the wait expired. The case most needing the correction was the one
silently skipped. The wait now answers a value that a timeout cannot, and
the distance is read off any row from the pressed one upwards, all of which
move by exactly the row's growth.

Verified on the emulator with ui-trace (~/.local/bin), which samples the
accessibility tree at 60Hz and reports node bounds in device pixels:
expanding and collapsing from a heading hold it to the pixel, collapsing
from the foot bar holds all four rows below it, a drag 120ms after a tap is
left alone, and scrolling back stays put for six seconds. ktfmt, lint and
85 tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 11:56:53 -04:00
irisandClaude Opus 5 ec40499ba4 Revert "Open a row downwards, from whichever end was pressed"
This reverts commit f4d4c82. The anchoring it added made the transcript
jump on every expand and collapse, and left the list snapping back to the
bottom when somebody scrolled up, which is worse than the upward-opening
it was meant to fix.

Two things to look at when this is retried. `LazyListItemInfo.offset` in a
`reverseLayout` list is not obviously the coordinate space this assumed,
so `offset + size` may have been measuring the bottom edge -- the one the
list already holds -- rather than the top. And the anchoring scroll ran in
a coroutine that could still be pending when the reader started dragging;
`scrollBy` takes the default mutation priority, so it cancels that drag.

Verify the next attempt with `uiautomator dump` -- node bounds in device
pixels, before and after a toggle -- rather than by eye from screenshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 03:03:55 -04:00
iris 7190eac6f3 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 02:52:51 -04:00
iris f4d4c82910 Open a row downwards, from whichever end was pressed
Tapping a group's heading used to send that heading up off the top of the
screen and fill the space above it, so the calls appeared on the far side
of the control that produced them. The transcript is laid out from the
bottom, so every row's bottom edge is what the list holds still and all
growth goes upward.

The rule now is that the end the reader pressed is the end that must not
move. A heading anchors the top, so the row opens downwards under it; the
bar at the foot of an open group anchors the bottom, so shutting it from
there leaves what follows the group where it is -- which is what already
happened, but by accident of the layout rather than on purpose, and would
have been lost the moment anything else changed.

Bottom is the list's own behaviour and costs nothing. Top is measured
rather than calculated: only the layout knows how tall an open group is,
so `toggleAnchored` reads where the top edge was, lets the change land,
and scrolls by however far it moved.

Applied to every row that opens, not just groups -- a lone tool call and a
peer message are the same gesture, and one of them opening the other way
would be the odder for it.
2026-08-30 02:52:45 -04:00
iris 317ad29d85 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 02:40:37 -04:00
irisandClaude Opus 5 be47beb0ff Say it over the app when the app is what somebody is looking at
The notification stream now has three places to land instead of two, decided
in one function. Nothing at all for the session on screen, as before. A
banner over the app while the app is up. Android's drawer otherwise. Never
two of them for one moment: a drawer filling up behind an app that showed
you each one is a drawer nobody reads.

The banners queue, one per session replacing that session's own -- the rule
the drawer already followed, and for the same reason. Each can be tapped,
which opens the session by the same path a tapped notification takes;
pushed off either side; or left alone, in which case the bar across its foot
retires it. The bar and the retiring are one value rather than a bar beside
a timer, so a banner cannot outlive the countdown drawn under it. They clear
when the app goes away, since a claim that a session wants somebody *now*
does not survive an absence -- and the drawer has the job back by then.

Which of the three applies needs no flag anybody keeps level. The session on
screen is registered by the one composable that draws one, and "the app is
up" is the queue being collected, which happens exactly while it is.

Also: tapping a model or permission button while its own menu is open now
closes it. A non-focusable popup does not swallow the press that dismisses
it, so the same finger was reopening what it had just closed -- measured at
3ms between the two, which is what the guard is sized against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:40:32 -04:00
iris 3ecc550c1e Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 02:18:36 -04:00
iris 8c1b6b7467 Sit the transcript on the bottom when it does not fill the screen
A session whose loaded rows were shorter than the viewport drew them
against the top of the list, leaving a gap between the newest message and
the box you type in -- and nothing to scroll, because there was no
overflow. Opening the keyboard shrank the viewport enough for the content
to overflow it and the list snapped down, which made a placement fault
look like a scrolling one.

`reverseLayout` defaults the arrangement to `Bottom` on its own, but
naming `spacedBy` replaces that default with `spacedBy`'s own, which is
`Top`. The arrangement is only consulted when the content does not fill
the viewport, which is why this sat here since 2026-08-28 without being
seen: it needs a session that loads less than a screenful, and a page of
tool calls collapsing to one "Called 80 tools" row is how a long
conversation manages that.
2026-08-30 02:18:31 -04:00
iris e0eaa4b3f8 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 02:10:49 -04:00
irisandClaude Opus 5 9d5a7cf602 Leave the session you are reading alone, and put its menus on their buttons
Three things about the session screen.

A notification is no longer posted about the session in front of you: the
transcript is already saying it, and one that was posted before you opened
it is cancelled, since a row in the drawer for the conversation on screen
is the same duplication. Bound to RESUMED rather than STARTED, so a session
left on this screen behind another app still reports.

The model and permission menus opened 142px clear of the buttons that
opened them -- the status bar's height, exactly. Compose measures the
anchor in window coordinates, which for an edge-to-edge activity is the
whole display, but asks whether the menu fits inside the visible frame,
which is that less the system bars; sitting just above a control near the
bottom then reads as an overflow and Material3 parks the menu near the
bottom of the visible frame instead. Turning clipping off makes both
questions about the same window.

The model picker now offers "default". The button has always been able to
say it -- that is what a session with no model of its own reads as -- but
the list could not, so choosing any model was a one-way trip. It is the
Claude CLI's own word for "whatever is configured", which its set_model
accepts, so it is a request rather than a name invented here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:10:39 -04:00
iris 0d623b7073 Recover a session's context from the CLI's own file
A restarted server has been told nothing, so a Claude session that has not
taken a turn since reported its context as unknown -- which was true, and
useless, since the CLI had written the figure down at the time and it was
sitting in the session's file the whole while.

It now reads it from there at load, over the session's transport, in the
background: the same three input fields the import list already reads, so
it is a measurement rather than a guess. Only when nothing else has
answered, and only for a provider that keeps such a file.

A clear needs no special case even though it makes the last usage in a
file stale, because clearing gives the CLI a new session id -- so the
lookup lands on a file with no usage yet and answers unknown, which is
what it is.
2026-08-30 02:04:24 -04:00
iris bc0a48799c Put a question to the reader on a row of its own
An AskUserQuestion arrived in the middle of a run of tool calls and was
folded into the collapsed card with them, so the one row where somebody
was asked something -- and the answer they gave -- was hidden behind
"Called 6 tools" like any other grep.

It now starts a run of its own and ends the one before it, which needs no
change to the grouping: a run of one is drawn as itself. The calls around
it become a group before and a group after, so where the work stopped to
ask is legible from the shape of the transcript without opening anything.

Echo's `/ask` now runs three ordinary calls on each side of the question,
because that is the shape this has to be looked at in and there was no
way to produce it.
2026-08-30 02:00:48 -04:00
iris 09f7f8d203 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 01:53:48 -04:00
iris 5e11b9da80 Report the context a session holds, not what it has spent
The number on the status row was a running total of tokens spent, so it
could only ever climb: a session compacted from 128k down to 10k, or
cleared outright, went on reporting the larger figure, and disagreed with
the divider directly above it saying what the compaction had recovered.

It now reports what the model is holding -- prompt plus both cache
figures -- folded through `driver::context_after`, which is the one rule
the pump, the transcript and the phone all use: a turn sets it, a
compaction replaces it with what the compaction measured, and a clear
leaves it unmeasured. Unmeasured says so in words, because an empty
context and one nobody has counted used to look identical.

Taken from the turn's last assistant message rather than its `result`:
measured against CLI 2.1.237, a two-message turn reported a cache read of
40,211, being 14,259 and 25,952 -- the same conversation counted twice,
and no size the model ever held.
2026-08-30 01:53:43 -04:00
iris 68704fce7c Ask the driver whether a command can go, not the status it reported
A `/clear` that did nothing, traced to the end. There was no race to lose:
the driver sees every line it writes and every line that comes back, so it
always knew. What it knew was being asked of the wrong thing.

Two views of "is a turn running" had grown apart. The driver's moves the
instant it writes a line; `SessionStatus` moves when output is *recorded*.
Messages ask the driver -- which is why they behave -- and commands asked
the status, which for a command is stale for its whole round trip: a
command's reply carries no assistant text, so nothing proved a turn had
started and the recorded status stayed idle from the moment it went out
until the moment it came back. A second command in that window went
straight out too, landing inside the turn the first one had started, where
the CLI reads it as text instead of running it. Nothing anywhere says so:
a command read as a message looks like a message.

So `Commands` asks `Driver::between_turns()` now, and asks again when it
releases a held one -- the recorded idle that woke it is a moment in the
past by then. `local_command` says `Running` when it writes, which is both
true and what makes the next idle a change worth recording; without it the
idle at the end of a command was equal to the idle before it, and nothing
behind it was ever released.

The other half was a turn nobody here started. The CLI picks the
conversation back up on its own -- measured: a backgrounded `sleep`
finished nine seconds after the turn's result and it began again unprompted
-- and it announces that with a `system/init` about a second and a half
before its first assistant text. We had been ignoring that line and
learning about the turn from the text, so for that second and a half the
session read as idle. It is a turn now, told apart from the `init` at
startup by the translator already having a session id, and from our own
`/clear` by `running` already being true.

Measured against the real CLI, not argued: two `/clear`s sent back to back
on one connection now record `commandSent`, `running`, `commandQueued`,
`cleared`, `idle`, `commandSent`, `cleared` -- held, then run, in order,
both of them. Before this the second was swallowed. The self-started turn
shows as `running` eleven seconds after the previous turn's idle, which is
the window a command used to disappear into.

Also measured on the way, and worth writing down: a message written into a
running turn is *folded into it* -- one `result`, `num_turns: 2`, both
things answered -- so an idle after one is honest and there was nothing to
fix there. A command written when the CLI is genuinely between turns is
executed even ten milliseconds after the result, so the boundary itself was
never the problem.
2026-08-30 01:42:47 -04:00
iris 81c8a57181 Colour the divider rules to match their words
A compaction line and a clear line each read as one mark now, rather than
a coloured phrase sitting in a grey rule that looked unrelated to it.
2026-08-30 01:34:49 -04:00
iris 451afb50d5 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 01:23:45 -04:00
irisandClaude Opus 5 ea2da0896d Open the session a notification is about, and say the two dividers plainly
Tapping a notification landed on whatever the app was last showing. It now
opens the session it named. The id rides in the intent's data rather than an
extra, because PendingIntent identity is Intent.filterEquals -- with an extra
every session's notification would share one PendingIntent and every tap would
open whichever session was notified last. MainActivity sorts the aiapp:// URI
by host, so enrollment and this are one entry point rather than two.

The notification carries only an id, so the session is fetched before there is
a screen; a fetch that fails says so and offers to try again, since somebody
deliberately tapped and an app that opens to the list explains nothing.

That made session-to-session navigation reachable for the first time, and it
crashed: SessionScreen remembers a transcript and an event stream, and without
a key Compose kept both across the change and merged two conversations into
duplicate list keys. Keyed on the session id.

The two transcript dividers now say only what they are, centred between two
rules: "Compacted <bullet> 128,402 -> 9,617 tok" in blue, and "Context cleared"
in red. The rules stay the ordinary divider colour -- they are framing, and the
words are what carries the meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 01:23:37 -04:00
iris a5659393e9 Merge remote-tracking branch 'origin/main' 2026-08-30 01:05:24 -04:00
iris f1a185a9cd Answer a command a session can never run, and put attachments under the text
Investigating a `/clear` that did nothing. What I could measure says the
basic path is sound: the CLI honours `/clear` in stream-json mode -- it
emits `conversation_reset`, opens a fresh session id, and the model then
answers "NO CONTEXT" to a question about something it was told a moment
before -- and a `/clear` sent into a running turn is queued here and applied
at the boundary, with the model losing context, in two reproductions.

What the investigation did find is a command that can wait forever. Held
commands drain at the next idle, and a session whose process is gone has no
next idle, so `/clear` sent to one sat in the queue with a waiting bubble on
the phone that nothing could resolve and nothing anywhere saying why. The
*message* path has always answered this case -- a message to the same
session reports the exit at once -- which is what made the silence visible:
one session answered one and swallowed the other. A command owes the same
answer, since what makes it unanswerable is the same fact.

`Unknown` still waits. It means nobody could find out whether the process is
there and it resolves itself, so refusing on it would turn "we don't know"
into "it's gone".

`local_command` gets the `closed` check `send_user_message` has had all
along, for the window between the status being read and the line being
written -- a line into a fifo nothing is reading goes nowhere and looks
exactly like one that arrived.

Attachments now draw under the message text rather than above it: what
somebody wrote is what the bubble is, and it keeps the first line of every
bubble in the same place down the transcript whether or not there is an
image in it.
2026-08-30 01:05:24 -04:00
iris 03a8d7d3d3 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 00:58:04 -04:00
iris ad791a0d84 Rejoin a message the page boundary cut in two
joinPages healed a tool call split across a page boundary but not a
message split across one, so a long reply came back as two rows with a
paragraph break through the middle of a sentence -- visible on any
session whose replies are longer than an eighty-event page.

Same cause, same cure, and the rule was already written down one member
of the set: `foldEvent` never leaves two assistant messages adjacent
inside a page, since deltas accumulate into the message before them, so
two meeting at a join are always halves of one reply.

The newer half keeps its identity for the reason adoptRun gives -- it is
the row already on screen. It grows by what the older half brings, which
is safe at this join and nowhere else: the join is at the oldest end of
what is loaded, so the growth extends off the top, away from the row the
list anchors to.
2026-08-30 00:57:57 -04:00
iris 8a1621a207 Load history in one go, and go back to the newest instantly
Two things that made scrolling back feel like work.

The jump-to-newest button animated. An animated scroll travels the whole
transcript, so the further back somebody has read the longer the press
takes -- the one control whose cost grows with how much there is to
skip, which is backwards. It goes straight there now.

History loaded a page per gesture, and a page is eighty *events*. Eighty
events are routinely one row: a reply arrives as hundreds of text deltas
that fold into a single message. So a page could land and leave the far
end exactly where it was -- and since the far end moving is what asks
for the next page, nothing did. The list then only loaded when somebody
dragged it again, which is what "it only loads when you touch the top"
was. It now keeps fetching until there are rows behind the reader again,
and starts doing that a cushion before the end rather than at it.

Measured on a session of five very long replies, about two thousand
events: reaching the oldest message used to stall at every drag; it now
takes flings alone, and the jump back to the newest end is one frame.
2026-08-30 00:55:55 -04:00
iris da65c1571f Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
2026-08-30 00:49:34 -04:00
iris b0629f77ca Shrink a photo to what the provider takes, and put it in its own bubble
Sending an image was broken in the way that is hardest to see from the
phone: a camera photo is twelve megapixels and several megabytes, the Claude
API resizes anything past 1568px on its long edge before looking at it and
refuses far larger outright, so the picture was uploaded whole over the
tunnel to be thrown away or rejected at the other end.

Shrunk on the phone, to a limit the server states. Which number it is comes
from the provider's *kind* -- `DriverKind::max_image_edge`, reported on the
session row -- because that is where a provider's requirements are known,
and a phone carrying its own copy of them would be a second place to update
when one changes. `None` where nothing cares, rather than a large number:
"no limit" and "a limit that happens to be big" are different answers and
only one of them stays true. Doing it before the upload rather than after is
the point -- the expensive part on a phone is the tunnel, not the decode --
and an image already inside the limit is uploaded byte for byte rather than
being round-tripped through JPEG for nothing.

EXIF orientation is applied while scaling. The camera writes which way up
the picture is into a tag rather than into the pixels, and re-encoding drops
it, so a portrait photo would have arrived at the model on its side with
nothing anywhere saying so.

**What is attached is now visible before it is sent**, in a row directly
above the box it will be sent from: the count on the "+" button said how
many and never which, so the only way to find out what you had picked was to
send it. It scrolls sideways rather than shrinking, and tapping one takes it
back off -- an image picked by mistake could otherwise only be dealt with by
sending it. The tile is outlined as well as filled, because most of what
gets attached here is a screenshot of a dark app and a cropped one is
near-black: without an edge the only thing on screen saying an image was
attached was the cross drawn on top of nothing.

**And the picture is inside the bubble that sent it.** Attachments used to
be their own `Image` events emitted just before the message, which drew
somebody's screenshot as a row floating above the bubble and left the phone
deciding from adjacency alone which message an image belonged to -- a thing
the sender knew and could simply say. `UserMessage`, `MessageQueued` and
`MessageTaken` carry the refs now, so a waiting message keeps its picture
for as long as the turn runs, and a replay puts it back in the same place.

Verified on a real claude-cli session rather than an echo one, since the
limit only exists for that kind: a 3000x4000 image arrived as 1176x1568
JPEG -- long edge exactly the limit, aspect ratio intact -- and haiku
answered "AI Sessions displays idle Photo", which is what the picture was.
No error, and the transcript records the message with `images` on it.
2026-08-30 00:47:16 -04:00
iris c60b0cbac9 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-30 00:42:41 -04:00
iris de049bcec6 Hold the transcript still while somebody is reading further back
Two separate defects, both of which moved the list under the reader.

The first: a row that grows drags the view toward the newest end. The
list is laid out from the bottom, so it anchors on the first visible
item's *bottom* edge -- and a reply streaming in extends that row
upwards, pushing everything already on screen with it. Measured against
a reply streamed in four hundred pieces: scrolling back one screen and
waiting six seconds ended at the very bottom, forty lines further on
than where it was left. So the transcript now only changes while the
reader is at the newest end; anything arriving before then waits in
order and lands when they return. Status, tokens and the model still
update live, because none of those are drawn in the list and freezing
them would trade a jumping transcript for a status row that lies.

The second: every markdown row was measured at nothing before it was
measured at its real height. The renderer's `content: String` overload
parses in a coroutine and draws an empty loading slot until it finishes,
so a row composes with no height and springs open a frame later. Seen
with five replies on screen at once, all blank, the whole conversation
shrunk to a single screen. Parsing in the composition costs a few
milliseconds on the main thread and is worth it: no scroll anchoring can
survive a row that lies about its height first.

`/stream N` in the echo driver is what made the first one reproducible
-- `/slow` emits a line a second, and the growth has to be continuous
for the anchor row to drag.

Verified on the emulator: scrolled back through a whole 400-piece
stream, the transcript region is pixel-identical across ten seconds
while the status row goes from working to idle; returning to the bottom
brings the backlog in one go. Checked the tool-call rig too, which this
change had no reason to touch -- paging back still works and every group
still reads "Called 8 tools".
2026-08-30 00:42:28 -04:00
iris 5d47a1ec89 Merge remote-tracking branch 'origin/main' 2026-08-30 00:17:54 -04:00
iris 5616ed8aba Say which emulator, now that this checkout has its own
`run-android.sh` derives its AVD from the checkout, so two clones of this
repo run two emulators, and with both attached a bare `adb` call stops
working. The failures do not say so: `adb shell` and `adb get-state` fail
with `more than one device/emulator`, and `adb shell pm list packages`
comes back empty -- which reads as the app having been uninstalled, and
sent dev-updater's session looking for a wipe that had not happened. Its
enrolment script mis-diagnosed the same ambiguity as "no emulator is
running", whose advice is to start a third.

The enrolment line in this file was itself a bare `adb shell`, so it was
the instruction that would have produced the confusion.
2026-08-30 00:17:38 -04:00
iris 49d3439ec4 Merge remote-tracking branch 'origin/main' 2026-08-30 00:14:23 -04:00
iris 50670863f4 Name a run of tool calls once, instead of after whichever call is first
A group of adjacent tool calls was identified by its first call, and the
list is keyed by that identity. But a run can gain members at *either*
end -- a new call arriving beside it, or a page of history arriving in
front of it -- so its first member is not a name, it is a description
that changes. Every time it changed, the row was a different row as far
as the list was concerned: the anchor went with it, and the transcript
stepped under whoever was reading.

Each call now carries the run it belongs to, decided once when it is
folded in and never recomputed, and the row keys on that. A lone call
that gains a neighbour becomes a group *without* changing identity,
which the old key got wrong in the other direction too -- one row was
replaced by another rather than updated.

`joinPages` hands the arriving older calls the name of the run they are
joining, rather than renaming that run after them. The obvious way round
is the wrong one: the newer half is the part already on screen, so
naming the joined run after the older half renames the row the reader is
looking at, which is the whole failure this is meant to remove.

Checked against the same twelve-`/tools 8` rig, whose page boundary falls
inside the second group: every group still reads eight, so the grouping
is unchanged -- what changed is that none of their identities move.

Toward the standing rule for this screen, which is that it may only move
when the reader is at the newest end and something new arrives.
2026-08-30 00:14:05 -04:00
iris 2f4dff1435 Stop painting code green, and give each checkout its own emulator
Code is not a literal. Green is what this palette colours a literal, so
painting a whole fenced block green said the block *was* one -- and it
disagreed with the syntax highlighter a tool call's input already gets,
where green means a string and peach means a number. Code blocks and inline
spans now take the ordinary text colour; the monospace face and the tinted
background are what say "this is code", which is the part colour was not
doing. `codeColor` goes with it, since nothing else wanted a colour for
code. Where a literal really does appear inside code, the thing that should
colour it is a highlighter reading the code, not a rule about the container.

`run-android.sh` derives its AVD name from the checkout instead of defaulting
to a machine-wide `tdep`. That default made the emulator the one thing here
that cannot be worked on in parallel: two clones of this repo meant asking
whoever had it, waiting, and handing it back, and installing onto a running
one takes the foreground from whatever they were looking at. Derived rather
than written down, so neither clone names the other's, and `AVD_NAME=` still
overrides for sharing one deliberately.

Also: two things reported as markdown defects yesterday were not defects,
and are worth recording so nobody fixes them twice. The table is not
clipped -- it scrolls horizontally, which the renderer does whenever the
columns are wider than the screen; a screenshot of one looks exactly like a
clipped table, and swiping it shows the rest. The paragraph that appeared to
break around an inline code span was an artifact of how the test text was
sent through the echo driver, not of the renderer: sent as one message it
flows correctly.
2026-08-30 00:10:36 -04:00
iris dde592ecb7 Merge remote-tracking branch 'origin/main' 2026-08-29 23:52:58 -04:00