Make the Rust client the sole app
This commit is contained in:
1 parent
a8602c1626
commit
d8bb1699a8
230 files changed
+762
-27300
No files matched your search
@@ -1,776 +1,177 @@
|
||||
# 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.
|
||||
A phone and desktop interface to AI coding sessions. The backend is Rust/Axum;
|
||||
the shared client and UI are Rust, drawn by the in-tree `iris` framework. The
|
||||
Android app uses a thin Java activity and `android-view`; desktop uses winit.
|
||||
|
||||
**`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.
|
||||
`docs/PLAN.md` is the design source of truth. Read it before structural work
|
||||
and update it when a decision changes. Working documents are pruned as work
|
||||
lands: preserve current invariants, measurements, and failed hypotheses, not a
|
||||
chronicle of completed tasks. Do not create a decisions log.
|
||||
|
||||
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).
|
||||
## Architecture
|
||||
|
||||
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.
|
||||
A session is a child process translated by a driver into one common event
|
||||
model. A new session type is a new driver, never a session-type branch in
|
||||
shared routes, transcripts, or screens.
|
||||
|
||||
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.
|
||||
Android and desktop share `app/src/client` and `app/src/ui`. Platform modules
|
||||
own only what the platform forces: JNI, lifecycle, insets and IME on one side;
|
||||
winit and argv on the other. Layouts may differ, but widgets, styling, folding,
|
||||
paging, config, and network logic are shared.
|
||||
|
||||
`iris/` is a UI framework and nothing else. It must not know about sessions,
|
||||
transcripts, setups, or servers. Product code belongs in `app/`, and the
|
||||
dependency runs one way.
|
||||
|
||||
## 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/` — `ai-server`. `routes.rs`'s module comment is the HTTP table.
|
||||
- `event-model/` — the wire contract shared by server and app.
|
||||
- `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui`
|
||||
contains Iris widget trees; `android` and `desktop` are thin hosts.
|
||||
`android-project/` packages the Rust cdylib. The `bench` feature and
|
||||
`bench-fixture/` are retained performance rigs, not a second app.
|
||||
- `iris/` — the framework, proc macro, tabs demo, and input rig.
|
||||
- `scripts/` — repository-wide scripts and independent profiling rigs.
|
||||
- `wg-app-link/` — a git submodule shared with dev-updater. Clone with
|
||||
`--recurse-submodules` or run `git submodule update --init`.
|
||||
- `docs/` — design and working documents.
|
||||
|
||||
- `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.
|
||||
Nerd Font icons are a committed subset. `iris/core/build-icon-font.sh`
|
||||
produces `iris/core/assets/fonts/nerd_icons.ttf`; its codepoints must match
|
||||
`iris/core/src/icon.rs`. Body and monospace fonts come from the platform.
|
||||
|
||||
**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.
|
||||
## Checking work
|
||||
|
||||
**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.
|
||||
Commit each coherent, warning-clean slice and push it.
|
||||
|
||||
### Icons
|
||||
- Whole product: `./scripts/run-tests.sh`.
|
||||
- Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets
|
||||
-- -D warnings && cargo test`.
|
||||
- App: `cd app && cargo fmt --all --check && cargo clippy --all-targets --
|
||||
-D warnings && cargo test`.
|
||||
- Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's
|
||||
emulator, or `./build-apk.sh release` for a phone. The script builds with
|
||||
cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone
|
||||
frame times from a debug emulator build.
|
||||
|
||||
**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.
|
||||
`app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through
|
||||
per-directory toolchain files. `server/` and `event-model/` use stable.
|
||||
|
||||
**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.
|
||||
The release signing key lives at `~/.config/ai-app/release.jks`, never in the
|
||||
checkout. `build-apk.sh` creates it once. Normal builds use application id
|
||||
`com.example.aiapp`; benchmark builds add `.bench` and are built explicitly:
|
||||
|
||||
## Checking your work
|
||||
./build-apk.sh release --features "transcript-screen bench"
|
||||
|
||||
- **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.
|
||||
## Running the server
|
||||
|
||||
- **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.
|
||||
Use `--bind 127.0.0.1` for emulator development. Without it the server binds
|
||||
wg0, which the emulator cannot reach. Use scratch state:
|
||||
|
||||
## Running it here
|
||||
ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \
|
||||
--data-dir /tmp/ai-sessions --port 8444
|
||||
|
||||
- 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 emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints
|
||||
another device link while the server runs. `--delay MS` is important for UI
|
||||
states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug`
|
||||
logs transcript page bounds and SSE catch-up/reset decisions.
|
||||
|
||||
## The rigs
|
||||
Exercise the server directly when possible:
|
||||
|
||||
Each exists because something was invisible without it.
|
||||
curl --cacert ~/.config/ai-app/certs/ca.pem \
|
||||
-H "Authorization: Bearer …" https://127.0.0.1:8443/sessions
|
||||
|
||||
- **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.
|
||||
`./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel
|
||||
between network namespaces and verifies pinned TLS against 10.66.0.1.
|
||||
|
||||
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.
|
||||
## Rigs
|
||||
|
||||
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.
|
||||
- `app/ui-sandbox.sh` runs an isolated delayed server with invented
|
||||
transcripts, a fake CLI, stable enrollment, and a file-explorer fixture.
|
||||
Its HOME and data are disposable; never point import/delete tests at real
|
||||
`~/.claude/projects`.
|
||||
- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption,
|
||||
stop, restart, and process lifetime without using an account or token.
|
||||
- `app/run-bench.sh` installs a benchmark APK on this checkout's emulator,
|
||||
taps its accessibility-labelled control, and prints the report.
|
||||
- `cd app && cargo test` drives the real transcript screen without a window
|
||||
through `iris::harness`; touch recordings live in `app/touch/`.
|
||||
- `iris/run-headless.sh phone --phone --dir ../app --shot …` opens the same
|
||||
screen at phone size. `--replay ../app/touch/flick-120hz.touch` replays a
|
||||
recorded gesture.
|
||||
- `scripts/rigs/ui-profile/tests/frame_profile.rs` measures CPU frame cost;
|
||||
`arena_churn.rs` measures GPU-array upload. Run ignored profiling tests in
|
||||
release mode or the numbers are meaningless.
|
||||
|
||||
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 checked-in benchmark transcript is synthetic. Never put a real transcript
|
||||
in this repository; it contains conversation text, tool input, and file data.
|
||||
|
||||
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.
|
||||
The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while
|
||||
GLES is host-accelerated through virgl. Let Iris's runtime fallback select
|
||||
GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before
|
||||
interpreting a measurement. Vulkan is verified on desktop and a real phone.
|
||||
|
||||
### Driving the UI
|
||||
## Driving Android 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:
|
||||
Read the installed `this-machine-android` skill before using Gradle, adb, an
|
||||
AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it
|
||||
with `emu serial` rather than typing a device name.
|
||||
|
||||
grep -n "tap [0-9]" app/*.sh
|
||||
Scripts tap controls by accessibility label, never by coordinate. Coordinates
|
||||
are allowed for swipes because a swipe describes a distance across a scrolling
|
||||
surface. A coordinate tap can silently hit a different control and turn a
|
||||
failed run into a plausible-looking result.
|
||||
|
||||
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||
is a distance rather than a control.
|
||||
## Host and VM boundary
|
||||
|
||||
**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*.
|
||||
Production `ai-server` runs on the host, where the phone can reach WireGuard.
|
||||
The Claude CLI is in this VM, so the host reaches it as a remote provider.
|
||||
The VM's wg0 is useful for development but has no reachable phone peer. A dev
|
||||
server in the VM creates a throwaway CA; never install an APK enrolled against
|
||||
that CA on the real phone.
|
||||
|
||||
**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 …`.
|
||||
For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that
|
||||
directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because
|
||||
their fluent nonsense resembles a broken integration.
|
||||
|
||||
### Testing llama.cpp and ssh here
|
||||
For SSH transport tests, SSH this VM to itself with a throwaway key and a
|
||||
harmless command. Remove the key afterwards. The remote login shell is fish,
|
||||
so POSIX-quoting assumptions require explicit verification.
|
||||
|
||||
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.
|
||||
## Session invariants
|
||||
|
||||
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.
|
||||
Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes
|
||||
running; restart adopts their process records without starting stopped
|
||||
sessions. Sending a message to a stopped session starts it. Use
|
||||
`--throwaway-sessions` for test-created sessions.
|
||||
|
||||
## Where things run (host vs this VM)
|
||||
Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`,
|
||||
and `stderr.log`. Do not edit or remove them while live: the stdout byte offset
|
||||
in `process.json` prevents replay and loss.
|
||||
|
||||
This checkout runs in a VM while production runs on its host:
|
||||
Never import a Claude Code session open in a terminal. One Claude session id
|
||||
may occur in multiple project directories; import listing deduplicates by id
|
||||
and prefers the copy with more lines, while deletion removes every copy.
|
||||
|
||||
- **`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.**
|
||||
Deleting an app session only deletes the provider's transcript when
|
||||
`deleteForeign=true`. The server deletes the foreign transcript first so an
|
||||
unreachable machine cannot leave a half-deleted session.
|
||||
|
||||
## Sessions outlive the backend
|
||||
## Known traps
|
||||
|
||||
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.
|
||||
- `tracing` caches callsite interest process-wide. Logging tests must install
|
||||
their capturing subscriber before any tested callsite runs.
|
||||
- `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve
|
||||
identical timestamp bytes.
|
||||
- Import lookup must use `import::find`, not list every transcript. Validate
|
||||
ids before putting them in a glob.
|
||||
- Transcript sequence numbers increase, so page edges are found by bisection.
|
||||
Do not replace indexed window reads with whole-transcript parsing.
|
||||
- A page's event count has no fixed relationship to visible rows because
|
||||
deltas and tool calls fold together. History cushions are measured in
|
||||
viewports, not row counts.
|
||||
- Android generic motion is separate from touch. Keep hover, wheel, and mouse
|
||||
button handling in `iris::android`; product UI consumes the same pointer
|
||||
state on desktop and Android.
|
||||
Reference in new issue
Block a user