Commit Graph
41 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:41:15 -04:00
irisandClaude Opus 5 6bdec6e785 Let a session resume itself when its usage limit lifts
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).

Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.

The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.

The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.

Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.

Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 05:21:43 -04:00
irisandClaude Opus 5 4821a02bd3 Default thinking level for new sessions, and move the rigs out of AGENTS.md
`Config::default_effort` is what a session starts at when nothing chose one,
applied in `spawn_session` rather than filled in by the spawn screen so it
holds for an import and a bare API call too. It is set by the spawn screen's
own picker, whose label says so: one control, where new sessions are made,
rather than a settings page for a single value. Not on a provider, because
providers are discovered and the next rediscovery would erase it; not on the
phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it as a struct, so the permission mode --
still hardcoded to `auto` on the spawn screen -- can move there later without
a second route.

Only drivers that read a level are given one: an echo session was storing a
`--effort` it never passes to anything, which is a config file answering a
question about itself wrongly.

Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12
KB of it was rigs and reference measurements that only matter once you are
running one. Those are the `ai-app-rigs` skill now -- the same text, still the
only copy, read when the work touches it. 35,198 -> 20,813 chars.

Verified on the emulator against the sandbox: the spawn screen pre-fills from
the server, picking `low` spawned a session at `low` and left `/defaults` set
to it, and an echo session spawned afterwards took no level at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:42:05 -04:00
iris 3c0214ece8 Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
2026-09-04 17:56:50 -04:00
irisandClaude Opus 5 127b25e60a Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.

Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.

And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:

- A forwarded launch gets a pty and every other one keeps `-T`. Killing
  the ssh client ends a CLI by closing the stdin it reads; llama-server
  never reads its stdin, so the same kill left it running on the far
  machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
  machine's own models directory, so `GET /setups/{id}/models` is what
  the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
  that will not load exits in a second and would otherwise have been
  reported as "gave up after 300s". The failure carries the log's tail.

Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:45:32 -04:00
irisandClaude Opus 5 79682f03a7 Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong.
PLAN.md still described pi as the llama.cpp harness, a refcounted
LlamaServerManager, and a providers-by-hosts cross-product, all of which
were superseded or never built; it also carried a second copy of the HTTP
table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held
implementation checklists for work that has since landed. AGENTS.md
restated most of PLAN.md's design instead of being the working-notes
layer it says it is. 3225 lines of markdown to 2180, with the stale
sections gone rather than reworded.

On the server, comments explaining what the code already says are out and
the ones recording a constraint, a measurement or an incident are kept but
cut to a few lines each: 5504 comment lines to 4586.

Four doc comments in session/mod.rs, and one each in process.rs and
usage.rs, had drifted onto the item above the one they describe --
functions were reordered without them, so `stop_session`'s doc sat on
`set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on
`type Cached`. Each is back on its own item.

routes.rs's module table also claimed later phases would add `/hosts`,
which setups replaced.

cargo test (127 passed), clippy --all-targets and fmt are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:45:43 -04:00
iris e3e02d55f7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
2026-09-04 15:03:37 -04:00
irisandClaude Opus 5 a802522039 Keep visited transcripts on the phone
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:00:25 -04:00
irisandClaude Opus 5 74110b4d72 The reconnect loop does not reproduce; log who falls behind
Measured against a session streaming 20 events a second. Reopening one with
an anchor 1,800 events back connects 87-119 events behind, under
CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page
and one span covering the whole distance to the anchor, where the report
was written against a restore that took thirteen. Three provocations, no
repeat connection between them: that reopen, twenty seconds backgrounded
while 415 events were produced, and a reset forced by dropping the limit to
5, where the app cleared, refilled and carried on without reconnecting.

So the TODO entry goes. What stays is the instrument that answered it:
every SSE subscriber now logs at debug the cursor it arrived with and
whether it was continued or reset, since nothing else could say. The app
sees a window arrive and cannot tell how far it had fallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:01:01 -04:00
irisandClaude Opus 5 45e631ab96 Delete TRANSCRIPT_RENDERING.md, which was all finished work
The file was a week's record of the transcript work, and everything in its
architecture, techniques and rejected sections has landed -- so it was a log
of how the code got here, which the code's own comments and AGENTS.md
already carry. Kept only what existed nowhere else: stream-bench.sh,
trace-draw.sh and the two emulator-loop traps go to AGENTS.md beside
transcript-bench.sh, along with the standing rule to run the benches either
side of a transcript change; GrapheneOS's broken System Tracing goes to
~/.claude/MACHINE.md, since it is about the phone rather than this project.

Its one unfinished item -- the reconnect loop after a restart onto a
streaming session, which may already have been fixed by the restore's
one-event-per-request fix -- moves to TODO.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:38:40 -04:00
irisandClaude Opus 5 ffc266bf3e Name the measurement files in the fixture's description
AGENTS.md lists what the explorer's sandbox tree holds and had not caught
up with the three sized source files added beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:18:00 -04:00
irisandClaude Opus 5 2c12274285 Measure the explorer, and cap edit mode at what it can carry
Three numbers, taken on the emulator through the app's own render report
and written into EXPLORER.md; the fixture tree the sandbox now builds is
what they were taken against.

The viewer's scan was on the main thread. Decision 8 said off it, and the
first version did it in a `remember` inside the composition, which is not
that -- 460ms of frozen screen on a 1 MiB file, long enough that the
accessibility tree cannot be read, which is exactly what "the app has
stopped" looks like from outside. It runs on Dispatchers.Default now, with
a spinner where the file will be. Reading a megabyte is otherwise fine:
the viewer is a row per line, and it opens and scrolls 28,660 of them.

Edit mode needed a cap, and not the one the plan expected. The cost that
matters is not the highlighting -- 40ms a keystroke at 128 kB, which is
survivable -- it is Compose laying out one enormous text in the field:
2,027ms per frame at 128 kB, with typed characters dropped, and no
response at all at 1 MiB. Switching highlighting off would have saved
nothing, since every arrangement of a single text field pays it. So
EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and
above it the pencil is disabled with the reason in words beside it: a
disabled control teaches what the thing can do but cannot say why it is
off, and a reader who cannot edit a file they can plainly read would
otherwise conclude the app is broken.

`FileLines.of` is timed like everything else here, so the figure lands in
the render report rather than needing a harness to ask for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:16:57 -04:00
irisandClaude Opus 5 9c4d33273b Merge branch 'main' of git.arirex.me:iris/ai-app
The drag-right-to-go-back gesture was removed on the other side while the
file explorer was being built on this one, so the explorer's own use of it
goes with it: back inside the explorer is the arrow and the platform's own
gesture, which is what the rest of the app now has too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:00:48 -04:00
irisandClaude Opus 5 db55ed4a8f The file explorer on the phone
The other half of EXPLORER.md: a folder button on the session header opens
the machine's filesystem, starting where the session works.

It draws **over** the session in the same `Box`, so the session under it
stays composed -- its event stream keeps flowing, its draft and scroll
position stay where they were, and coming back from a file costs nothing.
Back steps one level inside it (editor, viewer, directory, parent) and only
closes from where it opened; the platform gesture, the button and the swipe
all go through the one function, so they cannot mean different things.

The viewer is a `LazyColumn` of lines rather than one `Text`, because text
layout is linear in the text and a twenty-thousand-line file in a single
`Text` measures all of it to draw a screenful. Lines do not wrap and share
one horizontal scroll, so a logical line is a visual line and the gutter
cannot come to number the wrong text; the gutter's width is measured from
the digit count of the line count in the style it is drawn in. The editor
is a `BasicTextField` with a `VisualTransformation` carrying the scanner's
spans, which is the one Compose API that colours a field's own text rather
than replacing the field.

`fileLanguage` reads the same table `fenceLanguage` does, so a language
added for fences is a language added for files.

A file that changed on the machine while it was open here refuses to be
overwritten and asks, with what each of the three answers costs. That is
the ordinary case, not the exotic one: an agent editing the file somebody
is reading is what this whole feature is for.

The speedometer moves off the header into the session settings dialog,
where the session's other about-the-session controls are, and the folder
takes a place between the usage chart and the cog -- widest scope to
narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto
`ui-trace`'s new tap-by-label action in the same change, so the render
report is never unavailable and never pressed at a coordinate that has
stopped meaning anything; `app/bench-lib.sh` is what they share, and
`grep -n "tap [0-9]" app/*.sh` is the check.

Exercised on the emulator against the sandbox's new fixture tree, with a
screenshot or a ui-trace for each: the listing (dotfiles, directories
first, a symlink to a directory sorted with them, a name with a tab in it),
a highlighted file, binary, too big, a permission error, editing and
saving, the 409 and its Overwrite, back with unsaved edits, creating a name
that exists, creating one that does not and landing in the editor, an empty
directory, and `..` above the directory the session opened in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:58:59 -04:00
iris 9fdab777b4 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 23:32:22 -04:00
iris 3bb178363d Draw an inline code chip behind the text instead of under it
The chip was the renderer's span background, and a span's background is
part of the text's own drawing: the text node paints the selection first
and the glyphs over it, so an opaque chip covered the selection and
selecting a sentence highlighted every word of it except the ones in
backticks. The previous fix let the selection show through by taking the
chip to 60% alpha, which is a compromise on both sides -- the chip is a
weaker step down from the page, and selected it reached #3C344F where the
words around it reached #776394.

There is a place that is under both, and a fenced block was already in it:
a modifier on the text rather than a style inside it. So `appendCodeChip`
takes the code span from the renderer's inline builder, keeps its style and
its space of padding either side but drops the background, and marks the
range; `LinkedText` draws those ranges in a `drawBehind`. The chip is back
to the full `rawSurface` fill (measured #11111B against a #1E1E2E page) and
a selection over it now lands at #776394, the same as the rest of the
sentence -- the fenced block's numbers exactly.

The geometry is one box per line, from the bounding boxes of the run's
first and last characters, taken as far as the line's `visibleEnd`. Not
`getPathForRange`: that is the shape of a *selection*, which runs to the
right edge of every line but the last, and a code span that wrapped left a
full-width empty chip behind on the line above -- twice in one fixture.
`visibleEnd` is the same rule the selection rectangle obeys, so the chip
stops where the selection stops instead of sticking its padding space out
past the end of a selected line.

Checked on the emulator against a fixture with chips in a heading, three
kinds of list item, a quote, a table cell and a link label, unselected and
under Select All, and a link with a chip in its label still opens. Cost,
against the same build without the change, streaming sixty paragraphs of
three chips each: measure 755ms against 776ms, record 327ms against 321ms,
transcript draw 0.22ms in both.
2026-09-03 23:32:17 -04:00
irisandClaude Opus 5 cc7e4f63ef Read and change a machine's files from the backend
The first half of EXPLORER.md: server/src/files.rs, which lists a
directory, reads a file, writes one, and creates a file or a directory on
whichever machine a setup names.

Each operation is one small POSIX script run through `Transport`, the way
the import listing and the usage fetch already ask a machine a question,
so the local and the ssh case are one implementation rather than two that
drift. The path crosses as a positional argument and never as script
text; `PATH_PRELUDE` is the one line that gives a leading `~` its
meaning, because a shell expands a tilde in text and not in an argument,
and it is the far machine's home that has to answer.

A read has four answers -- text, binary, tooBig, or the machine's own
error -- because a binary file drawn as text and a big one cut off
silently are both wrong in ways the reader cannot see. A write carries
the sha256 the read reported and is refused with a 409 when the file has
moved on, which is what happens whenever an agent is editing the file
somebody is reading.

`Transport::capture_with_input` is the one description of "run this
there, with this on stdin", and `ship_attachment` moves onto it rather
than assembling a second ssh invocation of its own. It is also the only
capture that hands back the exit status, which is how the write says
"this is not the file you read" without that answer looking like a
failure.

Exercised on both transports against the sandbox -- ssh to this VM with a
throwaway key, since the quoting and the stdin path are what that proves
-- including a filename with an apostrophe, one with a tab, an unreadable
file, a binary one, one over the limit, and the 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:28:00 -04:00
irisandClaude Opus 5 a2b11d516f Colour code with a scanner of our own instead of the library
dev.snipme:highlights 1.1.0 found comments before it knew the language and
paired /* with */ by ordinal, so `//` in any URL commented out the rest of
its line, every Rust `#[derive(...)]` greyed out as a comment, a `#` inside
a Kotlin string swallowed the line, and `x '*/a/*'` in shell produced a span
whose end preceded its start -- the one that crashed a card holding
`-path '*/.git/*'`. None of that could be post-processed away, because
comments won over strings before the language was known.

Highlighter.kt is one left-to-right scanner: at each position it is in a
line comment, a block comment, a string, or ordinary code, and every span is
emitted by advancing an index, so spans cannot overlap, arrive out of order
or run backwards. Languages.kt is a `Rules` row per language -- comment
tokens, block comment and whether it nests, the string forms, what opens an
attribute, and the keyword set -- so a new language is a table entry. The
keyword lists came from the library's SyntaxTokens.kt (Apache-2.0, noted at
the table) so nothing that is coloured today turns plain, and RON, TOML,
fish and JSON are coloured for the first time.

HighlighterTest.kt is a new JVM unit test source set -- 24 cases, the
library's mistakes kept as regressions, plus a sweep asserting no span
escapes the code for any language on unterminated and empty input.
AGENTS.md's app line now runs :androidApp:testDebugUnitTest.

Measured on the ai-app emulator, debug build, a ~200-line Kotlin fence sent
into a sandbox session:

  before  code highlighted: 1, 101.9ms total, 101.9ms mean, 101.9ms worst
  after   code highlighted: 1,  15.0ms total,  15.0ms mean,  15.0ms worst

and a second fence in the same run took 13.9ms, so that is the steady cost
rather than class loading. stream-bench.sh after the change:

  code highlighted: 1, 12.1ms total, 12.1ms mean, 12.1ms worst
  markdown reparsed while streaming: 1329, 2130.7ms total, 1.6ms mean, 8.7ms worst
  record: one block: 131, 11.4ms total, 0.1ms mean, 0.4ms worst
  draw phase 1.21ms per frame, the transcript 0.23ms of it

transcript-bench.sh after: draw phase 1.10ms per frame, the transcript
0.49ms (place 0.48), worst place 4.3ms -- unchanged within run-to-run noise,
as expected, since the scan happens in `warm` and not while drawing.

Looked at on the emulator: a URL inside a Kotlin string, a Rust attribute
with a lifetime and a raw string, a shell line with globs and `$#`, a RON
fence and a TOML fence all colour correctly; a Bash tool card still colours
its command; a plain Python fence -- which this change had no reason to
touch -- looks as it did; an unknown language stays plain; and a fence is
plain while it streams and colours when it freezes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:23:59 -04:00
irisandClaude Fable 5.1 801618ba0e Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work
Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.

A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.

TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:24:36 -04:00
irisandClaude Fable 5.1 6180663f14 Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 08:15:10 -04:00
irisandClaude Fable 5.1 48bb7de304 Draw replies as pieces of one parse, and lists an item at a time
A settled reply used to be cut into block *strings*, each parsed on its own
and each a unit of the lazy list; the live reply split the same way with a
whole-message parse per delta on top. Now a message is parsed once, and a
Piece addresses a top-level block of that tree -- or one item of a
top-level list, which was the one block still unbounded: a list of forty
sources was one item composed whole in the frame it scrolled into. Units,
the live reply's column and peer messages all draw from the same parse,
so warm parses each message once instead of once per block, a delta costs
one background parse instead of two, and a reference definition at the
foot of a message resolves again because nothing is parsed apart from it.

The renderer keeps parsing and providing its environment; MarkdownRoot
wraps that around a piece, and a whole block still goes through its
dispatch with our component table. List items are drawn here, with the
renderer's own paddings so a split list looks like an unsplit one, and
lists inside quotes come to the same code through the table -- the marker
is drawn in one place, which is what a styled bullet would need later.

Found on the way: a heading's words are a child of the heading node, and
the inline builder draws nothing for a node type it does not know, so the
span-link path had been drawing headings empty. LinkedHeading hands it the
content child.

Lint: profileable's shell attribute scoped to API 29 where it exists, and
recordFrames renamed to the composable convention. What remains is the
AGP 9.4.0 notice.

Verified on the emulator against a fixture of every block kind (headings,
nested and ordered lists with a start number, task items, a quote holding
a list, a fence, a rule, a table with a linked cell, a setext heading), a
forty-item list which the render report now shows as per-item units, a
reply streamed live (34 deltas: 34 background reparses, one warm at
settle, no crash), and the older link fixture. ktfmt, build and lint run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 19:34:41 -04:00
irisandClaude Fable 5.1 1656b058bf ai-server --enroll-link: mint one more device's link while the server runs
Prints the enrollment URI, one line on stdout, and exits; the running
server adopts the token the first time that device presents it, via the
spool wg-app-link's enroll module now provides (submodule bumped to
d35c880). This is the server half of enrolling through Dev Updater: its
coming per-component Enroll button runs this command and opens whatever
it prints on the phone, which is what a reinstall -- a signing change, a
new phone -- needs when nobody is at the terminal the QR is printed on.

Verified against the sandbox server: minted while it ran, first request
with the token served and the token moved into config.ron, spool empty,
second request served as an ordinary token. 108 tests, clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:44:17 -04:00
irisandClaude Fable 5.1 68e77c7b37 Let build-apk.sh build either variant: ./build-apk.sh [release|debug]
Dev Updater is gaining build modes declared per component as complete
command lines, so the variant is a positional word and nothing else.
Release stays the default and the only one that signs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:33:50 -04:00
irisandClaude Fable 5.1 8750ff90cb Build the phone's APK as a signed release, and say so in the render report
The phone has been running the debug build: build-apk.sh assembled it, and
nothing in the app or its report said which build a frame time came from.
A debuggable build runs Compose at a fraction of release speed, so the
tuning so far was measured against the wrong number. On the emulator, the
same fixture and gestures: measure 1.4ms mean / 14.7ms worst on debug,
0.8ms / 6.6ms on release.

build-apk.sh now assembles the release variant, signed with a key it
generates once under ~/.config/ai-app (beside the pinned CA, outside any
checkout). The report's header names the build. The one native library is
declared kept-with-symbols so packaging stops warning about an NDK the
build does not need.

Also: ui-sandbox.sh keep now keeps the config too. The server appends
spawned sessions and enrolled tokens to it, so regenerating it left the
transcripts on disk and the registry empty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:21:30 -04:00
irisandClaude Fable 5 63c0bb9e4a Page history by rows, coalescing a reply's deltas server-side
The scroll-up freeze-then-skip was the history pager fighting the transcript's
own storage. A streamed reply is stored one token per event -- hundreds of
AssistantText events for one message -- but a page was counted in events, so
on a delta-heavy conversation a page was a fraction of one row: the opening
80-event load was less than a screen, "scroll up a bit" hit the unloaded
boundary at once, and each page the client did fetch cost a 400-event fold
(hundreds of thousands of list copies) that landed as one jarring insertion.

The server now joins each run of consecutive AssistantText deltas into the one
event the client's fold makes of it, and counts a page's limit in these
coalesced rows -- so a page is a page of the screen whatever the delta density.
Measured against a 3,500-event / 100-row echo session on the emulator: a raw
limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns
five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms
and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100
row-events instead of 3,500 token-events. No duplicate keys; the first reply
still reconstructs whole from token zero, so healSplitMessage welds the raw
newest window to the coalesced older pages exactly as before.

Coalescing is opt-in per request (`?coalesce=true`) and applied only to older
pages (`before` set): the newest window keeps real seqs because the live stream
resumes from the newest seq the phone applied, and a coalesced newest event
would hide the deltas after its first seq and replay them. The anchor-restore
path also stays raw -- it counts events to reach a known seq, which a page
measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore
span and its cushion stay in events.

Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping
sessions, so a fixture that costs minutes to build (a long delta-heavy
transcript) survives a server rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 21:43:18 -04:00
irisandClaude Fable 5 7a48f8ff1f Split the newest reply once its turn ends, and make the UI harness reusable
The transcript's remaining lag was the newest assistant reply: transcriptUnits
kept the last row whole -- right while it streams (splitting a changing text
is a parse per delta), wrong forever after, so a session that ends on a long
reply drew it as one lazy-list item with every node alive. On a Pixel 9 Pro
XL that was 13.8ms of draw phase a frame, 79% of it the framework's own
per-node bookkeeping, against a 34,996px item.

An AssistantMsg now carries `settled`, folded from the status event that ends
its turn (status changes are transcript events with seqs, so replay settles
the same way), and cleared if a delta ever grows the message again. A settled
newest reply splits like every other. Folding it -- rather than reading the
screen's status -- routes the resplit through the held-events gate, so it can
only happen at the newest end while pinned, never under a reader. The
"session is working" predicate now lives once, in sessionWorking().

Measured on the emulator, same session and gestures, a 43KB reply as the
last row: draw phase 3.92ms -> 1.20ms per frame, framework share 3.07ms
(78%) -> 0.54ms (45%), worst single measure 82.5ms -> 9.1ms. The report's
"on screen" line went from one 60,674px AssistantMsg to five blocks of
95-846px. A live streamed turn settles and splits the moment it goes idle.

The harness half, asked for by Bryan: ui-sandbox.sh now derives its port and
root from the checkout name (two checkouts' sandboxes cannot reach each
other), keeps its token in ~/.config/ai-app/sandbox-token and salvages
enrolled device tokens across restarts (enrol the emulator once, ever), and
gained the driving verbs every UI session was re-inventing in /tmp: spawn,
send (text or @file), api. transcript-bench.sh is the standard
scroll-and-report measurement. AGENTS.md documents all of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:19:19 -04:00
iris 6d12388063 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-01 01:20:46 -04:00
irisandClaude Opus 5 465645cefb Draw a peer message above the turn it started, not below it
The live Claude Code path only learns a turn was another agent's when
the turn ends -- the whole of the message arrives as an `origin` object
on the `result` -- so the note was appended after everything it caused,
and the transcript showed the answer above the question.

It cannot be recorded in place: by the time anyone knows, the reply is
already written, and the transcript is append-only. So the event carries
where it belongs instead. `PeerMessage` gains `turnStart`, the seq of
the status that opened its turn, stamped by the pump -- the only thing
that knows a seq and the only thing that sees every driver's turns. The
phone gives the note that seq, so it sorts into place rather than being
drawn out of order at the end. A status draws no row, so there is
nothing for it to collide with and the list stays sorted, which the
scroll anchor and paging both depend on.

Absent where there is nothing to correct: a message replayed out of a
session file by `import` is already in the right place, and one that
opened no turn has no turn to sit above. Both stay where they arrive.

The echo driver gets `/peer-turn` for the live shape, beside `/peer` for
the in-place one. Verified on the emulator both ways, live and on
replay, plus an ordinary `/tools` turn to confirm the run grouping the
insertion cuts through is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:20:38 -04:00
iris 28902ac834 Read the keyboard's inset in the layout phase again, not in composition
Reported: opening the keyboard lags more than it used to, and the scroll
area lags behind the rest of the UI vertically until the keyboard is fully
up.

Both come from the shape of the previous commit's fix rather than from what
it was fixing. Coercing the stuck-open animated inset to zero is right, but
it was written as a bottom padding computed in `SessionScreen`'s body --
`padding(bottom = ... + imeInsets.getBottom(this).toDp())` -- and reading
the inset there subscribes the whole composable to a value the platform
rewrites every frame of the keyboard's animation. That is exactly what the
comment above the box says the arrangement exists to avoid: the transcript
box was meant to be the whole of what a keyboard frame re-measures, with
nothing recomposed at all.

Measured on the emulator with the debug button's counters, over one
keyboard open on an idle session: `session screen recomposed` 16 before,
1 after -- the one being `isImeVisible` flipping, which is the recomposition
the guard actually needs. The per-frame layout work either side is
unchanged (17 measures of the transcript, ~0.6ms each), because that is the
work the keyboard is supposed to cost.

The second symptom is the same cause seen from the other end. The composer
is moved by a `graphicsLayer` block, which re-reads the inset in the draw
phase of the frame it changed; the transcript's padding was reading it in
composition, so the two only stayed together while that recomposition kept
landing inside the frame. `imePadding` reads it in the layout phase of the
same frame, which is where it was before and where the composer can be
followed from by construction.

`isImeVisible` still does the correcting -- the modifier is dropped rather
than the inset zeroed, which is the same coercion by a different route, so
a callback starved of its `onEnd` still cannot leave the composer floating.

Verified by tracking the two against each other frame by frame, from a
screen recording rather than from uiautomator, whose bounds do not update
per frame for a layer translation: the purple outline of the message field
and the last message bubble both move -820px over the ~150ms the keyboard
takes, and are within the 2px measurement floor of each other on every one
of the ten frames in between. Format, compile and lint are clean.
2026-09-01 00:51:15 -04:00
iris 858b4148ad Stop the composer sticking above the bottom of the screen after the keyboard closes
Reported: closing the keyboard on purpose, while a reply was streaming,
left the composer floating above the bottom of the screen for the rest of
the session -- a bar of background colour under it, nothing that closed it.

The composer's position and the transcript's bottom padding are both driven
by the raw, animated `WindowInsets.ime` value, read inside a `graphicsLayer`
block specifically so a keyboard frame invalidates layer properties only
rather than recomposing the whole screen (see the layout note above it).
That value is carried by a `WindowInsetsAnimationCallback`, and a callback
interrupted mid-flight leaves whatever it was carrying frozen at its last
value with nothing left to correct it -- no further keyboard movement is
coming to fire the callback again. A streaming reply invalidates the view
every frame, which is exactly the condition known to starve a running
callback of its `onEnd`, and that is the "actively responding sessions"
correlate in the report.

`WindowInsets.isImeVisible` doesn't share that failure mode: it is set once,
from the platform's own start/end of the transition, over a different path
(`onApplyWindowInsets` rather than the animation callback) -- so it cannot
get stuck mid-animation the way the interpolated value can. Read once per
keyboard toggle and used to force both the composer's translation and the
transcript's reserved padding back to exactly zero the moment the platform
says the keyboard is gone, whatever the animated value still claims.

Checked on the emulator with an actively streaming echo session: opened the
keyboard, closed it with the system back gesture while the reply kept
growing, and the composer settled flush at the bottom with the transcript
filling the freed space, both immediately and after the keyboard was
reopened and closed again.
2026-09-01 00:26:19 -04:00
iris deb908034c Move a session to another working directory
`POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A
working directory is settled when the process is spawned -- the CLI is
launched with it as its cwd and there is no control request that changes one
-- so this records the new one and ends the process that is in the old one.
It does not start a replacement, and the field says so in a line beside it:
a session with no process starts on the next message or on Start, which is
this app's rule for that everywhere else, and "usually restarts" is a worse
control than "always stops".

The path is checked against the session's own machine and refused if it is
not there. The spawn path corrects instead of refusing, because it is
resuming a directory the *machine* recorded and that can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here would surface much later as a session that would not
start, with nothing pointing at the typo. The refusal names the machine and
the path, and is drawn under the field it is about.

Nothing of Claude Code's own is moved, and that is measured rather than
assumed: on CLI 2.1.237, `claude --resume <id>` finds a session from any
working directory -- an id that does not exist answers "No conversation
found with session ID", and a real one resumed from an unrelated directory
did not. So the conversation continues in the new place with nothing
relocated. Doing otherwise would mean reproducing a rule this app cannot
see the whole of; PLAN.md records what that rule is, for whoever tries.

Found while checking it: `SessionInfo.cwd` came from the snapshot a session
launched with, so a moved session went on reporting its *old* directory for
as long as its process lived -- a dialog showing a directory the next launch
would not use, with nothing saying so. It is read from the config where the
row is built now, the same way `setup_name` already was, and for the reason
already written above `setup_name`: only the manager holds the config, and
both of these change under a running session.

Checked end to end on the emulator against a session whose process really
does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the
dialog showed the new path immediately rather than after a restart, and a
directory that is not there and a relative path were both refused with the
session left exactly as it was.
2026-08-31 23:16:34 -04:00
iris 6236f0d5bd Show a message another agent sent, on a session this server is running
Peer messages were only ever produced by the *import* path, reading them out
of the CLI's own session file. A message another agent sent to a session
this server was driving appeared nowhere, so the session started working on
something nobody on the phone had asked for and there was nothing on screen
to explain it.

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

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

Both halves tested: the real record shape, and an ordinary result carrying
no `origin` -- which is the half that decides whether the check is a check.
Four ordinary results on a real session's stdout had none between them.
2026-08-31 23:02:49 -04:00
iris fe6a36bde4 Page back at all, and merge the run the boundary fell through
Two defects on the same path, the second found while trying to reproduce
the first. Both are invisible against a loopback server and both show up at
`--delay 150`, which is what a phone over the tunnel actually costs.

**A run of tool calls came back as two groups.** `joinPages` heals three
things across a page boundary -- a message cut in half, a call separated
from its result, and the *run* a group is named after -- but the third only
ran on the path where a split call had been found. A boundary landing
cleanly between two finished calls, which is most of them, went straight to
concatenation and left the older page's calls under the name they were
folded with. On screen, one run of twelve drawn as "Called 7 tools" and
"Called 5 tools", with the seam wherever the reader happened to have paged.
The two early returns were an optimisation on a list the size of one page,
and what they saved was the work.

**And nothing older loaded at all.** The history pager fires on the first
layout, before a single event has arrived: `moreHistory` starts true, so the
spinner is in the list, so `visibleItemsInfo` is not empty, and with no
units loaded the room ahead adds up to zero. It then asked for the events
`before = 0` -- the ones before the first one, which is none -- and an empty
page is precisely how this code is told it has reached the start of the
conversation. So `moreHistory` latched false, racing the opening page's own
write of true, and a session that lost the race stopped one page from its
newest end with no spinner and nothing on screen to say why. Guarded inside
`loadOlderPage`, because it is a fact about the question rather than about
who asked: the post-open fetch reaches it too, on the path where the opening
page failed and left `oldestSeq` unset.

Checked both ways round on the emulator, with the boundary placed on
purpose (the opening page is 80 events, so it is a matter of counting back
from the newest): 7 + 5 without the join fix, one group of 12 with it. And
the case the change had no reason to touch still holds -- a boundary that
*does* split a call, which is the path that always worked, and one through a
streamed reply, which `healSplitMessage` owns and this does not go near.
2026-08-31 22:56:23 -04:00
iris bfaf5e6f38 Keep the image on screen when its tool call joins a group
A `Read` that returns an image is a row of one call, and the moment the
session makes its next call the two become a group -- which is a different
composable in a different part of the tree, so the old subtree goes and
everything it remembered goes with it. The full-screen viewer was inside
that subtree, so somebody looking at a screenshot was thrown back to the
transcript because the session carried on working. A page of history
landing does the same thing to the same row.

What is open is a property of the screen rather than of whichever row
happened to draw the thumbnail, so it is held there now and drawn beside
the other two dialogs. Nothing that happens to rows can reach it.

The cost is one fetch when it opens, since the thumbnail's decoded bitmap
belongs to a row this no longer goes through. Paid deliberately rather than
plumbed around: it is one request for a picture somebody asked to see, and
the viewer draws the same two empty states the thumbnail does -- still
coming, and never coming -- which it previously could not have, since it
only ever opened on a bitmap already in hand.

`/tools n gap` now puts a screenshot on its first call, so the case is
reproducible rather than argued about: that command already existed to make
a run *grow* while somebody watches, and the image is what made growing
matter. Checked on the emulator with `/tools 3 30` -- opened the image on
the lone call, and it was still open a minute later with the row by then
inside a group of three, and back returned to the transcript rather than
leaving the app.
2026-08-31 22:41:04 -04:00
iris ed88bdb31f Mark the answer on the question, and close the notes that are not turns
Four things the transcript and the composer said badly.

**An answered question threw away the question.** It collapsed into
"Answered: Deny", which does not say that Allow was the alternative -- and
whether a tool was allowed or refused is what a reader comes back to that
row for. The options stay now and the one that was taken is marked, in the
same purple border that says "picked" while the question is still open, so
it is one appearance learned once rather than two renderings of one thing.
The buttons are disabled rather than removed, and state their own border
and label colour, because Material dims a disabled button's and that would
have taken the mark with it. Both places got it: the question card, and the
permission ask on a tool row, which had the same line.

An answer typed into **Other** matches no option, so nothing could mark it.
That one is still written out -- it is the state the marking cannot say.

**Memory notes were open.** A `<cc-memory>` note is not part of what was
said to the reader, it is a note about where a claim came from, and left
open it breaks a reply in half around a card. Closed like a tool call and a
peer message, with the file it came from still visible, since that is what
somebody scanning for "why does it think that" is looking for. Open-ness is
the screen's rather than the card's, so a note opened and scrolled past is
still open on the way back.

**Picking a slash command left its own suggestion up.** `/compact` is a
whole command and a prefix of itself, so the list stayed with the one row
already chosen -- something to dismiss, in front of the box it was about to
be sent from.

**A model switch warned when there was nothing to warn about.** The warning
is that a cache is dropped, so it needs there to be one: a session whose
process has exited has nothing holding a cache, and one reporting zero
context is holding nothing. Where the figure is *unknown* the fallback is
what it was -- whether anything has been said -- because unknown is not
nothing, and an import nobody has measured yet is exactly where the
conversation may be enormous.
2026-08-31 22:33:31 -04:00
iris c9d74b63f2 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-08-31 22:20:51 -04:00
iris 82401cd887 Select any of the transcript, and take a queued message back
Two things a reader could not do to what is on screen.

**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.

**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.

The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.

Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
2026-08-31 22:20:47 -04:00
irisandClaude Opus 5 7a8811aab3 Offer a session once when two project folders hold it, and never crash on a repeat
Resuming a Claude Code session from a different working directory makes the
CLI write a second transcript with the same id under that directory's
project folder. This machine has one: 160 KB under `-home-bob-repos-tdep-survey`
and a 614-byte stub under `-home-bob-repos-tdep`. Everything downstream
addresses a session by id -- `--resume` takes it, the delete glob resolves
it, the in-flight registry is keyed on it -- so two rows sharing an id are
two rows no operation can tell apart, and the phone keys its list on it, so
scrolling to them closed the app on Compose's duplicate-key throw.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:27:20 -04:00
iris 3c159fa1e1 Run imports and deletes on the server, and say so on an event
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.

So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.

Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.

The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.

Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.

The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
2026-08-31 21:05:33 -04:00
iris b172c464ea ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them.
The server spawns and adopts CLI processes, normalises everything they emit
into one event model, keeps the transcript, and serves it over pinned TLS on
a WireGuard interface; the phone streams that, replies, sends images, and
imports conversations the machine already has.

`AGENTS.md` is the working guide -- what runs where, what has been measured,
and the faults that were expensive to find. `PLAN.md` is the design record.

History before this point was squashed away. It was a personal project's
running commentary and carried a name and a couple of machine paths that
have no business in a public repository; the tree is what mattered and the
tree is here.
2026-08-31 20:29:07 -04:00