777 lines
48 KiB
Markdown
777 lines
48 KiB
Markdown
# ai-app
|
||
|
||
A phone interface to AI coding sessions (Claude Code and llama.cpp),
|
||
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||
between them.
|
||
|
||
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||
date, its rationale, and what was rejected. Read it before changing anything
|
||
structural, and update it in place when a decision changes rather than
|
||
letting this file and the plan become two versions of the truth. This file is
|
||
the working notes layer: layout, commands, rigs, and things that have bitten.
|
||
The design and working documents live under `docs/` — everything except this
|
||
file and `CLAUDE.md`, which stay at the root because that is where Claude
|
||
Code and other agent harnesses look for them.
|
||
|
||
The central design point, worth not undoing by accident: **a session is a
|
||
child process, translated into one common event model.** A new session type
|
||
is a new driver — never a session-type branch in shared code (routes,
|
||
transcript, app screens).
|
||
|
||
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||
and a planned desktop app share almost all of their code.** Screens,
|
||
widgets, folding, paging, config and the network client live in
|
||
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
|
||
and `src/desktop` are thin entry points that own only what the platform
|
||
forces (JNI and the IME on one side, winit and argv on the other). The two
|
||
*layouts* will differ, to suit a phone's screen and a finger against a
|
||
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||
button, a text field, a list, a card) and the styling (colours, spacing,
|
||
type) are one implementation with no per-platform copy. Anything that could
|
||
work on both goes in `ui` the first time it is written, and a platform
|
||
module growing a widget or a colour is a defect to move, not a convenience
|
||
to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
|
||
|
||
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
|
||
nothing else.** Nothing in it may know about a session, a transcript, a
|
||
setup or a server; anything that does belongs in `app-rust/`, and the
|
||
dependency runs one way only. The port's project code is **one crate**
|
||
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
|
||
"One app crate" for what forced each of the splits that were removed and
|
||
the two that remain.
|
||
|
||
## Layout
|
||
|
||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||
|
||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||
comment is the HTTP table and the surface's source of truth.
|
||
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
|
||
which is the whole reason it is a crate of its own rather than part of
|
||
either: it is the contract between them, so the two agree by construction.
|
||
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
|
||
client` is everything with no UI in it (the REST and SSE clients, the
|
||
transcript cache and fold, the highlighter, the ANSI parser, config and
|
||
the enrolment link); `src/ui` is the screens as iris widget trees;
|
||
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
|
||
is the `android-view` entry point and `android-project/` its Gradle app;
|
||
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
|
||
Features pick which face a build is: `screens` (default) for anything
|
||
that draws, `shell` for the Compose app's bridge, `bench` for P0's
|
||
fixture build. See its `Cargo.toml` header.
|
||
- `iris/` — the UI framework, and **only** the UI framework: `core`,
|
||
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
|
||
and `rig-input`. It must not mention anything this product is about.
|
||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||
the Keystore-sealed token.
|
||
- `scripts/` — everything at the root that was neither a program nor a
|
||
document: the three repo-wide shell scripts (`run-tests.sh`,
|
||
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
|
||
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
|
||
profiling rigs), and `xtask/`. **A project's own scripts
|
||
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
|
||
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
|
||
top level sh files").
|
||
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
|
||
convention: an ordinary Rust binary that does build work a shell script
|
||
would otherwise do, run as `cargo xtask apk` **from the repo root**
|
||
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
|
||
the working directory). It packages `app/shellApp` without Gradle
|
||
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
|
||
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
|
||
which is where Dev Updater looks. There is deliberately **no `target/`
|
||
at the repo root** any more: there is no workspace there, and what used
|
||
to be one was only xtask's own scratch space, now in
|
||
`scripts/xtask/target/`.
|
||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
||
and the RON house rules (`format`). Clone with `--recurse-submodules`, or
|
||
`git submodule update --init` in an existing checkout — `server/` will not
|
||
build without it, since it is a path dependency, which is what keeps the two
|
||
projects version-locked to the commit this repo pins. What deliberately did
|
||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||
sessions and setups are what makes this project itself.
|
||
- `docs/` — every design and working document except this file and
|
||
`CLAUDE.md`:
|
||
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||
the opening and stream effects in `SessionScreen.kt`.
|
||
- `docs/TODO.md` — the working list.
|
||
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
|
||
phone's view, and the choices behind the shape.
|
||
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||
framework decision, and the ordered experiments with their pass
|
||
conditions. Read it before touching anything under that branch.
|
||
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
|
||
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
|
||
design, its texture-atlas design, and the design of `app-rust`'s
|
||
`client` module, respectively.
|
||
|
||
**These documents are pruned as the work lands, not appended to
|
||
forever** (Iris, 2026-09-08: *"remove everything that's already done and
|
||
decided… many with checkboxes already ticked off that just fill up
|
||
context"*). A ticked box, a finished experiment and a completed review
|
||
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
|
||
a finished document is removed rather than archived in place. What
|
||
survives is what cannot be cheaply re-derived — measurements, dead ends
|
||
and failed hypotheses, invariants and their reasons, and the design of
|
||
what exists now rather than the route to it.
|
||
|
||
**There is no decisions log and no design log, and one should not be
|
||
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
|
||
2026-09-09 at Iris's instruction: *"I've decided to instead make
|
||
decisions when planning with agents rather than after they do things,
|
||
and they're both too long for me to wanna read, + don't cover all the
|
||
decisions I'll wanna make about the code anyways. I'll just naturally
|
||
run into things for now."* So raise a choice **while planning it with
|
||
her**, when the direction is still cheap to change; otherwise decide it,
|
||
put the reasoning at the code it governs, and carry on. TODO lists are
|
||
still wanted — a list of open work is useful, a list of finished work
|
||
is not.
|
||
- `docs/SCROLL.md` — how anything in iris scrolls: one
|
||
`ScrollController` holds the position, the gesture, the fling and the
|
||
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
|
||
one each through the `Scrollable` trait. Read it before touching
|
||
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
|
||
pans, flings or lays out a long list.
|
||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||
rather than a script kept here), the Compose app and the shell APK, in
|
||
parallel. **It does not publish either benchmark APK.** Phone benchmark
|
||
builds live in the separate `~/repos/ai-app-bench` repository: build here,
|
||
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
|
||
path, then commit and push that repo. Dev Updater pulls the committed APK
|
||
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
|
||
file points at
|
||
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
||
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
||
offer them. Note what deleting the config directory takes with it: the CA
|
||
under `certs`, which is the one-way door. **Stop** on the server card stops
|
||
the server a phone reaches through the tunnel, so on that phone it stays
|
||
down until somebody starts it again; Dev Updater reaches it over its own
|
||
port and is unaffected, which is what makes the button safe to press and
|
||
easy to regret.
|
||
|
||
### Icons
|
||
|
||
**Nerd Fonts glyphs from a committed subset**, not vector assets and not
|
||
ordinary Unicode. `NerdIcons.kt` declares each codepoint and
|
||
`app/build-icon-font.sh` subsets the font; the two lists have to agree,
|
||
because a codepoint in the Kotlin that the script did not subset is a glyph
|
||
that silently isn't there. Rerun the script and commit its output when adding
|
||
one — it needs network access. `md-cog` and `md-refresh` are deliberately the
|
||
same codepoints dev-updater uses and must not drift from it. The subset is
|
||
the **Mono** face, where every glyph is one em square, which is what makes
|
||
two icon buttons the same width without either being given one — and why
|
||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
||
|
||
**The Rust app does the same, from its own subset**:
|
||
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
|
||
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
|
||
with `Family::Icons`. Same rule about the two lists agreeing (there is a
|
||
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
|
||
Design family so an icon means the same thing in both apps. Its subset is
|
||
separate rather than shared because subsetting only what one app draws is
|
||
the point. This is the **only** font iris bundles — body and monospace
|
||
text come from the platform (decided 2026-09-07), and an icon
|
||
is the opposite case: a small closed set of codepoints no system font is
|
||
guaranteed to have.
|
||
|
||
## Checking your work
|
||
|
||
- **Commit completed work.** Once a coherent piece of work has passed its
|
||
relevant checks and has no known major issue or unresolved design decision,
|
||
commit it rather than leaving it in the worktree. Keep independently
|
||
completed slices in separate commits.
|
||
|
||
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
|
||
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
|
||
own suite, which is slower and not about this product. Each workspace
|
||
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
|
||
warning-clean and rustfmt-clean at the defaults — there is no
|
||
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
|
||
UI profiling rig use the rolling nightly channel through per-directory
|
||
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
|
||
- **App**: from `app/`,
|
||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
|
||
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
|
||
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
|
||
type compiles separately and lint has caught real bugs debug alone never
|
||
would (see "Android Lint" below).
|
||
- **Android Lint is not optional and is not run by a build.** It found a
|
||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||
desugaring off) and later a permission check that silently dropped every
|
||
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
|
||
that way, and suppress with `tools:ignore` plus a written reason rather
|
||
than by lowering the bar.
|
||
- Then `./build-apk.sh` for the APK to install on a phone through Dev
|
||
Updater, or `./run-android.sh` to build, install and launch on the
|
||
emulator. **The phone gets the release build**, signed with a key the
|
||
script generates once under `~/.config/ai-app/release.jks` (never in the
|
||
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
|
||
build modes call the script with exactly that word. Dev Updater lists every
|
||
variant under `build/outputs/apk`, so pick `release` there; a phone still
|
||
holding the debug build has to uninstall it first, since the two are signed
|
||
differently.
|
||
- The emulator scripts stay on the debug build. **Never read a frame time
|
||
from one as the app's** — a debuggable build runs Compose at a fraction of
|
||
release speed; the render report says which build it came from.
|
||
|
||
## Running it here
|
||
|
||
- Run the server for development with `--bind 127.0.0.1`. Without it the
|
||
server binds wg0, which exists here but is unreachable from the emulator
|
||
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the token.
|
||
`ai-server --enroll-link` mints one more device's link while the server
|
||
keeps running; the server adopts that token on its first use. It is what
|
||
Dev Updater's Enroll button runs.
|
||
- Point development at a scratch state directory rather than the real one:
|
||
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
|
||
- **The APK pins the CA of the machine that builds it**, read at build time
|
||
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the
|
||
server must have started once on that machine first — the build stops with
|
||
that instruction otherwise — and an APK built in this VM only works against
|
||
a server in this VM.
|
||
- Prefer exercising the server directly over going through the UI:
|
||
`curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
|
||
The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`,
|
||
never in the checkout, so a relative `certs/ca.pem` finds nothing.
|
||
The emulator app reaches it at `https://10.0.2.2:8443`; enroll with
|
||
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
|
||
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
||
phone's requests take tens to hundreds of milliseconds, and several faults
|
||
live entirely in what the app does *while* one is outstanding. On a
|
||
loopback server those windows close before anything can be observed, so the
|
||
bug looks like it is not there.
|
||
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
|
||
`after` and what came back, and logs each SSE subscriber's cursor and
|
||
whether it was continued or reset (`stream backlog:`). That is the only
|
||
place "how far had this phone fallen behind" is answerable — the app sees a
|
||
window arrive and cannot tell.
|
||
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
||
network namespaces inside one machine and drives the server through it — a
|
||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||
involved. That is how to verify the wg0-only posture.
|
||
|
||
## The rigs
|
||
|
||
Each exists because something was invisible without it.
|
||
|
||
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||
and the 2026-09-05 decisions), the phone benchmark gate Iris
|
||
asked for before porting continues: a deterministic, checked-in synthetic
|
||
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
||
this app and iris open with no server, so a frame-time comparison
|
||
measures the renderer rather than the data. `./build-apk.sh bench` builds
|
||
it — own application id (`com.example.aiapp.bench`) and label ("AI
|
||
Sessions bench") so it installs beside a real enrollment rather than
|
||
replacing it. Opening it goes straight to a session screen holding the
|
||
fixture (no enrollment, no permission prompts) with a "Run benchmark"
|
||
control beside "Copy" in session settings: it drives the same scroll loop
|
||
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
|
||
`ui-trace`, but in-process, since a real phone has no usable system
|
||
tracing and no agent can drive one (this-machine-android's skill).
|
||
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
|
||
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
|
||
requests from an in-memory copy of the fixture instead of opening a
|
||
socket — so the fold, the paging and `uniqueItems` under test are the
|
||
screen's real ones, never a shortcut built just for this. The report
|
||
gains a `bench:` section (process CPU time, peak RSS, battery current) on
|
||
every build, empty except when `BenchRun.kt` filled it in.
|
||
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||
and data directory, holding eight invented Claude Code transcripts and a
|
||
`claude` that is two lines of shell. **That isolation is the point**: the
|
||
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||
real agent transcripts, so exercising *delete* against the ordinary server
|
||
deletes somebody's conversation and exercising *import* starts a real
|
||
`--resume` on the owner's account.
|
||
Its port and root derive from the checkout's name, so two checkouts'
|
||
sandboxes cannot reach each other, and its token is generated once into
|
||
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||
start banner prints the command) and stays enrolled. It shares the real TLS
|
||
certificates, because the installed APK pins that CA.
|
||
Driving verbs, so none of this is re-derived per session:
|
||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||
`./ui-sandbox.sh send SID text|@file`, and
|
||
`./ui-sandbox.sh api /path [curl args]`.
|
||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||
enrolment already there — for when the fixture under test was expensive to
|
||
build; plain `start` wipes them, which is right for the list-screen
|
||
fixtures and wrong for that.
|
||
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||
fake CLI slow to start. Both exist because operations that finish in
|
||
milliseconds have states on the way that nothing can observe, and an
|
||
unobservable state is one where broken and working look identical.
|
||
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||
explorer, holding the states otherwise only reachable by finding a real
|
||
machine in one: an empty directory, a name with a tab and one with an
|
||
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||
symlink to a directory and a broken one, a source file per language, and
|
||
the three sizes the limits were measured against (`edit-32k.rs`,
|
||
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||
The explorer's 409 is produced by editing the file on the machine
|
||
(`printf … > file`) between pressing the pencil and pressing save.
|
||
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||
echo driver is the right rig for most things and the wrong one for anything
|
||
whose cost scales with what was actually written: a real reply is longer,
|
||
is real markdown, and carries tool calls whose input and output are
|
||
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||
page of history landing mid-fling threw the reader back to the newest end,
|
||
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||
`-b` takes the biggest conversation on the machine rather than the newest,
|
||
which is what a scrolling test wants; `--stop` takes it down.
|
||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||
own, so the import can only see the copy — importing spawns `claude
|
||
--resume`, and against the real file that is a second CLI writing to a
|
||
conversation somebody may still be in. **A transcript never goes in this
|
||
repository**: they hold whatever was said, read and written in that
|
||
session, and `~/repos` is shared with the host besides.
|
||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||
signal. So adopt, stop, restart and start are all drivable without a real
|
||
`--resume` and without spending a turn on somebody's account. Reach for
|
||
this when what is under test is *whether a process is running*, and for
|
||
`debug-transcript.sh` when it is *what the transcript draws*.
|
||
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||
gesture loop, and prints the app's render report — the same one the in-app
|
||
copy button produces, whose `on screen:` line names what the viewport was
|
||
holding. Compare two runs with the same gestures; the emulator's absolute
|
||
frame times transfer nothing, the report's accounting does. Run it either
|
||
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||
that move first are the worst `record: one block`, the reparse mean while
|
||
streaming, and the draw phase's accounting line.
|
||
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||
resets the report, sends FILE, waits for the transcript to stop growing,
|
||
and prints. Both of those are corrections to a first version that measured
|
||
nothing: a transcript parked further back never redraws while a reply
|
||
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||
for idle answers before the turn has started.
|
||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||
framework, from `atrace` text output with no trace processor needed. It is
|
||
how the cost of a layout node per link was attributed to the framework
|
||
rather than guessed at.
|
||
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
|
||
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
|
||
straight into `android-project/app/src/main/jniLibs/`) and its APK
|
||
(Gradle, from `android-project/`) in one step and verifies the result
|
||
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
|
||
installs it on this checkout's own emulator, taps "Run benchmark" by
|
||
label, and prints the report -- written so the P0
|
||
build/install/tap/read-report cycle stops being retyped by hand each
|
||
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
|
||
`--features` alone decides what is in the `.so`; that is what keeps the
|
||
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
|
||
build is published only by replacing
|
||
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
|
||
and pushing the **bench repository**, not this checkout.
|
||
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
|
||
the commands and what each cannot answer): test at the cheapest one
|
||
that can answer the question. `cd app-rust && cargo test` runs the real
|
||
transcript screen over the bench fixture with **no window, no
|
||
compositor and no GPU** (`iris::harness`), on a clock the test owns and
|
||
a gesture replayed from a `t_ms action x y` file under
|
||
`app-rust/touch/` -- which is how the batched 120Hz
|
||
flick a finger actually makes is testable at all, since a `ui-trace`
|
||
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
|
||
--dir ../app-rust --shot …` opens the same screen in a window at the
|
||
phone's own size and density for looking at, and `--replay FILE` drives
|
||
the same recording into it (`--dir` names the workspace to build in,
|
||
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
|
||
lifecycle and one verification run before a build goes to the phone --
|
||
not for iterating on layout.
|
||
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
|
||
a crate of their own so a rig's dependencies stay out of the app's
|
||
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
|
||
dependencies and such don't get mixed"*). Run either from that
|
||
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||
neither runs them nor can fail on them, and both need **release or the
|
||
numbers mean nothing**.
|
||
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
|
||
--nocapture`. Two runs: a fling over the bench
|
||
fixture eight times out and back, and a reply streaming into it one
|
||
event at a time. Text shaping dominates, which is why the profile is
|
||
meaningless unoptimised. It cannot answer anything about the GPU, the
|
||
swapchain or the phone's own clock.
|
||
|
||
What it established on 2026-09-09, worth not re-deriving. A **fling**
|
||
is not CPU-bound: only about one frame in six lays anything out (the
|
||
rest are moved on the GPU through `move_offsets`), and the
|
||
multi-millisecond spikes are all in the *first* pass over a stretch of
|
||
transcript -- every later pass over the same rows is p99 0.26ms. A
|
||
**streamed event** is, and it is not where it looks: folding the event
|
||
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
|
||
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
|
||
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
|
||
bitten"; measuring it is what ruled it out.) That frame is one
|
||
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
|
||
is 14,888 characters in a single block** -- against a largest-ever
|
||
1,580 across 7,706 blocks of real replies. So the stream phase's number
|
||
is a property of the fixture, not of streaming; docs/RUST.md's
|
||
"Incremental text" has the measurements and why parley cannot help.
|
||
|
||
The last two runs (`where_a_streamed_deltas_cost_is`,
|
||
`what_the_fixture_streams`) exist to keep that answerable: what a delta
|
||
costs to re-split and re-compare, and what the fixture actually
|
||
streams.
|
||
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
|
||
of a frame layer 1 builds and never performs, and so the half
|
||
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
|
||
array per frame, and the point of the rig is that no two of them alone
|
||
are honest: **floor** (entries whose bytes actually differ, found by
|
||
diffing), **uploaded** (what iris really writes, read from the same
|
||
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
|
||
old code wrote whenever anything changed). A gap between the first two
|
||
is over-marking; one was 122x and invisible until both were printed
|
||
side by side.
|
||
|
||
What it established on 2026-09-09, and what the three optimisations it
|
||
drove were. Uploading the whole arena on any change cost **758 MB over
|
||
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
|
||
frame. Three things were wrong and each is now guarded by this rig:
|
||
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||
the buffer's contents undefined and forced a full rewrite; a redraw
|
||
freed its primitives and pushed new ones, which -- since freed slots
|
||
are only reusable next frame and provisional layout nested -- grew
|
||
the arena to **127,443 slots for 11,569 live primitives**; and nothing
|
||
tracked *which* entries changed. Now: the stream arena is 11,569 slots
|
||
for 11,569 live, and every array uploads within a hair of its floor.
|
||
The CPU half improved with it, since the freeing and renumbering
|
||
went away: a streamed frame is p50 1.39ms, from 2.20ms.
|
||
|
||
The remaining layout cost was then removed at the framework boundary:
|
||
`Painter::set_child_offset` gives a container one retained coordinate slot
|
||
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
||
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
|
||
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
|
||
`Primitives` also cancels dirty marks for provisional writes restored before
|
||
upload, so CPU-only layout states never become GPU work.
|
||
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||
in software -- while its GLES *is* the host's real GPU through virgl at
|
||
ES 3.1, so an ordinary build's runtime fallback lands there by itself
|
||
and nothing should pass `force-gles` to arrange it. The Vulkan path is
|
||
verified on the desktop build and on Iris's phone. Do not boot the
|
||
emulator with SwiftShader Vulkan to "test the Vulkan path": that
|
||
measures a software rasteriser and steers iris away from the one
|
||
hardware-accelerated backend it has there. Every run says which adapter
|
||
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
|
||
that line before reading a number.
|
||
|
||
### Driving the UI
|
||
|
||
**No script that drives this app's UI presses a coordinate.** Every control
|
||
is found by the name it already carries for assistive technology —
|
||
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||
against the screen at the moment of the gesture and fails the whole run when
|
||
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||
coordinate is a position measured once by hand, and anything that moves the
|
||
control makes the tap land on whatever now sits there — the bench then
|
||
reports a number that was never measured, which reads exactly like a result.
|
||
Both bench scripts pressed the render report at `tap 723 205` until that
|
||
button moved into the session settings dialog on 2026-09-03. The check that
|
||
none has crept back:
|
||
|
||
grep -n "tap [0-9]" app/*.sh
|
||
|
||
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||
is a distance rather than a control.
|
||
|
||
**Two traps in the emulator bench loop**, each of which cost a run.
|
||
`adb shell pm clear` removes the enrolment and the notification permission
|
||
along with the saved anchors, so the next run measures a permission dialog —
|
||
re-enrol with the command `ui-sandbox.sh` prints, and
|
||
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||
so the only way two builds start a scroll from the same place is a *fresh
|
||
session for each*.
|
||
|
||
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||
name` prints, never a name typed out here, since this file is the same in
|
||
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||
to every attached device; when it refuses, say which device you mean at the
|
||
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||
|
||
### Testing llama.cpp and ssh here
|
||
|
||
The prebuilt CPU llama.cpp lives outside the repo at
|
||
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
|
||
own directory on `LD_LIBRARY_PATH`, so start the server as
|
||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
||
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||
broken driver — `llama-cli` produces the same from the file directly, which
|
||
is how to tell the two apart in a hurry.
|
||
|
||
There is no second machine, so **ssh this VM to itself**: generate a
|
||
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
|
||
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
|
||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
|
||
nothing real. Point a provider's `command` at something harmless like
|
||
`/bin/echo` rather than at `claude`: the transport is what is under test, the
|
||
process exiting immediately is the signal, and it costs no tokens. **Take the
|
||
key back out afterwards.** The remote login shell here is **fish**; the
|
||
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||
both, but that is luck rather than design, and a shell that is neither is the
|
||
thing to suspect first if a remote spawn ever mangles an argument.
|
||
|
||
## Where things run (host vs this VM)
|
||
|
||
This checkout runs in a VM while production runs on its host:
|
||
|
||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||
address the phone can reach is, and where WireGuard terminates.
|
||
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||
- **The tunnel and the real phone can never terminate in the VM**, because
|
||
nothing outside can open a connection into it. Phone bring-up is host work.
|
||
- `wg0` (10.66.0.1) exists in this VM too, so the production path is
|
||
exercisable during development. It has no reachable peer and does not need
|
||
one — but with no `--bind` the emulator cannot reach the server.
|
||
- **The `claude` CLI is only in the VM, so from the host it is a remote.**
|
||
The backend reaches it as it would any other machine.
|
||
- Starting the server in the VM makes a separate throwaway dev CA. **Never
|
||
install a build pinning that on the real phone.**
|
||
|
||
## Sessions outlive the backend
|
||
|
||
Since 2026-08-29 a session's process is deliberately left running when
|
||
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||
day to day:
|
||
|
||
- **Stopping the server no longer stops the sessions.** After `pkill
|
||
ai-server` the `claude` processes are still there, on purpose
|
||
(`reattaching to the claude-cli it left running` in the log). To end one,
|
||
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
|
||
and `/start` brings the process back on the same conversation — or delete
|
||
the session, which ends the conversation too.
|
||
- **A message or a command sent to a stopped session starts it**, so the
|
||
Start button is for when you want a process and nothing to say to it yet.
|
||
- **A backend start adopts and starts nothing.** If you are looking for a
|
||
stopped session's process after a restart, there is deliberately none.
|
||
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
|
||
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
|
||
keep what a development server spawns. The flag decides only what **new**
|
||
sessions are marked as; what happens on the way out is decided by the
|
||
**mark**.
|
||
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
|
||
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
|
||
in `process.json`; removing either by hand while the session is live loses
|
||
output or replays it.
|
||
|
||
## Importing
|
||
|
||
The import list reports each session's **size as well as its line count**,
|
||
because the two disagree in the way that matters: these transcripts embed
|
||
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||
count tells you what continuing a session will cost. Shown, not warned about;
|
||
importing a large session is a choice somebody is entitled to make.
|
||
|
||
**Never import a Claude Code session that is open in a terminal.** The app
|
||
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||
than a warning.
|
||
|
||
**One Claude Code session id can name two files, and the listing offers it
|
||
once.** Resuming from a different working directory makes the CLI write a
|
||
second transcript with the same id under that directory's project folder — an
|
||
ordinary state of a machine, not corruption. Everything downstream addresses
|
||
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||
the copy with the most lines, because the other is usually a few-hundred-byte
|
||
stub and is often the *newer* of the two, so recency is the wrong key.
|
||
Deleting removes every copy rather than the first, or the row came back after
|
||
a delete that reported success. The phone's half is `uniqueItems`, which
|
||
every list keyed on a server-chosen id goes through: a repeat there must
|
||
never be able to close the app, whatever produced it.
|
||
|
||
**Deleting a session offers to take the machine's own transcript with it** —
|
||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||
confirmation, and only where the driver keeps a record of its own
|
||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||
because leaving that copy is what makes an ordinary delete recoverable — and
|
||
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||
since the sentence promising the conversation "should still be there to
|
||
import again" is exactly the one the switch makes false. The server deletes
|
||
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||
where it was instead of half-deleted.
|
||
|
||
## Shared appearance
|
||
|
||
- **A row something is happening to is dimmed, drained of colour, and says
|
||
which operation in a word** — `BusyItem`, used by both the session list and
|
||
the import list so the appearance is learned once. The word rather than a
|
||
bare spinner because "deleting" and "importing" differ in kind. It does
|
||
**not** make the row inert: the caller disables its own click handler while
|
||
it passes a label. An overlay consuming pointer events was tried and
|
||
swallowed the drag along with the tap, so a list could not be scrolled
|
||
while anything in it was busy.
|
||
|
||
## Things that have bitten
|
||
|
||
Project-specific only; keep cross-project machine notes out of this file.
|
||
|
||
- **tracing caches callsite interest process-wide.** A test that hits a
|
||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||
for a concurrent test that captures logs (flaky "nothing was logged"
|
||
failures). Keep every exercise of a logging code path under the one
|
||
capturing subscriber — that is why the auth middleware has a single
|
||
combined gating+logging test.
|
||
- **The composer can get stuck floating above the bottom of the screen after
|
||
the keyboard closes, while a reply is streaming.** 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, to avoid
|
||
recomposing the whole screen every frame of the keyboard's animation. That
|
||
animation 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. A streaming reply invalidates the
|
||
view every frame, which is exactly the condition known to starve that
|
||
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
|
||
failure mode — it is set once, from the platform's own start/end of the
|
||
transition over a different path — so it is read once per keyboard toggle
|
||
and used to force both places back to zero.
|
||
**The guard is a boolean; the inset itself must never be read in the
|
||
composable body.** That correction first shipped as a `padding(bottom = …
|
||
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
|
||
that changes every frame: measured at **16 full recompositions of
|
||
`SessionScreen` per keyboard open, against 1**. It is
|
||
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
|
||
`imePadding` reads the inset in the layout phase, and dropping the modifier
|
||
is the same coercion to zero the boolean was added for. The counter to
|
||
check is `session screen recomposed` in the debug report, which should move
|
||
by one across a keyboard open, not by the number of frames it took.
|
||
- **The keyboard pans the window unless the activity opts into resize.**
|
||
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
|
||
slides the whole window up (top bar off screen) instead of resizing —
|
||
`imePadding()` alone does not fix it and the transcript looks empty.
|
||
- **A PEM constant must start at the opening quotes.** A generated
|
||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its
|
||
preamble sniff, so it tries DER instead and fails at runtime with
|
||
`ASN.1 … DECODE_ERROR` — nowhere near the code that produced it.
|
||
- **ZXing only looks for a dark code on a light ground.** The enrollment QR
|
||
is block characters in the terminal's foreground colour, so a dark-themed
|
||
terminal renders it as a negative and the in-app scanner silently never
|
||
matches — while the phone's own camera app, which tries both, does. The
|
||
scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and
|
||
inverted frames; keep it that way rather than making the server dictate the
|
||
colours.
|
||
- **`serde_json`'s default float parser is not correctly rounded**, so the
|
||
server handed out the same transcript line two different ways: a `ts` of
|
||
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
|
||
stream sent the original. Nothing on screen could show it — a `ts` is drawn
|
||
as a relative time — and what found it was the phone's cache comparing a
|
||
line it held against the server's answer. The `float_roundtrip` feature in
|
||
`server/Cargo.toml` is the fix and
|
||
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
|
||
fails within a second of the feature being dropped.
|
||
- **Resolving one importable session used to list every one of them.**
|
||
`import::delete` and the import seed both called `list`, which reads every
|
||
transcript Claude Code has ever written — measured at 3.7 seconds against
|
||
the 867 MB in this VM, paid once per session in a batch. `import::find`
|
||
takes the same script with one glob narrower: 78ms. Ids are checked
|
||
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
|
||
out of the projects directory.
|
||
- **A transcript page used to cost the whole transcript.** `read_window` read
|
||
and parsed every line and then kept the last `limit` of them, so the work
|
||
was the size of the conversation rather than the size of the answer: one
|
||
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
|
||
took the same 500ms whichever page was asked for. It is a bisection now
|
||
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
|
||
edge of a range is found by parsing one line per halving. Same page,
|
||
~110ms, of which ~20ms is the file scan. The file is still read whole; that
|
||
is where the remaining cost is, and going further means a chunked backwards
|
||
reader.
|
||
- **Paging back has two failures that look like "there is simply no more
|
||
history", and neither says anything on screen.** Both invisible on a
|
||
loopback server and reproducible at `--delay 150`. The pager fires on the
|
||
*first layout*, before any event has arrived — `moreHistory` starts true,
|
||
so the spinner is in the list and `visibleItemsInfo` is not empty — and
|
||
`before = 0` asks for the events before the first one, which is none, which
|
||
is exactly how this code is told it has reached the start. `loadOlderPage`
|
||
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
|
||
path where a *split* call had been found, so a boundary landing cleanly
|
||
between two calls — most of them — left one run of tool calls drawn as two
|
||
groups with the seam wherever the reader happened to have paged.
|
||
Reproducing either takes a boundary placed on purpose: the opening page is
|
||
80 events, so arrange the transcript so that event counts back from the
|
||
newest.
|
||
- **A page is 800 events and a screen is a handful of rows, and the two have
|
||
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
|
||
hundreds of text deltas folded into one. So anything that budgets in rows
|
||
has to measure a screen rather than name a number: the history cushion was
|
||
eight rows, which on a tool-heavy transcript is less than one screenful, so
|
||
the reader hit the end of what was loaded on every swipe and stood there
|
||
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
||
is actually on screen.
|
||
- **Only `fetchTranscript` was off the main thread; the fold was not.**
|
||
`foldEvent` returns a new list per event, so a page is that many copies of
|
||
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
||
run in the middle of the scroll that asked for it. `warm` had the same
|
||
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
||
that wraps the *fetch* and leaves the work done with the result outside it.
|
||
|
||
## Measurements worth not re-taking
|
||
|
||
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||
emulator against a real imported transcript with the server at
|
||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||
is *not* at the floor is the first few seconds after opening a session,
|
||
where every row on the way is being composed for the first time; that is
|
||
inherent to a lazy list and it is why a measurement taken before the screen
|
||
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||
- **The reset path is not reachable by reopening a session.** Measured
|
||
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||
opening page, then one span covering the whole distance. To exercise the
|
||
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||
the app takes the reset on a live connection, clears, refills and carries
|
||
on without reconnecting.
|
||
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||
the launcher while 415 events were produced brought no reconnect at all,
|
||
which is not what the comment above that loop expects, and is most likely
|
||
this emulator being headless rather than the phone's behaviour.
|
||
- **Reopening a cached session costs one request for one event** (the probe),
|
||
and scrolling the whole conversation back costs nothing more; a cold open
|
||
of the same 500-event session is two pages, 100 events. Measured
|
||
2026-09-04 on the emulator against the sandbox.
|
||
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||
28,000-line file because it draws one row per line; the editor is one
|
||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||
screen. If you make the editor faster, that number is what to move.
|
||
docs/EXPLORER.md's "What the measurements said" has the rest.
|