Commit Graph
50 Commits
Author SHA1 Message Date
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 47d6b84265 Report what a session last did and what it cost, not what this page holds
Three readings that were each a part presented as the whole.

**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.

**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.

The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.

**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.

Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.
2026-08-29 23:52:58 -04:00
iris 694535badc Merge remote-tracking branch 'origin/main' 2026-08-29 23:43:26 -04:00
iris 135950c8ed Say when a session wants you, and stop calling a stop an error
Three things Bryan asked for, and one the second of them exposed.

**Notifications.** A session that asks a question or finishes a turn now
says so on the phone, per session, switchable from its settings screen.

The switch is stored on the backend rather than the phone, because it is a
fact about the session: one that runs unattended overnight should be quiet
on every device, and answering that question again on each device is how two
of them come to disagree. It is on by default -- a notification nobody
wanted is turned off in one tap, where one that never arrived is not
diagnosable at all.

Which moments count is `notification_for`, and the asymmetry in it is the
point. *Waiting on a person* is worth saying however it was reached.
*Finished* is only worth saying when this server watched the work happen:
sessions settle into idle for several reasons that are not "your work
ended", including every one of them being adopted at startup, and announcing
those would put "finished" on the phone for the whole config on every
backend restart. That is the failure that makes somebody switch the feature
off, so it has a test naming every transition rather than the two that
work.

The stream is `GET /notifications`, live only and with no cursor -- the one
place this server does not offer to catch a client up. A notification is a
claim about now; replaying "your turn" from an hour ago sends somebody to a
session that may have been answered from another device since, and a
notification that is wrong costs the trip *and* the credibility of the next
one. What was missed is still on the session list, which says what is
waiting without claiming to be news.

On the phone it is a foreground service, because Android has had no
long-lived background service since 8.0 -- it is what Syncthing does, and
Discord is not a counter-example since it takes a push from Google, which
would mean this backend talking to Google about somebody's sessions. The
ongoing notification Android charges for it sits on an `IMPORTANCE_MIN`
channel: no sound, no status-bar icon, bottom of the shade. `specialUse`
rather than `dataSync`, which is what it looks like: Android 15 caps
dataSync at six hours a day, and a connection that stops listening after six
hours misses the overnight run it exists for.

**A stop is not an error.** The CLI reports an interrupted turn exactly as
it reports a broken one -- `is_error` on a `result` -- so pressing Stop
showed "the turn ended with an error" for doing what the button says. The
line cannot distinguish them; what does is that this side asked, so the
driver says so before the request goes out and the translator spends that on
the next result. The test's second half is the one that matters: the naive
fix passes the first half and silences every genuine failure after it.

**Every status says which one it is.** The session screen's status row named
only `exited` and left the rest blank, so idle and "nobody could read it"
looked identical -- and a just-stopped turn showed nothing, which reads as
the app having lost the session rather than as the stop having worked. The
words are the session list's own, so a state is not called two things
depending which screen you are on. Red on a quota bar now starts at 90%.

**`GET /sessions/{id}`**, which the notification switch found missing. A
screen opened from a list row carries the row the list last fetched: fine
for a title, wrong for a switch, which is *set to* something. Caught on the
emulator, where the switch read on against a backend that said off, with
nothing on screen to say which was true. The screen now reads the session
when it opens, and until that answers the switch is disabled and says so --
a two-position control cannot say "I do not know", so it does not pretend
to.

Verified on the emulator with the app backgrounded: the service holds the
stream (`isForeground=true types=0x40000000`), a finished turn posts
"Finished" and a question replaces it with "Waiting for you" on the same
tag, turning the switch off silences it with no restart, and turning it back
on from the phone reaches config.ron. The interrupt is a translator test
rather than a live turn, which is where that logic is anyway.
2026-08-29 23:43:19 -04:00
iris 2dc61c5780 Stop drawing one tool call twice where a page of history begins
A page boundary lands wherever it lands, and about half the time that is
between a tool call and its result. The newer page then holds a `ToolEnd`
whose start it never saw, which the fold draws as a row of its own --
correctly, since a call rendering as nothing is indistinguishable from
one that never happened. But when the older page arrived it brought the
real `ToolStart`, and the two lists were concatenated, so the call was
left on screen twice: once as a proper card and once as a nameless
placeholder.

`joinPages` merges the two halves by the call's own id instead, which is
the one thing a page boundary cannot destroy. The older half wins on what
a start knows -- the tool's name, its input -- and the newer on what an
end knows, its output and whether it finished.

The miscount was the visible part; the moving was the point. The extra
row sits exactly at the join, which is where the reader is looking when
the page loads, so everything below it stepped down by a row at the
moment they scrolled into it.

Demonstrated both ways round on a rig of twelve `/tools 8` runs, whose
groups are eight calls each and whose page boundary falls inside the
second one: without this the transcript reads "Called 9 tools" there and
eight everywhere else, with it every group reads eight.

That rig is `/mixed N` in the echo driver, added here: N beats of
paragraphs at three lengths, single tool calls, runs of adjacent ones,
images and peer messages -- every row shape the app draws, in one
session, from a command that costs nothing and produces the same
transcript every time. The paragraphs are deliberately ragged, because a
wall of identical lines looks the same at every offset and makes a scroll
of one line indistinguishable from a scroll of ten, by eye or by
comparing frames.
2026-08-29 23:34:11 -04:00
iris a49120b0c8 Merge remote-tracking branch 'origin/main' 2026-08-29 22:45:22 -04:00
iris 69ef6f068a Colour the composer by what its buttons do, and let the read-out breathe less
Queue is the paper plane with a clock on it (`md-send_clock`) rather than
the plain plane plus the word: the pair is now told apart by the mark, which
is what an icon is for, and the word survives as the button's accessible
name where a screen reader still needs it.

The three composer buttons take their colour from what pressing one does --
green sends now, blue sends later, red takes the running turn away -- and
Stop becomes a filled button like the other two. Outlined said it was a
qualifier on the primary action; it is a second thing you can do about the
turn, and what separates them is the colour and the mark. The fills are
named in Theme.kt with their content colour stated beside them, because a
semantic colour has to carry its own contrast: these do not change with the
surface, so nothing will rescue a foreground that stops being readable.
Worth knowing when reading that file: the action greens and reds sit next to
a `runningColor` green and a `failedColor` red, which are *states*. Nothing
in one set is pressable and nothing in the other is a state, so a reader
never has to tell them apart.

The usage read-out loses its per-machine cards. A card is a step up the
surface ladder and inside a dialog -- already a raised surface -- the step
barely rendered while costing 16dp on every side. The machine and the
service it answered for are one small quiet line instead of a heading over a
subtitle, since the numbers underneath are what somebody opened this to see.

The gaps between the bars now go *between* them rather than after each,
which is what put a band of empty dialog above Close. The rest of that band
was AlertDialog's own spacing, fixed at sizes meant for a sentence of prose
and a decision, so this is a plain Dialog with the same container colour and
corner and spacing chosen for a dense read-out.

Looked at on the emulator: green send, then blue queue beside red stop
during a `/slow 20` echo turn, and the dialog over the live session. The
account had risen to 78% by then, which showed the five-hour bar and the
header glyph going yellow on real numbers rather than forced ones.
2026-08-29 22:45:18 -04:00
iris bb191eec21 Merge remote-tracking branch 'origin/main' 2026-08-29 22:29:18 -04:00
iris e37e90a579 Let the server say what is waiting, instead of the phone remembering
A message sent into a running turn was drawn as a pending bubble from
screen state, so leaving the session or restarting the app showed nothing
waiting while the queue was full. Nothing waiting is what "there is
nothing" looks like -- the reader had no way to tell it from a queue that
had already drained, and Bryan hit exactly that: a message he sent
arrived, and his phone stopped showing it after a restart.

The server now records the waiting. `MessageQueued { id, text }` goes into
the transcript when a driver takes a message it cannot deliver yet, and
is resolved by the `UserMessage` carrying the same id -- the same shape
`CommandQueued` and `CommandSent` already had, so this is one more
instance of a mechanism rather than a second one beside it.

The message itself still lands where the session read it, which is what
the last change was about; only the *waiting* is recorded early. The two
are different facts and now have different events.

Paired by id rather than by text. The old code removed the bubble whose
text matched, so sending the same thing twice cleared the wrong one and
left a message on screen that had already been read.

Both drivers that can queue do it: the echo driver too, because the phone
now draws pending bubbles from the stream and a rig that skipped the
event would exercise a state the real app never sees.

Checked on the emulator: two messages sent into a `/slow` turn, then the
app force-stopped and relaunched -- both still drawn as waiting, in the
pending style, and both resolved into ordinary bubbles when the turn
ended and the session read them.

Still outstanding, and worth knowing: an entry outlives a *server*
restart in the transcript but not in the driver's memory, so a backend
restarted mid-queue would leave the bubble drawn with nothing coming to
resolve it. Before this change that message vanished from the transcript
entirely, so the failure is now visible rather than silent -- but it is
not yet right.
2026-08-29 22:29:12 -04:00
iris ff39ef5cf9 Say it in icons, and put the whole backend behind four tabs
Six things Bryan asked for, which turned out to be one change: the app had
no icon set, so every one of them was blocked on having somewhere for icons
to come from.

That somewhere is dev-updater's arrangement, ported: a Nerd Fonts subset
committed as an asset, drawn as text. `Gear.kt`'s hand-drawn canvas gear
argued against icon fonts because a system font may not have the glyph and
whoever gets the empty box is never the person who wrote it. The objection
is right about *relying* on a system font and the answer is to ship the
glyph, so the file is gone and its reasoning is restated in `NerdIcons.kt`
rather than deleted -- otherwise the next reader re-derives it. `md-cog` and
`md-refresh` are dev-updater's own codepoints, because a cog means the same
thing in both apps.

The root screen's four words under the title are now four tabs, and the two
that act on the whole screen -- settings and refresh -- moved up onto the
title row as glyphs. That row's old comment recorded that a fifth word would
have had nowhere to go; tabs also say something the words did not, which is
that sessions, import, models and setups are four views of one backend
rather than four errands. Refresh feeds whichever tab is showing. Import,
models and setups lose their headings and their Back buttons, since the tab
row is now both.

Usage is a dialog. It is checked *against* what you were reading -- "can I
start this" is asked with the transcript still on screen -- and it had no
navigation of its own, so the only thing its Back could mean was "put this
away". The button that opens it is a chart glyph coloured by the worst of
the machine's windows, so the row says whether the limits are worth opening
before anybody opens them.

One `quotaColor` now colours every bar that measures a quota: blue, yellow
at 75%, red at 95%. The session bar escalates where it used to sit blue at
every level, and the dialog's thresholds moved out of it. A download keeps
plain blue at every value -- it has no limit to approach, and colouring it
like one would say the opposite of what is happening. States that are not
measurements take the ordinary control colour, since blue is the low end of
this scale and would read as "checked, and fine" about a machine nobody
could reach.

Send and stop are the filled paper plane and the filled square. Send keeps
the word "Queue" while a turn is in flight, because that is what pressing it
then does, and an icon that does two things while looking identical would
promise something immediate and do something that waits.

Looked at on the emulator: all six glyphs render, the tabs and the system
back gesture between them, the dialog over a live session, and the bar
bands at 82% and 97% forced through a scratch build, since this account is
at 72/31/5 and would only ever have shown blue.
2026-08-29 22:27:54 -04:00
iris ba71c798f5 Keep what was typed, and ask before a switch that re-reads everything
Two things about the box at the bottom of a session.

**A half-typed message survived nothing.** It lived in `remember`, so
leaving the screen threw it away, and so did the system reclaiming the
app. `Drafts.kt` keeps it per session id and the box is seeded from it.
On the device rather than the backend, which is where this app otherwise
puts state so every device sees it: this is the contents of a text box on
the phone somebody is holding, written on every keystroke, and half a
sentence surfacing on another device would be a surprise rather than a
convenience. What has been *sent* is the server's, and that is the part
which has to outlive this phone.

**Switching model quietly re-reads the whole conversation.** The picker
did it on the tap, and the cost only showed up as the next turn being
expensive. It now asks first, in words, with no number: what it will cost
depends on how long this conversation is, and the screen does not know
that -- the running total beside it counts what has been spent, which is
a different quantity, and a figure derived from it would be a guess
wearing a measurement's clothes.

The picker beside it deliberately gets no dialog, and that is measured
rather than assumed. Driving one session through both changes and reading
the CLI's own usage: a warm turn read 30,771 tokens from cache and
created 87; after a *permission mode* change it read 30,858 and created
75 -- still a hit; after a *model* change it read nothing at all and
created 41,509. So the model picker is the whole of the set, and warning
on both would teach that these dialogs can be clicked through, which is
what stops the one that matters from working.

Nothing is asked when there is nothing to lose either: choosing the model
already set, or switching before the session has said anything, applies
straight through.

Checked on the emulator. A draft survived leaving the session and a
force-stop; the dialog names both models and both buttons; declining left
the model where it was; and the permission picker still applies on the
tap with no dialog in the way.
2026-08-29 22:16:02 -04:00
iris 6b4911c67b Give the session's own state a line, instead of the transcript's corner
The token total floated over the bottom-right of the transcript, where a
long message ran underneath it, and the working indicator was an item
inside the list -- so it scrolled away exactly when somebody reading back
wanted to know whether anything was still happening.

Both are facts about the session rather than turns in it, so they get one
row directly above the box you type into: the thing they report on is the
next thing you touch. `exited` moves with them, since it is the same kind
of fact and nothing else on the screen would have said it once the
indicator left the list.

The row is drawn whether or not it has anything to say. An empty one
costs a line; a row that came and went would move the text box under a
reader's thumb every time a turn started, and would make its own presence
the signal for a state it never names. For the same reason the compaction
case had to fit the same single line: its bar now takes the row's free
width between the label and the total rather than a row of its own, which
keeps it far wider than a spinner -- the reason it is a bar at all, since
nothing arrives in the transcript while a compaction runs and a small
moving thing there reads as a session that has hung.

Still no fraction to fill, re-measured today rather than assumed: a real
80,346-to-2,088-token compaction took 23 seconds and the CLI emitted not
one line between saying it had started and saying it had finished.
Elapsed seconds remain the only honest number.

Looked at on the emulator in all three states -- idle, working, and six
seconds into a real compaction -- and at 320dp, the narrowest width a
phone actually has, where the row still holds one line.
2026-08-29 21:35:30 -04:00
irisandClaude Opus 5 6680fbb987 Clear from the phone, and put the numbers where they are read
Four changes to the session screen, three of them Bryan's and one that
fell out of them.

**/clear is offered like any other session command.** It joins
SESSION_COMMANDS, so it suggests itself while being typed and goes out
through the command endpoint that /compact already uses -- no new path,
and the boundary pump holds it mid-turn exactly as it holds a compaction.

**A clear draws a divider, not a deletion.** `Event::Cleared` becomes a
ClearedNote row saying that everything above stays here and is no longer
sent. That sentence is the row's whole job: the reader can see the
conversation is still on screen, so without it the divider reads as
something having been thrown away, which is the one thing it is not. It
carries no counts, because nothing was measured -- a compaction's
numbers are real and there is no equivalent here to report.

Compaction and clear now share `TranscriptDivider`. They are the same
kind of mark to somebody scrolling back -- "the session no longer has
what is above this" -- and the difference belongs in the words rather
than in how they are drawn, so the styling is written once and cannot
drift.

**The five-hour usage bar sits under the session header.** It reports
the paid service's own metering for the machine this session runs on,
fetched from that machine, refreshed every minute off the backend's
cache. It is never derived from the transcript's token counts: those are
a different quantity measured differently, and a quota-shaped bar built
out of them would be a guess wearing a measurement's clothes. Not
knowing has its own appearance and its own words -- "unknown" and why --
because a bar resting at zero because a machine is unreachable reads as
plenty of headroom, which is the opposite of the truth. The window is
selected by the API's own `kind` ("session"), added to UsageWindow in
this change, rather than by matching the label a person reads.

**The token total moved from the header to the bottom right of the
transcript**, pinned above the input rather than scrolling with it. In
the header it was one item in a run of dot-separated facts about the
session and read as another of them, rather than as the running total it
is.

SessionSummary now carries the setup id, which it deliberately did not.
The stated reason was that nothing here addressed a setup and holding
both id and name invited showing the wrong one; the usage bar addresses
one, so the reason lapsed rather than being overruled, and the comment
now carries the rule that replaces it: never display it. The server has
always sent the field, so nothing changed on the wire.

ktfmt, compileDebugKotlin and lintDebug all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
2026-08-29 20:11:26 -04:00
iris 749b2db287 Make a command a thing the app knows, and hold it until it can run
Typing "/" now suggests what this app understands -- `/compact` and
`/rename <name>` -- with a line each about what they do, and anything
else beginning with a slash is passed to whatever runs the session,
because a dialect's own vocabulary grows without this list.

None of them are messages, and that is the substance of the change. A
line written into a running turn is read by the *model*, so a command
sent mid-turn either does nothing or arrives as text somebody has to
puzzle over. They now wait for the turn to end. The waiting is done once
for every provider, in the pump that already watches every event for the
boundary, rather than in each driver where a new provider could get it
wrong by leaving it out.

Waiting is a state, so it is on screen: the command sits at the reader's
end of the conversation in blue, with a spinner and "waiting for this
turn to end", and becomes an ordinary blue row when it goes. Blue
because these are about the session rather than about the task -- the
same blue a compaction already used, which is now one colour with one
name rather than two.

Renaming from the settings screen sends exactly this, so it waits and
draws the same way. The name itself is not held: it is this server's own
datum, so the list and the header change at once and only telling the
session waits.

Echo grew the same split, which is where the bug in it showed: its
commands are its messages, so running one announced a `MessageTaken` as
well, and the same line drew twice -- once blue, once purple. A command
owes no announcement; the manager has already recorded that it was sent.

Watched rather than reasoned about: `/compact` during a 25 second turn
held with its bubble up, went out when the turn ended, and the
compaction that followed reported what it recovered.
2026-08-29 17:10:21 -04:00
iris bebaae7a94 Carry a question in the event model, not in one provider's JSON
A question is now fully described by the event that reports it: the tag
it was asked under, each option's label, what it means, and the sample of
what picking it would produce, plus whether several may be picked at
once. The app renders from that alone.

It had been reading Claude Code's tool input to find the parts the event
dropped -- that dialect's schema, written out a second time in Kotlin,
where no other provider could reach it and where it would drift the
first time the schema moved. Echo could not describe an option at all,
and llama never will.

Answers travel as a list for the same reason. A question that takes one
answer sends a list of one rather than being a different shape, and the
one place that flattens it is where the CLI is spoken to: its answers
map holds a string, so several choices are joined there. That join was
in the phone.

Also here because it is the same rule: the permission ask reuses the
question body rather than owning a second one, so Allow/Deny renders and
resolves through exactly the code an AskUserQuestion does.

Verified against both, since a refactor that only satisfies the case it
was written for has been tried on the half that cannot fail: a two
question `/ask` answered from the phone, one option and then two, and a
real sonnet session's `rm -f` permission asked, allowed, and run.
2026-08-29 16:46:43 -04:00
iris fea8e7e92b Show every option a question offers, on the call that asked
Reported by Iris through the dev-updater session: a two-question
AskUserQuestion arrived with only one option visible per question, so
the answer she sent was the only one she had been offered.

The cause was a `Row`. It hands out intrinsic widths in order and clips
whatever runs past the edge, so the first option or two drew and the
rest went off the side of the screen -- which does not read as a bug, it
reads as those having been the only choices. The same Row was in the
permission ask beside it; both wrap now. That pairing is the reason to
look: a rule stated on one member of a set is usually missing from the
others.

The rest of what she asked for, and what each was:

- It drew twice, as the tool call and again as loose question cards,
  because the backend marked these questions as belonging to no call.
  They belong to the call that asked, and now say so.
- So it renders like any other tool: one card, its own heading, opened
  because a decision cannot be made from a closed row.
- Each option shows its description and its `preview` block, which is
  the part a reader is deciding on and none of which was reaching them.
- "Other" is a field on every question. The harness always offers it, so
  leaving it out narrowed a question that was never that narrow.
- A multi-select sends the labels it collected as one string, which is
  the tool's own schema rather than a guess -- its answers map is
  string-valued.
- No spinner while it waits. A spinner says the machine is working; here
  the machine is idle and the turn is stopped on the person, so the card
  says "your turn" in the colour this app already uses for that.

Verified against a real session as well as the echo fixture: haiku asked
two questions with three described options each, both were answered from
the phone, and the model carried on with the answers. Echo grew `/ask`
so the shape can be looked at without paying a model to produce one, and
its option cards are outlined rather than tinted -- as one surface step
up they were three paragraphs where three things to press should be.
2026-08-29 16:22:15 -04:00
iris d3fff3d229 Let a session be renamed, under the same name everywhere
A gear at the end of the session's own bar opens what can be changed
about that session; the name is the first thing there. Compact is gone
from that bar -- `/compact` typed into the message box is the CLI's own
way to ask and it already worked, so the button was a second way to say
one thing. Echo takes the typed word too now, since it is the rig the
compaction display is checked against and losing the button would have
taken that with it.

The name is this server's, not a driver's: it is what the list shows, it
exists before any process does, and every provider has one. So it is
settled in the config and the driver is *told* -- which is the opposite
of the model and the permission mode, and the difference is written down
at `Driver::set_title`. A driver whose process has no notion of a name
does nothing and says nothing, because there is no failure to report.

Claude Code has one, so the name reaches it: `--name` for a session we
create, and `/rename` afterwards, which is a local command rather than a
control request -- `set_session_name` is not a subtype it knows, which I
established by asking it. A resumed session is deliberately not renamed
at launch: an import already has a name, quite possibly one the person
typing in it chose, and taking that would be helping itself to something
the app was only shown.

Verified end to end rather than argued: renaming from the phone put
"Session renamed to: paging and scroll" in the CLI's own session file,
and the session now lists under that name to other agents.

The gear is drawn rather than set in a font, for the reason Chevron
gives. It was a sun on the first attempt -- thin teeth standing clear of
a thin hub -- which no amount of reading the diff would have shown.
2026-08-29 16:06:15 -04:00
iris 3eccf7e443 Show the model and mode the session has, not the ones it was asked for
Picking either from the phone wrote the choice straight into the
session's state and then sent the request. Asking and having are
different things, and the difference is not rare: `auto` is a permission
mode the CLI accepts on the command line, silently resolves to
`default`, and refuses outright over the control channel -- "auto mode
unavailable for this model" -- so a session spawned in auto was in
default and one switched to auto stayed where it was, with the phone
reporting auto in both cases.

So the drivers report what they are set to and the manager follows that.
Measured, because the confirmations are not uniform: a model change
answers success with no value, so what was asked is remembered until the
answer arrives; a mode change echoes the mode it became, and that answer
wins over the request; and `init` names both -- resolving `haiku` to
claude-haiku-4-5-20251001 -- which also covers a session adopted from a
terminal that set them outside this app. A driver that cannot change
either already says so with an error, and now that error is the whole
story rather than a note beside a display that changed anyway.

The config keeps the requested value, deliberately: that answers a
different question, which is what to launch this session with next time.

Two things fall out. Control request ids are random rather than the
clock, because two in the same second shared an id and something now
looks them up. And the phone shortens a resolved name for the button --
`haiku-4-5` -- since the full one is what the CLI reports and roughly
twice the room that row has once Stop is in it.
2026-08-29 15:36:53 -04:00
iris 404066fa7d Say when a session is working, and what it was told
Three things a phone could not see, all of them the same shape: the
session was doing something and nothing on screen said so.

A turn nobody here started never reported itself. `Running` was sent
where a message was *sent*, so a session picked up mid-turn, one
compacting on its own, or one another agent wrote to sat there reading
as idle until it finished. The driver now says it from what it observes
-- output that could only come from a turn in flight -- which is the
same set of events that already announced a steer, with the ends
swapped.

An imported session had it worse: nothing but replayed lines ever
reaches it, and a status was not among them, so it was permanently
whatever it was when it was adopted. Its file does not record a turn
ending, but it does record why each assistant message stopped, and
`tool_use` versus anything else answers it. A record that says nothing
leaves the status alone rather than voting for idle.

Messages from other agents were dropped outright: the CLI marks them
meta, and this replayed everything except meta. They are now a row of
their own, closed by default like a tool call, named for the session
that sent it -- not the reader's own bubble, because they did not say
it, and a session working on something this phone never asked for is
exactly what one of these explains.

Measured against a real session file rather than guessed: the peer
record carries the sender's name and the message body in `origin`,
beside a copy wrapped for the model to read.
2026-08-29 15:27:46 -04:00
iris f18639e4b1 Count the far end of the list in rows, not events
Scrolling back stopped dead at the top of what was loaded, and no older
page ever arrived. Bryan spotted the cause from the outside: it had to do
with tool calls being collapsed.

The trigger compared an index into the list being drawn against
`items.size`, the number of transcript events. Those were the same number
when it was written. They stopped being the same when adjacent tool calls
started folding into one row, and the queued bubble and the working
indicator are two more rows with no event behind them. In this session
645 rows stood in for 720 events, so the last visible index could reach
646 and the threshold it needed was 717. It was not close; it was
unreachable, and the further a session went the worse it got.

Both numbers now come from the list itself, which is the only place they
are commensurable, and `totalItemsCount` counts whatever gets added to it
next.

Checked on the emulator against the case it was breaking on rather than a
clean one: five collapsed "Called 8 tools" groups in front of a 720-event
transcript, scrolled from the bottom to seq 1, which is the beginning of
the session. It stops there because that is the top, and holds position
while each page arrives.
2026-08-29 14:55:19 -04:00
iris 279c76e8a1 Hold the reader's place when a message arrives
Scrolled back through a conversation, every new message dragged the view
with it -- which reads as the screen scrolling down on its own, at the
exact moment somebody is trying to read something else.

The list had no keys, so its rows were identified by position. The
transcript is drawn newest-first, so a new message is an insertion at
index 0: every existing row shifts up one index, the viewport stays on
the index it was on, and the content slides through it. The working
indicator appearing and disappearing did the same thing at the same end.

So rows now carry the identity they always had in the data. Every
transcript event has a seq, which is what the transcript is ordered by
and never changes, and every row keeps the seq of the first event behind
it -- a streaming message keeps the seq of its first delta, so it holds
still for the whole answer rather than becoming a new row on every
frame, and a tool call keeps its start's.

Paging older history is the same insertion from the other end, and it is
the thing this could plausibly have broken. Checked on the emulator:
scrolled back mid-turn, the view sat still through twenty seconds of
streamed deltas, and scrolling to the far end still fetched earlier
pages and stayed where it was while they arrived.
2026-08-29 14:47:31 -04:00
iris 5396da76c7 Show a compaction happening, and what it recovered
The Compacting status had been declared, rendered in four places, and
never once emitted: no driver produced it, and the app had no control to
ask for a compaction in the first place. Pressing nothing for two
minutes and then quietly having less context was the whole experience.

The CLI turns out to announce all of it, which was worth measuring
rather than guessing at. Driven through /compact against 2.1.237 it
emits a `status: "compacting"` line at the start, a `status: null`
carrying `compact_result` at the end -- `"failed"` with a sentence
saying why, when it does -- and then a `compact_boundary` with the token
counts. The same records appear in the CLI's own transcript file with
camelCase keys, which is the obvious place to read the shape off and
gets every field name wrong.

So none of it is inferred here. The driver writes the line and says
nothing; the translator reports what the CLI reports. A failed
compaction surfaces the CLI's own sentence, which is specific enough to
act on.

The counts are the part worth keeping afterwards, so they land in the
transcript rather than only in a status that vanishes: a session that
went from 128,402 tokens to 9,617 has just been given its context back.
They are optional throughout, because a compaction whose size nobody
reported has to be able to say so -- a zero would read as "recovered
nothing".

Also here, all found on the way:

- `rename_all` renames variants; fields need `rename_all_fields`. Every
  field in Event was a single word until `pre_tokens`, which went out as
  snake_case, was not found by the app, and rendered as the "no counts
  reported" case -- a state it is allowed to be in, so nothing looked
  wrong. There is now a test on the wire names.
- The unparseable-line warning sliced bytes, not chars, on output that
  is full of em dashes. A panic there kills the task reading the
  session's stdout, and the session goes deaf with nothing on screen.
  The other three truncations in the tree already did this correctly.
- Echo compacts too, with invented numbers and a real shape, so this
  screen can be looked at without spending two minutes of somebody's
  account to reach the state.
2026-08-29 14:42:08 -04:00
irisandClaude Opus 5 42131c75d6 Send a steer into the running turn, and put an image under its call
**The queue was holding messages the CLI would have taken.** Two claims
in this file contradicted each other: the module header said a mid-turn
message is injected at the next tool boundary -- "the behavior this app
exists for" -- and `Queue`'s own doc said a line written mid-turn simply
becomes the next turn. The code followed the second, parking every
message until `Status::Idle`, which is the end of the whole turn.

Measured rather than argued, twice. Writing a line straight into a live
session's stdin fifo mid-turn produced one `result` for the whole thing,
so it was consumed inside that turn, not as a new one. The header was
right and the queue was built on the wrong claim.

The cost was exactly what Bryan reported: he steered after the second
tool call and it sat unread until every remaining call had finished.
Measured before and after on the same three-step turn -- steer sent at
+13s, recorded at +24.7s before this change and at +14.1s after, which
is the next tool boundary.

So the line goes out immediately. What stays behind is the
*announcement*: the CLI says nothing on stdout about having read a
message, so `MessageTaken` now waits for the next assistant text or tool
call, which is proof another model call happened and the steer was in
it. That keeps a held message drawn below the working indicator until
the session has actually taken it -- the thing that mattered when this
was last changed -- without delaying the message to get it. Idle counts
too, and is the case that must not be missed: a message written after a
turn's last model call has no later output to prove anything.

`closed` is untouched, and `Queue::close` still reports held messages by
name rather than dropping them.

**An image now names the call that produced it.** `Event::Image` gains
`about`, the `tool_use_id` from the tool result it came out of, so a
screenshot is drawn inside that call's card instead of floating beside
it -- pairing them by position is what a page boundary breaks. `None`
for a person's own attachment, which belongs to no call. The import path
threads it through as well, so replayed history reads the same as live.
Images show whether the card is open or closed: a call whose result *is*
a picture says less closed than the one line it replaced.

Verified on the emulator against a real haiku turn: the checkerboard sits
inside `Read /tmp/tiny.png`, and the steer sits between that call and the
next, where it was taken. 53 tests, clippy, rustfmt, lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:17:22 -04:00
irisandClaude Opus 5 4cbd567c35 Take ktfmt's formatting
Committed unformatted again: I piped the check through grep, so the
task's failure never reached the shell's exit status and the chained
commit ran anyway. Check exit codes, not output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:46:34 -04:00
irisandClaude Opus 5 73923dbbc3 Make jump-to-latest the same chevron, pointing down
It was a labelled button beside a tool group that collapses with a
drawn chevron -- two controls doing the same kind of thing in two
visual languages. Now one `Chevron` composable serves both directions,
parameterised rather than copied, since a pair that differs by a minus
sign drifts and the drift is a bug in exactly one direction.

The comment it replaces argued against an arrow here, on the grounds
that the list is laid out upside down. That reasoning was about the
code: nobody reading the screen knows the list is reversed, and on
screen the newest message is at the bottom, which is where this goes.

It draws no text, so the name lives in its content description -- the
whole of what a screen reader has, and the answer to "what was that
arrow for" later.

Verified on the emulator: scrolled up, the chevron appears bottom
centre matching the group's; tapped, it returns to the newest message
and takes itself away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:46:13 -04:00
irisandClaude Opus 5 011ed0d0e1 Hold an image's place, open it full screen, and fold a run of calls
Five changes to how a transcript reads.

**Images no longer move the page.** The row was as tall as whatever had
loaded, so it grew when the bytes arrived and pushed everything below it
-- and in a bottom-anchored list, an image loading above the viewport
moved the text under the reader's eyes. The height is now decided before
the fetch and never changes: four lines of the body style, measured from
the type so it stays four lines when the reader has scaled their fonts.
Nothing to see when loading finishes, which is the point.

**A small image is enlarged with nearest neighbour**, a large one shrunk
smoothly -- decided per image from its actual size rather than set once,
since blowing a 16px sprite up with interpolation turns it into a blur
of exactly the thing being looked at.

**Tapping one opens it full screen**, fitted so the whole image is
visible first, with two-finger zoom to 8x and pan once zoomed. A dialog
rather than a screen, so back returns to the transcript.

**A tool call is one line closed**: the tool's name and what the call is
for. The command is not on it, because a wrapped command turns one row
into four. Open, it shows the command, the rest of the input and the
output, with the timeout at the top right -- a limit on the call rather
than part of what it does, worth seeing beside the command it constrains.
A call waiting on permission is shown open regardless, since the command
is the thing being decided.

**Adjacent calls fold into "Called n tools"**, closed by default, and it
closes again from either end -- a long group's heading scrolls away while
its last call is still on screen, and the reader who wants it shut is
looking at the bottom. The calls keep their full width; what says they
belong together is the surface behind them, one cue rather than two
half-cues. Grouping happens at display time, not in the fold: the
transcript's own order is what paging and the stream depend on.

Echo gains `/tools [n]` so a run of calls can be produced without paying
for one.

Verified on the emulator: four calls folded and expanded, one opened
inside the group showing `timeout 5000` top right, a 16px checkerboard
enlarged with hard pixel edges beside a shrunk screenshot at the same
height, the screen byte-identical between one second and six after
opening, full screen fitted, and back returning to the same scroll
position. Pinch itself is the one thing not verified here -- `adb input`
cannot inject a two-finger gesture.

53 tests, clippy, rustfmt, Android lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:43:46 -04:00
irisandClaude Opus 5 8fe13634cb Take ktfmt's formatting on the files just added
Four files went in unformatted: I ran the formatter mid-change and then
kept editing. ktfmtCheck is part of finishing, not part of starting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:28:24 -04:00
irisandClaude Opus 5 d580461bd5 Show what was remembered as a note, not as markup
Claude Code marks a sentence taken from its stored memory by wrapping it
in `<cc-memory filenames="...">`. Markdown has nothing to say about that,
so it arrived as literal angle brackets mid-sentence and read as the
model having emitted broken HTML. It is the opposite: a claim about
where something came from, and "I was told this before" and "I worked
this out just now" are different things the reader could not otherwise
tell apart.

Each one becomes a card naming the files it came from, with the prose
either side of it left as prose. Named rather than merely tinted, since
a colour can say "this one is different" but not what kind of different.

A tag still arriving is left alone: streaming means the closing half may
be seconds away, and a half-written marker is not a marker yet.

Verified on the emulator with two notes in one message, one of them
citing two files, and prose before, between and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:27:00 -04:00
irisandClaude Opus 5 c5dbd1535d Ask for permission on the call it is about, and read the input
A bash permission request arrived as a second card repeating the tool
call's input verbatim, so the same command appeared twice and the reader
had to work out it was one event. `Event::Question` now carries `about`:
the `tool_use_id` the CLI's `can_use_tool` request already names. That
makes the pairing a measured fact rather than a match on input text --
and it stays `Option`, because AskUserQuestion is not permission for
anything and an echo session's question is about no tool at all. Those
still draw as their own card, which is what every question did before.

The card also reads the input instead of dumping it. Every tool's input
is JSON, and showing it raw makes the reader parse `{"command":"…",
"timeout":5000}` to find the line they care about. A small table says
which field is the subject of which tool -- Bash's `command`, Read's
`file_path` -- and the rest is still listed, since dropping a field
would claim the tool had no other input when it might. The subject is
syntax-highlighted with dev.snipme:highlights, for the reason the
markdown renderer is a library: lexical rules are somebody else's
specification. Its theme is Catppuccin, mapped in Theme.kt beside the
rest of the palette rather than taken from the library's defaults.

The input shows whether or not the card is expanded. A row that says
only "Bash" says nothing anyone can act on, least of all when it is
asking to run something.

Verified on the emulator against a real haiku session: one card, the
description, `grep -rn "needle" /tmp | head -3` highlighted, `timeout:
5000` pulled out, and "Allow Bash?" with its buttons inside the card --
then Allow, which resolved in place and ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:24:05 -04:00
irisandClaude Opus 5 9f403cddab Render markdown, and stop wiping messages still waiting to be read
Two things, both about the transcript telling the truth about itself.

Markdown is rendered rather than shown as its source. The parsing is
mikepenz/multiplatform-markdown-renderer, not something written here:
markdown is somebody else's specification, and a hand-written subset of
one disagrees with it at the edges, which is where the bug reports come
from. `Markdown.kt` is only the mapping onto this app's palette, so code,
links and rules take the Catppuccin values the rest of the app uses
rather than the renderer's defaults.

The queued-message list was cleared wholesale whenever a turn ended. But
the backend holds a queue of its own and takes one message per turn, so a
turn ending is precisely the moment the *rest* are still waiting -- the
bubbles vanished while the messages were on their way, which reads as
everything after the first having been dropped. Now a held message
leaves the list exactly two ways: the session reads it, which arrives as
a UserMessage, or its send failed and there is nothing to wait for.

Measured first, because the report was that the backend dropped them:
three messages sent behind one long turn were all delivered in order
(ONE, TWO, THREE) against current main, so the loss was in the display.

Verified on the emulator: headings, emphasis, inline code, nested lists,
a quote bar, a fenced block, a rule and a link all render, and the three
queued messages sit through their turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:10:22 -04:00
irisandClaude Opus 5 aff2cb90e2 Stop reporting the app being switched away from as a failure
Backgrounding the app left "Lost the event stream
(SocketTimeoutException: null)" waiting at the top on return. Android
stops the activity, the socket dies with it, and the reconnect loop --
which kept running on a phone nobody was looking at -- recorded the
failure. Switching apps is a choice somebody made, not a fault to report.

Worse, it could not clear. `streamError` was reset when an event arrived,
so a session that reconnected and then sat idle displayed a connection
error it had already recovered from, indefinitely. That is the expensive
half: a stale failure is indistinguishable from a live one.

So the stream now runs only while the screen is at least STARTED, which
makes the drop a deliberate close rather than an error (EventStream
already distinguishes them), and resuming reconnects from the same
cursor. What takes a failure off the screen is `onOpen` -- the measured
moment the server accepted the connection -- rather than the first event
to follow it.

The message that does get shown leads with what will happen next rather
than with the exception's class name, which named nothing the reader
could act on.

lifecycle-runtime-compose is declared rather than inherited from
activity-compose, for the reason core-ktx already is: this code calls
repeatOnLifecycle and LocalLifecycleOwner directly now, and a transitive
could change under it. 2.11.0, the current stable.

Verified on the emulator against an idle session, which is the case the
old code could never clear: backgrounded 35s, returned, no banner -- and
a message sent afterwards arrived live, so the reconnect genuinely
reattached rather than merely staying quiet. Build, lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:36:04 -04:00
irisandClaude Opus 5 362d436d4f Let sessions outlive the backend, and never resume one twice
Three `claude` processes ended up running against this checkout on
2026-08-29, and the account hit its session limit. One cause, several
ways in.

An agent imported the Claude Code session it was *itself* running in.
That is an ordinary import, and importing runs `--resume` -- so a second
CLI attached to a file the first was still writing. The whole 65 MB
conversation, 154 embedded screenshots included, was re-appended to the
transcript under a new prompt id; both copies then read each other's
writes as work done elsewhere, and the adopted one was billed for
re-reading all of it. Meanwhile `shutdown_all` asked each session to stop
and the process exited immediately, so the SIGKILL timer died with the
runtime, the stop was unreliable, and whatever survived was orphaned with
nothing written down to find it by.

The processes leaked either way. So leak them on purpose, and be able to
pick them back up.

A session's process now outlives the backend and is adopted again on the
way up, which is worth having for its own sake: restarting the server no
longer ends a turn somebody is waiting on. Its stdio lives in the session
directory -- a fifo opened read-write so the process is its own last
writer and never reads EOF, plus stdout/stderr logs read from a byte
offset. `session::process` records the pid *and* the kernel's start time
for it, because a pid alone is reused and adopting a stranger's would mean
never resuming the real conversation.

That makes the fix structural rather than a check: everything goes through
`ClaudeDriver::launch`, which adopts if it can and starts if it cannot,
and `--resume` is reachable only on the second path. `Driver` gains two
ways out where it had one -- `detach` (coming back) and `stop` (the
session is being deleted, so the process must not survive).

Importing a session that is open is now refused outright. Claude Code
keeps `~/.claude/sessions/<pid>.json` for every live session, so this is a
measurement rather than a guess; it reports no/yes/unknown, because a
machine that keeps no such record cannot answer and "could not check" is
not "nobody is using it". `SessionStatus` gains `Unknown` for the same
reason.

Also here, found on the way:

- A reconnecting phone was sent the entire backlog. Opening a session was
  bounded to a page but reconnecting was not, so a long disconnect
  delivered thousands of events one frame at a time. Past `CATCH_UP_LIMIT`
  the stream sends a `reset` frame and the newest window, and the client
  rebuilds from it as it does on open -- without the reset the window is
  spliced onto rows no longer adjacent to it.
- A session's status was assumed idle at launch. Read from the transcript
  instead, so a restart stops claiming an exited session is waiting for
  you.
- `llama-server`'s stdout was piped and never drained, so a chatty one
  blocked on a full pipe buffer mid-load. It goes to a log now.
- A turn that exited or errored never emitted `Idle`, so the queue stayed
  "running" for good: every later message was held forever and, since a
  message is only recorded when taken, vanished with nothing on screen.
- Two doc comments had drifted onto the wrong functions.

Verified by killing the server mid-turn: the process survived, finished
its turn unattended (12.8 KB of output nothing was reading), and the
restarted server adopted it -- one process, all 700 lines in the
transcript, no hole, and it still took a new message afterwards. Deleting
a session stops its process; a 266-event backlog resets while a 16-event
one streams. 46 tests, clippy and rustfmt clean, app compiles and lints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
2026-08-29 04:47:43 -04:00
iris 9791afcfd6 Hold a queued message below the indicator until the turn takes it
The backend records a message the moment it is sent, so one sent into a
running turn landed in the transcript at the time *we* spoke -- above the
working indicator, in among things the session had already read. It had
not read it. Showing it there says otherwise.

Messages sent while a turn is in flight are now held below the indicator,
drawn quieter, and take their place in the conversation when the turn
ends. That is an approximation and worth naming: the CLI injects a queued
message at a tool boundary, and tells nobody when it does, so the end of
the turn is the first moment anything here can honestly say the message
was taken. It errs toward "not yet read", which is the direction that
cannot mislead.

Also in this change, from the same pass over the screen: the app draws
above the gesture strip rather than under it, and the three status colours
that were literals in two other files -- an amber, a green and a red off
Material's defaults -- are now Catppuccin members in Theme.kt beside the
rest of the scheme.

The ordering is verified by construction rather than photographed: the
list is bottom-anchored, so the first item emitted is the lowest on
screen, and the queued block is emitted before the indicator. Staging a
real long-running turn to photograph cost four model turns and never
produced one, because the model kept declining to sleep -- which is its
own finding, and the reason the next change is a test command in the echo
driver.
2026-08-28 23:49:55 -04:00
iris f402a1f6c8 Hold the bottom while typing, and put the status where the answer goes
**Typing moved the newest message out of sight.** The list re-pinned on a
new item and nothing else, but the two things that shrink it while
somebody writes a reply are the field growing from one line to four and
the keyboard opening under it -- neither of which is a new item. So the
message being replied to drifted upward, and the view only came back when
the reply was finally sent, which is the one moment it did not matter.

It now watches the viewport as well as the item count, and only acts while
already pinned.

**The status has moved out of the corner.** As a label up there it said
something about the session; at the end of the transcript it says
something about a place -- this is where the next thing appears -- and
that is where the reader is already looking, because it is where the last
message is.

`exited` is still said, in the same place. Removing the corner label
without it would have left a session whose process is gone looking exactly
like one waiting for input, on a screen whose whole purpose is typing at
it.

Verified on the phone-sized case rather than reasoned about: three lines
of text with the keyboard open keeps the newest message directly above the
field, and sending shows "working" immediately under the sent message.
2026-08-28 23:32:35 -04:00
iris e03b757dec Stop losing tool calls at the seam between transcript pages
A tool's start and end are two events folded into one row, and the fold
only ever *updated* an existing row -- so an end whose start was not in
the same fold changed nothing and vanished. Not a broken row: no row at
all, which is indistinguishable from a tool that never ran, and is what
Iris saw as gaps where she remembered work happening.

Paging made it routine. Each page was folded on its own and prepended, so
every seam split whatever spanned it: 30 tool ends in the first page of
this conversation, one of them already orphaned before a single scroll.

Two changes, because the two halves fail differently.

Pages are re-folded from the events they came from rather than folded
apart and stitched together. That needs the events kept beside the rows,
since folding is one-way. One pass over everything loaded, paid only when
somebody scrolls back, which is the moment they asked for it.

And an end with no start now creates a row instead of disappearing. Its
name is unknown from an end alone, so it says "tool" until the earlier
page arrives and replaces it -- a row that admits what it does not know
beats silence, because silence is a claim that nothing happened.

Verified across a real seam: scrolling back through the 80-event boundary
of an 864-event import is continuous, with no gaps where tool calls were.
2026-08-28 23:25:13 -04:00
iris 2fe34176c0 Give the transcript a way back, and stop losing an import's name
**Every imported session was called "claude-cli session".** The app has
nothing to say about the title -- the server names it after the session it
is continuing -- so it sent `""`. That is `Some`, which satisfied the
`or_else` meant to catch "no title given", so the imported name was
computed and then thrown away in favour of the `<provider> session`
fallback.

Normalised at the boundary instead: blank means absent, because that is
what it means to the person who left it blank. Both the client's value and
the imported one go through the same trim, so neither can be a string of
spaces standing in for a name.

**And a jump-to-latest button**, shown only while the newest message is
off-screen. Reading back through a conversation is a place to be rather
than a state to be rescued from, so it waits to be wanted and leaves once
there is nowhere to jump to.

It says where it goes instead of drawing an arrow. The list is laid out
from the bottom, so "down" in the data is up on the screen, and an arrow
would be asking the reader to hold that in their head to press a button.

Verified on screen: an import now arrives titled "ai-app" rather than
"claude-cli session", and the button appears on scrolling back, returns to
the newest message, and disappears on arrival.
2026-08-28 23:19:07 -04:00
iris dcb158ee44 Fetch the transcript instead of replaying it one event at a time
**The five seconds of loading top-down.** Opening a session subscribed to
the event stream from sequence zero, so the backlog arrived as one SSE
frame per event -- 864 of them for an imported conversation, rendered as
they landed. That is not a slow list; it is a conversation being replayed
at network speed, and it looks like loading from the top because it is.

The newest page now comes as one request, and the stream starts from where
that page ended, carrying live events only -- which is what a stream is
good at. Scrolling back fetches the page before it, so history costs
something only when somebody actually reads it. 80 events instead of 864,
and the first frame is already the end of the conversation.

I had called this fixed after anchoring the list at the bottom, on the
strength of an emulator on the same machine as the server. That test could
not have shown the problem: the whole backlog arrived in one frame's worth
of time over loopback. Iris's phone, over a tunnel, took five seconds.

**Send disappearing while running.** It was never conditional -- the row
simply ran out of width. A Row hands out intrinsic widths in order and
clips the overflow, so when Stop appeared the pickers I had added pushed
Send off the screen: the app's central control, gone at exactly the moment
the app is most in use. The settings now share what is left after the
actions have taken what they need.

While a turn is in flight the button says **Queue**, because that is what
sending then does -- the message is injected at the next tool boundary
rather than starting a turn of its own. The backend has always done this;
the button was describing something else.

**And the model picker no longer dismisses the keyboard**, which it did by
taking focus. Changing the model mid-sentence is an aside, not a departure
from what you were typing.

Verified on the 864-event import: at the newest message within a second,
history paging back continuously past the first page, and Stop beside
Queue while running.
2026-08-28 23:12:34 -04:00
iris ba25a5cacf Open the transcript at the bottom instead of travelling there
The list was built oldest-first and then scrolled to the end, so opening a
session started at the top and raced downward through everything in it. On
an imported conversation that is nine hundred items measured before a word
is readable, and it was visible every single time.

Laying the list out from the bottom removes the journey rather than hiding
it. The newest message is index 0, so the first frame is already the right
one, and older items are composed only when somebody scrolls back to them
-- which is also what makes a long history cheap rather than something to
load up front.

Following the tail gets simpler as a result. There is no longer a moment
where new content pushes the anchor away, so "am I pinned" is read
straight from the scroll position instead of being remembered across
scrolls, and a new message is one step back to index 0 rather than a jump
across the transcript.

Checked on the 863-event import of this very conversation: a screenshot
one second after opening is already at the newest message, and scrolling
back reaches older ones in the order they happened.
2026-08-28 23:02:56 -04:00
iris a9ea84c96c Stop choosing a model, and keep an imported session up to date
**Why the model became fable.** `spawn_session` fell back to the
provider's first listed model when none was given. That list is a shortcut
for the spawn screen, written in whatever order somebody typed it, and its
first entry is `fable` -- so every session spawned without a model, which
is every import, silently became a fable session. It looked like a default
and was an artefact of list order. Absent now means absent: no `--model`
flag, and the CLI uses whatever the person configured for themselves.

**Model and permission mode are now visible and changeable** from the
session, as buttons that read as their current value rather than labels
beside one. The mode was spawn-only; the CLI turns out to accept
`control_request{subtype:set_permission_mode}` and echo the mode back,
probed against 2.1.237 the same way the rest of the protocol record was.
Both default to `auto` -- on a phone every ask is a round trip to a
question card, which is how "allow Bash?" became the most-answered
question in the app.

The mode is reported by the API so the picker shows what the session is
actually set to, and it is kept in the live session beside the model for
the reason the model already was: `meta` is the shape a session was
*launched* with, so reporting from it shows the value a change replaced.

**And an imported session keeps itself level with its source file**, so
work done at a terminal arrives without a button. `--resume` appends to
the same transcript rather than forking -- measured, not assumed -- so the
only hard question is which new lines came from here.

Answered by counting the events this session has recorded. Status is the
obvious signal and is wrong, which cost a round trip to find: a turn that
starts and finishes between two polls reads as idle at both, so its output
is replayed on top of itself. It showed up on screen as `donedone`, and
only because the reply was one word -- with a longer answer it would have
looked like the model repeating itself.

Verified against both halves: text appended to the source file the way a
terminal writes it appears within one interval, and a message sent through
the app appears exactly once, before and after a turn.
2026-08-28 22:44:41 -04:00
iris c3e7f07a5d Pin the transcript to its tail, and stop asking about every command
**The scroll.** The transcript scrolled on new items and nothing else,
which missed the two cases that matter most. An imported session's history
arrived and left the view wherever it landed; the keyboard opening shrank
the viewport and slid the newest messages under the IME, so typing meant
typing into a view showing the middle of something.

The view is now pinned to the tail, and it is the reader's scroll that
decides: settling anywhere above the bottom releases the pin, settling
back at the bottom re-arms it. The pin is written only when a scroll
*ends*, so it survives the moment when new content has just pushed the
bottom away but the reader never moved -- deriving it continuously from
"is the bottom visible" would release it on every append, which is the
race that makes naive follow-the-tail implementations let go at random.
New items and viewport resizes both re-scroll; the jump is instant rather
than animated, because an imported session appends hundreds of items at
once and animating through them is a light show.

**The input field gets a row of its own**, above the buttons. Sharing one
row put the full width behind three controls, so the thing being typed
into was the narrowest thing on the row.

**Permissions default to auto, and importing can choose.** The spawn
screen defaulted to "manual" and imports passed no mode at all, so the
CLI asked about everything -- and on a phone every ask is a round trip to
a question card, which is how "allow Bash?" became the most-answered
question in the app. Both paths now default to auto, with the other modes
one tap away for a session that warrants caution.

Looked at running, all three: an imported session opens at its bottom,
the tail stays visible while typing with the keyboard open, and the mode
picker shows auto selected.
2026-08-28 22:24:37 -04:00
iris 31135e3f22 Wear Catppuccin Mocha, and move Usage to where the provider is
Two changes to the app, plus the one they turned up.

**The theme.** Catppuccin Mocha, copied from dev-updater rather than
shared: wg-app-link is the *link* -- the tunnel, the pinned CA, enrollment
-- and a palette is not that. The two apps looking alike is a preference
rather than a contract, and the moment one wants a different accent a
shared version becomes a thing to fight. dev-updater's ActionTone and its
ANSI table did not come across; nothing here draws a log or a destructive
button yet, and copying a vocabulary with no speakers is how a file starts
lying about what the app does.

**Usage is no longer a global button.** It belongs to the provider, and
the session view is the only place a provider is currently named, so that
is where the control sits -- beside the line that names it, rather than
collected with the app-wide controls where its scope had to be guessed. It
carries the session back with it, so Back returns to that session rather
than dumping the reader on the list.

Its real home is that provider's own settings, which do not exist yet.

**And the bit that only running it could find.** I first subtitled the
usage screen with the session's provider, which on an echo session put
"echo" directly above a card reading "claude" -- Claude's account-wide
numbers labelled as echo's, a claim about echo that nothing measured. The
subtitle is gone; each card names the service that answered, which is the
true scope, and the reason is written where the subtitle was so it does
not get re-added.

Looked at on the emulator rather than read: the palette, the session
header, the usage screen, and Back landing on the session it came from.
ktfmt, compile and Lint clean.
2026-08-28 20:26:12 -04:00
irisandClaude Opus 5 6187958de3 Let a llama session actually be started from the phone
The driver worked and the models could be downloaded, but the spawn screen
had no idea llama.cpp existed: the model field and every extra setting were
gated behind `isClaude`, so a llama provider offered nothing, `model`
arrived null, and the driver refused with "a llama.cpp session needs a
model". The feature was reachable only by curl, which is not what was asked
for.

A llama provider now gets the models this backend has downloaded, as a
picker rather than free text -- there is nothing sensible to type, and a
name that is not on disk is a session that cannot start. Context size and
temperature are there too, blank meaning llama.cpp's own default rather
than a zero. Spawn stays disabled until a model is chosen, because without
one the button could only fail.

**Two bugs that only appeared by pressing the button**, both mine, both
from changing the server without re-driving the app:

- The app sent the setup's *label* where the server had started resolving
  by *id*. The failure was almost self-diagnosing -- `no setup named "this
  machine" -- configured: this machine` -- and that message now says "no
  setup with id" and lists ids, since listing labels was what made it read
  as a contradiction.
- The session header showed `on local`, the id, because the app read
  `setup` where the server had begun sending both `setup` (id) and
  `setupName` (label). The app now carries only the label: nothing in it
  addresses a setup, and holding both is what let it show the wrong one.

Verified by doing it: rediscovered the local machine from the phone so
`local-llama` appeared, spawned a session on Qwen3-0.6B-Q8_0 with a 4096
context, sent "Reply with exactly one word: ready", and it replied "ready"
with 125 tokens counted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 13:38:00 -04:00
irisandClaude Opus 5 ecac404fd4 A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of
each. They were never independent: a provider only exists on a machine
where that program is installed, so the spawn screen offered the whole
cross-product, including "the Claude CLI on the box that hasn't got it".
The picker could not know, because nothing in the model said.

Now a setup is a machine -- optional ssh, plus the providers it has -- and
spawning is two choices in order: pick a setup, then one of its providers.
The impossible pairs stop being expressible rather than being validated
against. Provider names are unique within a setup and only within one, so
two machines can each have a `claude-cli`, which was previously either a
name collision or two entries called things like "claude" and "claude on
the vm".

It also settles the "Run on" problem properly. That control was offered for
every provider but honoured only by the Claude driver -- an echo session
sent to a host ran locally and said otherwise. There is no such control
now: the machine is chosen first, and echo is a provider of the setup with
no ssh, where it belongs, since it runs in-process and has no transport to
cross.

The built-in echo provider is gone as a concept. It used to be conjured at
read time and never written to the file, which meant a provider nobody
could see or edit; it is now seeded into the config on first run alongside
claude-cli. What the file says is what there is, and deleting it is a
choice rather than a state to be repaired.

A config in the old shape is refused with instructions rather than loaded.
`Config` defaults unknown fields away, so `providers:` and `hosts:` would
otherwise have vanished into an empty config that was then seeded over --
a migration nobody would notice until their setups were gone.

Verified against a running server and on the emulator: a fresh install
seeds "this machine" with echo and claude-cli and the file reads cleanly;
a two-setup config lists both with their own providers; spawning on a
setup works and the session row names it; asking for a provider a setup
lacks says which it offers, and an unknown setup says which exist. On the
phone, selecting "dev vm" narrows the provider chips to that machine's one
and shows its address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 12:56:14 -04:00
irisandClaude Opus 5 0c13090d70 Take ktfmt's defaults for the Kotlin half
The server half went to rustfmt's defaults earlier today; this is the same
move for the app, and the same reasoning. Kotlin ships no formatter with
the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org
owned now (it moved from facebook/ktfmt), is a formatter rather than a
configurable linter, and has essentially nothing to tune -- which is what
rule 27 is asking for. ktlint's .editorconfig surface is the thing that
rule warns against, and detekt is static analysis, whose job Android Lint
already does here.

One setting, and it is a choice between the tool's own two styles rather
than a tuning: kotlinLangStyle() is the 4-space one, which is what this
code already was. The 2-space default would have reindented every file to
say nothing.

  ./gradlew :androidApp:ktfmtFormat   to apply
  ./gradlew :androidApp:ktfmtCheck    to verify

Formatting only. The one thing worth checking by hand was the generated
PEM constant, since a leading newline there costs Android's
CertificateFactory its preamble sniff and fails at runtime nowhere near
the cause: ktfmt moved `.trimMargin()` onto its own line and left the
template alone, and the regenerated constant still starts at the opening
quotes.

Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and
the APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:03:14 -04:00
irisandClaude Opus 5 dde5042b12 Run the linter the app build already had, and fix what it found
`./gradlew :androidApp:lintDebug` had apparently never been run. It
reported 11 errors, 8 warnings and 3 hints, and one of the errors was a
crash: UsageScreen formats its reset countdown with java.time --
OffsetDateTime and Duration, both API 26 -- while minSdk is 24 and core
library desugaring was off. On 24 and 25 that is a NoClassDefFoundError,
and the `catch (_: Exception)` around the code does not stop an Error, so
the usage screen would have died rather than degraded.

Core library desugaring is now on, with desugar_jdk_libs 2.1.5. Verified
by looking in the built APK rather than trusting the flag: it now carries
Lj$/time/Duration and Lj$/time/OffsetDateTime, the backported classes the
call sites are rewritten against.

The rest: the two KTX suggestions taken (SharedPreferences.edit's block
form, which cannot forget its apply(), and String.toUri), the three
autoboxing hints taken (mutableIntStateOf/mutableLongStateOf), and
androidx.core:core-ktx declared at 1.19.0 rather than inherited through
activity-compose, since this code now calls its extensions directly.

Two are suppressed with their reasons, both scoped to the one element.
DiscouragedApi on the scanner's screenOrientation, which is not a pin but
the removal of the library's landscape lock. MissingApplicationIcon,
because there is no icon yet and that is a decision to make later, not an
oversight -- an app with no icon is obvious to anyone who opens a
launcher, so the warning tells nobody here anything.

0 errors now. The 4 warnings left are one thing: Compose Multiplatform
1.12.0 is out and this is on 1.11.1. That is an upgrade to decide on, not
a defect, so it is left for its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:00:57 -04:00
irisandClaude Opus 5 99bcc341c1 Cleanup pass: one home for duplicated logic, stale comments out
Nothing behavioral except two status codes; mostly removing places where
the same rule was written down more than once and could drift.

- server/src/private.rs: the owner-only create/write helpers, which
  config.rs, certs.rs, and the session dirs each had their own copy of
  (certs.rs even duplicated the explanatory comment). One module owns the
  modes now, so the "nothing this server writes is readable by anyone
  else" property is checkable in one place.
- server/src/media.rs: the image media-type/extension table, which the
  four places that have to agree on it each spelled out separately --
  storing an upload, serving it back, building a content block, saving a
  produced image. The differing *defaults* stay at the call sites with
  the reasoning, since they genuinely differ by direction.
- routes.rs: a missing file was a 400 and an unreadable one a 400 with a
  hand-rolled log line; they are now 404 and Internal respectively.
  UnknownSession became NotFound, since it was the only 404-with-message.
- main.rs: xdg_dir takes the variable's value instead of reading the
  environment, which drops the unsafe set_var from its test and lets the
  test actually assert the relative-path rule.
- echo.rs had its own 4-byte hex generator beside session::random_hex.
- claude.rs: the two impl Translator blocks were one type's methods.
- Stale comments: phase-2 markers on shipped work, a permission-mode list
  that had drifted from the CLI's, "dev-updater" as the leaf certificate's
  fallback common name, a half-written sentence in build-apk.sh.
- App: the JSONArray walk written out in four fetchers, the four
  near-identical BackHandlers in AppRoot, and SessionScreen's inline
  fully-qualified names where the file otherwise imports.
- server/wg-test.log was committed by accident; *.log is ignored now, and
  the gitignore comments describe where state actually lives.
- PLAN.md's backend layout gains the new modules and drops hosts.rs for
  the ssh.rs that was built instead.

Verified: 35 server tests, clippy clean, app compiles warning-free, and a
scratch server driven over curl -- attachment upload/serve round-trip with
both a known and an unknown content type, the new 404s, transcript and
session-dir deletion, plus a real claude-cli session answering a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 16:10:33 -04:00
irisandClaude Fable 5 fff1fb49e8 Providers and hosts: what runs, and where, as independent choices
A session now names a provider (what: driver kind, command, models) and
optionally a host (where: an ssh target). Keeping them independent is
what the real setup needs -- the backend runs where the phone can reach
it, which isn't where the CLI is installed -- and it means any provider
can be sent to any host rather than a machine being baked into one.

The first provider is claude-cli, named for the CLI rather than bare
"claude", which would suggest the credit-billed API. A fresh config is
seeded with it so a new install has something to spawn and a worked
example to edit; echo stays a built-in provider needing no config.

ssh.rs builds the child process either way: locally, or `ssh -T` with
BatchMode and keepalives, every argument single-quoted for the remote
shell (a working directory that tries to close the quote and start a
command is covered by a test), and `exec` so dropping the connection
takes the CLI down instead of orphaning it.

App: the spawn screen reads /providers and /hosts instead of hardcoded
lists, so config changes need no rebuild. Chip rows are FlowRow, fixing
the reported bug where a row of models that didn't fit wrapped *inside*
each chip -- one letter of "haiku" per line -- rather than onto a second
line.

Verified: 29 tests, clippy clean; the same claude-cli provider run once
locally and once over ssh, with the remote one visibly in a different
environment; an unknown host name refused with the configured list; and
the spawn screen on the emulator showing server-driven providers, hosts,
and models that wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 03:34:33 -04:00
irisandClaude Fable 5 f2430671a2 Phase 2 complete: images both ways
Inbound: POST /sessions/{id}/attachments stores a picked photo under the
session; message attachmentIds become base64 image blocks in the
stream-json user message (verified live: an uploaded red PNG answered
"Red."). Outbound: image parts in tool results are decoded into the
session's files/ dir and referenced by Image events -- the transcript
stays lean -- and GET /sessions/{id}/files/{ref} serves them (verified
via the Read tool round-tripping the same PNG). The app grows an attach
button (system photo picker, upload-on-pick) and renders Image events
inline with an authenticated pinned fetch. Sent attachments are echoed
into the transcript as Image events so every device shows them.

Attachments and files are addressed under their session (a deviation
from PLAN.md's original bare /attachments -- recorded there) so their
lifecycle is the session directory's: deleting the session is still the
complete path out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 21:40:25 -04:00
irisandClaude Fable 5 213bc72b64 Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS
Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 21:07:02 -04:00