Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5ed6e398 | ||
|
|
7278a58387 | ||
|
|
386c1c4def | ||
|
|
849c3b599f | ||
|
|
66b3403a71 | ||
|
|
049780fda6 | ||
|
|
78f2fe3b79 | ||
|
|
6ed896f1e1 | ||
|
|
7e7910083c | ||
|
|
df48a334f7 | ||
|
|
cedb18e8c1 | ||
|
|
3b309766d7 | ||
|
|
bd9596d782 | ||
|
|
b7fd18b195 | ||
|
|
942edd6b31 | ||
|
|
c8bfc958ad | ||
|
|
ef788b0405 | ||
|
|
c3c6ab0ecf | ||
|
|
81c30dcda1 | ||
|
|
8c323fc7a9 | ||
|
|
74cda485e5 | ||
|
|
369b8f7e52 | ||
|
|
b660905098 | ||
|
|
bb5ac1a242 | ||
|
|
45f249ae91 | ||
|
|
81ab564a09 | ||
|
|
ac476ab0c9 | ||
|
|
392cc5413d | ||
|
|
03376af446 | ||
|
|
0a2f0eed5f | ||
|
|
cd0229bed6 | ||
|
|
84f978f16d | ||
|
|
ee5bef3686 | ||
|
|
914985b8b2 | ||
|
|
581e07624f | ||
|
|
1b38579b97 | ||
|
|
b86a5dc37a | ||
|
|
463acb28fa | ||
|
|
827a30768c | ||
|
|
cbae7ee8c0 | ||
|
|
a9cfea89e5 | ||
|
|
947ea8ecf2 | ||
|
|
f00a178cf0 | ||
|
|
9bcf0f1a48 | ||
|
|
33b130b6bb | ||
|
|
3d1b1e304d | ||
|
|
06bf1c8f81 | ||
|
|
3f94eeb6d6 | ||
|
|
1c60e78b55 | ||
|
|
8262ceb786 | ||
|
|
9fd21af4e8 | ||
|
|
f0661919bb | ||
|
|
0be15adbee | ||
|
|
1e52b2910c | ||
|
|
036eb375aa | ||
|
|
b9b777acaf | ||
|
|
46831520e3 | ||
|
|
3af2502982 | ||
|
|
59ebd75b46 | ||
|
|
579689cbb8 | ||
|
|
fe25108c51 | ||
|
|
898e6b92d0 | ||
|
|
cad0cbcfbe | ||
|
|
83b113ef0f | ||
|
|
7d9df5d572 | ||
|
|
e9a0f1b9da | ||
|
|
6d765ff6e4 | ||
|
|
559e6c9226 | ||
|
|
76895bc644 | ||
|
|
0b4da64062 | ||
|
|
62cb6c91d5 | ||
|
|
2ff0b13950 | ||
|
|
57e1cec09c | ||
|
|
6226a1cb43 | ||
|
|
22f263ccce | ||
|
|
59965d314f | ||
|
|
e3cca97cdd | ||
|
|
3c6e6778fd | ||
|
|
3c19b5a9bb | ||
|
|
f9c8f640ce | ||
|
|
4c15150338 | ||
|
|
cbdd8493ed | ||
|
|
26fe9895e7 | ||
|
|
b00e89795e | ||
|
|
10ce1a216b | ||
|
|
b507656abd | ||
|
|
4dc3e3d784 | ||
|
|
14dd520719 | ||
|
|
8c88a7e991 | ||
|
|
00538cc19b | ||
|
|
7ee88dfd9c | ||
|
|
6a0202b1b5 | ||
|
|
0862b47f76 | ||
|
|
fd71d876e1 | ||
|
|
9cc52beb09 | ||
|
|
1bbb642973 | ||
|
|
ef1aad8776 | ||
|
|
5711c2568a | ||
|
|
74c07d687a | ||
|
|
13d2d11c2d | ||
|
|
cf10b17c5b | ||
|
|
9fa09b0af1 | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
6bdec6e785 | ||
|
|
4821a02bd3 | ||
|
|
1ff662c7c3 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
127b25e60a |
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.claude/skills/ai-app-rigs
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
|
||||||
# every crate in this repo into one workspace -- they are deliberately
|
|
||||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
|
||||||
# from the repo root runs xtask/src/main.rs directly.
|
|
||||||
[alias]
|
|
||||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
---
|
||||||
|
name: ai-app-rigs
|
||||||
|
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ai-app: rigs, harnesses and measurements
|
||||||
|
|
||||||
|
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
|
||||||
|
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
|
||||||
|
that file cost on every one. Unchanged in the move, and still the only copy.
|
||||||
|
|
||||||
|
## The rigs
|
||||||
|
|
||||||
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
- **`/usage` in an echo session puts up an invented meter**, which is how the
|
||||||
|
rate-limit screens' states are reached without spending quota: `/usage 42`,
|
||||||
|
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
|
||||||
|
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
|
||||||
|
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
|
||||||
|
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||||
|
session meters nothing, which is the ordinary case and draws no bar.
|
||||||
|
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||||
|
`claude_cli` provider's `command` at a script that ordinarily runs
|
||||||
|
`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*. The sandbox's
|
||||||
|
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
|
||||||
|
rejects any code except `sandbox-code`, and exits successfully for that one.
|
||||||
|
- **`/think [seconds]` in an echo session puts up a thinking card**, long
|
||||||
|
enough to watch it spin before it closes with the span it actually took.
|
||||||
|
The rest of the turn is the ordinary echo reply, so it is also the rig for
|
||||||
|
a block and a reply meeting.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**Both are set up here** and need nothing typed. The prebuilt llama.cpp lives
|
||||||
|
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
|
||||||
|
2026-09-19, replacing the CPU one that was there before — and is symlinked as
|
||||||
|
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
|
||||||
|
is what makes **discovery find it over ssh**: `~/.local/bin` is not on the
|
||||||
|
PATH a non-interactive ssh session gets. It resolves its own libraries through
|
||||||
|
`$ORIGIN`, so no `LD_LIBRARY_PATH` is needed.
|
||||||
|
|
||||||
|
Two models are downloaded under `~/.local/share/ai-app/models`:
|
||||||
|
|
||||||
|
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
|
||||||
|
calls tools correctly and is the right rig for the driver's shape. Do not
|
||||||
|
judge *answers* by it — asked for the second line of a file it read from
|
||||||
|
line 2 and then named the third.
|
||||||
|
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
|
||||||
|
12 GB, ~20s to load, and the only one here with a multi-token-prediction
|
||||||
|
head. It is the rig for anything about `loading` being a state of its own,
|
||||||
|
since 20s is long enough to send into.
|
||||||
|
|
||||||
|
- `ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf`,
|
||||||
|
175 MB, plus the `mmproj-…` beside it, downloaded 2026-09-20 as the rig for
|
||||||
|
**vision**: it is the only model here that reads pictures, it loads in
|
||||||
|
seconds on the CPU, and it described a red circle correctly. The pair is
|
||||||
|
also what exercises the projector being found beside the weights, and the
|
||||||
|
projector being kept out of the models a provider offers. Qwen3-0.6B beside
|
||||||
|
it is the other half of that rig -- the model that answers `refused`.
|
||||||
|
|
||||||
|
**A second llama.cpp is installed here, and it is the rig for a custom
|
||||||
|
build.** `~/.local/share/ai-app/llama/prism/` is Prism ML's fork
|
||||||
|
(`prism` branch, `~/repos/llama.cpp-prism`, Vulkan, `cmake --install
|
||||||
|
--prefix`), so discovery finds it as a provider called `llama-cpp-prism`
|
||||||
|
beside the ordinary `llama-cpp`. It is what exercises that mechanism at all,
|
||||||
|
and it serves `prism-ml/Ternary-Bonsai-2-27B-gguf` -- ternary packings stock
|
||||||
|
llama.cpp rejects as unknown types. Rebuild it with
|
||||||
|
`cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON
|
||||||
|
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_RPATH='$ORIGIN/../lib'`, about six
|
||||||
|
minutes at `-j8`.
|
||||||
|
|
||||||
|
**Both of those install flags are load-bearing, and the failure is a session
|
||||||
|
that never becomes ready.** `llama-server` is a 16 KB launcher against
|
||||||
|
`libllama-server-impl.so`, so a build whose libraries it cannot find dies at
|
||||||
|
`exec` with `error while loading shared libraries` -- which reaches the phone
|
||||||
|
as the model never answering. The runpath has to be set, *and* the libraries
|
||||||
|
have to be where it points: `GNUInstallDirs` chooses `lib64` on some
|
||||||
|
distributions (Gentoo's amd64 profiles among them) while the runpath above
|
||||||
|
says `lib`. `readelf -d bin/llama-server | grep RUNPATH` and
|
||||||
|
`ldd bin/llama-server | grep 'not found'` are the two-second check after any
|
||||||
|
install here.
|
||||||
|
|
||||||
|
**Which Bonsai packing runs on the GPU is the backend's question, not the
|
||||||
|
model's.** Measured 2026-09-21 with `llama-bench -p 512 -n 64 -r 2 -fa 1
|
||||||
|
-ngl 99` on the free card:
|
||||||
|
|
||||||
|
| packing | backend | pp512 | tg64 |
|
||||||
|
| --- | --- | ---: | ---: |
|
||||||
|
| `PTQ1_0`, 5.53 GiB | Vulkan | 519 t/s | 7.5 t/s |
|
||||||
|
| `PQ2_0`, 7.21 GiB | **CPU**, 8 cores | unfinished after 9 min | -- |
|
||||||
|
|
||||||
|
The fork's Vulkan port covers `PTQ1_0` only -- shaders, a `mul_mat_vec` and
|
||||||
|
the FWHT included -- so `PQ2_0` has no kernel there and every matmul falls
|
||||||
|
back to the CPU. That reads exactly like a stuck load: the process sits at
|
||||||
|
700% CPU for minutes with the card idle. On CUDA and HIP it is the other way
|
||||||
|
round, since `mmq.cu` guards `PTQ1_0` out of the HIP build (it wants Turing
|
||||||
|
MMA) and leaves `PQ2_0` in. **ROCm cannot be tested in this VM**: there is no
|
||||||
|
`/dev/kfd`, because the GPU here is virtio-gpu rather than a passed-through
|
||||||
|
card.
|
||||||
|
|
||||||
|
7.5 tok/s is the honest speed of that Vulkan kernel, against 42 for the
|
||||||
|
IQ3_S 27B beside it -- smaller weights, slower decode. Nothing is
|
||||||
|
misconfigured; the fork's fast kernels are CUDA and Metal.
|
||||||
|
|
||||||
|
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B 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.
|
||||||
|
|
||||||
|
**The GPU is shared and llama-server dies loudly when it runs out.** A second
|
||||||
|
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
|
||||||
|
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
|
||||||
|
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
|
||||||
|
to test the driver while something else holds the card -- through the app, that
|
||||||
|
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
|
||||||
|
view, and `--models-max` above 1 is how two models come to be loaded at once
|
||||||
|
in the first place.
|
||||||
|
|
||||||
|
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
|
||||||
|
its built-in tools at `GET /tools` and runs one at `POST /tools` with
|
||||||
|
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
|
||||||
|
is drivable with `curl` and no model at all. The Exa MCP server at
|
||||||
|
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
|
||||||
|
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
|
||||||
|
refusal rather than a missing header).
|
||||||
|
|
||||||
|
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||||
|
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||||
|
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||||
|
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||||
|
`identityFile` plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
|
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||||
|
whole rig for "does a remote llama session work", since the far machine is
|
||||||
|
this one and the model file is the same file. For a throwaway machine of your
|
||||||
|
own, 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. 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.
|
||||||
|
|
||||||
|
## 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 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`, currently Claude Code or Codex). 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.
|
||||||
|
|
||||||
|
## Measurements worth not re-taking
|
||||||
|
|
||||||
|
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
|
||||||
|
27B above, decode speed for a 300-token reply, from `llama-server`'s own
|
||||||
|
timings rather than the clock:
|
||||||
|
|
||||||
|
| flags | tok/s |
|
||||||
|
| --- | --- |
|
||||||
|
| plain, any `-np` | 41.5 |
|
||||||
|
| `--spec-type draft-mtp -np 1` | 61.4 |
|
||||||
|
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
|
||||||
|
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
|
||||||
|
|
||||||
|
Draft acceptance is 0.53–0.73 in every case, so the head is working in all
|
||||||
|
of them: what changes is that speculating against a KV cache split four ways
|
||||||
|
is slower than not speculating. A model's preset gets `parallel = 1` unless
|
||||||
|
its settings say otherwise (the machines tab's provider view, since
|
||||||
|
2026-09-19), so this is recorded for whoever next sees MTP look broken or
|
||||||
|
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
|
||||||
|
worth another 7% in a single sample and is deliberately *not* passed — one
|
||||||
|
sample on a virtualised GPU is not a number to hardcode.
|
||||||
|
|
||||||
|
- **Prompt processing is the expensive part of a llama turn here, and decode
|
||||||
|
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
|
||||||
|
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
|
||||||
|
|
||||||
|
| context | decode | prefill of that prompt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 88 | 43.4 tok/s (cold) | 21s |
|
||||||
|
| 1,569 | 55.5 tok/s | (model still warming) |
|
||||||
|
| 6,068 | 53.2 tok/s | 9.5s |
|
||||||
|
| 14,068 | 50.3 tok/s | 22s |
|
||||||
|
|
||||||
|
So a turn on a long conversation spends tens of seconds before the first
|
||||||
|
token, and that is what `SessionStatus::Reading` exists to say. The same
|
||||||
|
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
|
||||||
|
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
|
||||||
|
"it gets slower as the conversation goes on". The figure the app draws is
|
||||||
|
`timings.predicted_per_second`, decode only, so prefill is never mixed into
|
||||||
|
it.
|
||||||
|
|
||||||
|
- **A busy GPU is a model that will not load at all**, not a slow one:
|
||||||
|
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
|
||||||
|
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
|
||||||
|
16.7 in that window before its server died, so a tok/s figure taken while
|
||||||
|
the card is shared says nothing about the model.
|
||||||
|
|
||||||
|
- **Asking for the head when the file has none is fatal**, not ignored:
|
||||||
|
`context type MTP requested but model doesn't contain MTP layers` and the
|
||||||
|
server exits. Without the flag the same file logs `unused tensor
|
||||||
|
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
|
||||||
|
when MTP is silently not happening.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
EXPLORER.md's "What the measurements said" has the rest.
|
||||||
@@ -55,21 +55,4 @@ components: [
|
|||||||
// the terminal the QR would be printed on.
|
// the terminal the QR would be printed on.
|
||||||
enroll: "server/enroll-link.sh",
|
enroll: "server/enroll-link.sh",
|
||||||
),
|
),
|
||||||
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
|
||||||
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
|
||||||
// with the same release key as "app" above so the two can install
|
|
||||||
// over each other -- a separate component, not a mode of "app" above,
|
|
||||||
// because it is a different applicationId (com.example.aiapp.shell)
|
|
||||||
// built by a different tool from different sources. No `cwd`: it
|
|
||||||
// defaults to this checkout's root, which both the `cargo xtask`
|
|
||||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
|
||||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
|
||||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
|
||||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
|
||||||
// both need.
|
|
||||||
Apk(
|
|
||||||
name: "shell",
|
|
||||||
modes: ["release", "debug"],
|
|
||||||
build: "cargo xtask apk",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
-19
@@ -1,20 +1,12 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
app/androidApp/build/
|
app/androidApp/build/
|
||||||
app/shellApp/build/
|
|
||||||
local.properties
|
local.properties
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
event-model/target/
|
|
||||||
client-core/target/
|
|
||||||
android-shell/target/
|
|
||||||
|
|
||||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
|
||||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
|
||||||
app/shellApp/src/main/jniLibs/
|
|
||||||
|
|
||||||
# Server logs from a development run (ai-server.log by convention,
|
# Server logs from a development run (ai-server.log by convention,
|
||||||
# wg-test.log from ./test-wg-tunnel.sh).
|
# wg-test.log from ./test-wg-tunnel.sh).
|
||||||
@@ -29,14 +21,3 @@ certs/
|
|||||||
config.ron
|
config.ron
|
||||||
config.json
|
config.json
|
||||||
sessions/
|
sessions/
|
||||||
|
|
||||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
|
||||||
iris/target/
|
|
||||||
iris/android-app/target/
|
|
||||||
|
|
||||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
|
||||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
|
||||||
# The repo root has no Cargo workspace, so this is xtask's own
|
|
||||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
|
||||||
xtask/target/
|
|
||||||
/target/
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ai-app
|
# ai-app
|
||||||
|
|
||||||
A phone interface to AI coding sessions (Claude Code and llama.cpp),
|
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp),
|
||||||
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
@@ -9,7 +9,15 @@ between them.
|
|||||||
its rationale, and what was rejected. Read it before changing anything
|
its rationale, and what was rejected. Read it before changing anything
|
||||||
structural, and update it in place when a decision changes rather than
|
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
|
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 working notes layer: layout, commands, and things that have bitten.
|
||||||
|
|
||||||
|
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
||||||
|
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
||||||
|
ssh here, how importing behaves, and the measurements not worth re-taking.
|
||||||
|
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
||||||
|
you are actually running one, and this file is sent with every request. Read
|
||||||
|
it before writing or running a benchmark, driving the UI from a script, or
|
||||||
|
touching the import screen.
|
||||||
|
|
||||||
The central design point, worth not undoing by accident: **a session is a
|
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
|
child process, translated into one common event model.** A new session type
|
||||||
@@ -26,10 +34,177 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
|
**A machine's models are served by one shared `llama-server`** (2026-09-19,
|
||||||
|
`session/llama/router.rs`): started with no `-m`, which makes it a
|
||||||
|
**router** — it reads a preset file naming models and their flags, starts a
|
||||||
|
child server per model asked for, and routes by the `model` field in each
|
||||||
|
request. So a session has no process of its own, two sessions on one model
|
||||||
|
share one copy of it in memory, and a backend restart adopts one process
|
||||||
|
rather than one per session. Four things fall out of it and are easy to get
|
||||||
|
wrong again — a session records the router's pid in its own directory as
|
||||||
|
`process::Detail::Shared`, and `process::stop` refuses to signal a `Shared`
|
||||||
|
record, which is what keeps one session ending from unloading everybody's
|
||||||
|
model; **nothing stops a router on its own**, and the only thing that does
|
||||||
|
is the machine's provider view (`POST /machines/{id}/providers/{p}/stop`);
|
||||||
|
how a model is *loaded* is per model on its machine
|
||||||
|
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`) rather than per
|
||||||
|
session, and saving those settings rewrites the preset, which **unloads**
|
||||||
|
that model; and the preset is read back before every edit, because a router
|
||||||
|
adopted from an earlier run is serving sections this process has never seen
|
||||||
|
and rewriting without them unloads those.
|
||||||
|
**A machine can have more than one llama.cpp** (2026-09-21,
|
||||||
|
`machines.rs`): anything at `~/.local/share/ai-app/llama/<name>/llama-server`
|
||||||
|
or `.../<name>/bin/llama-server` is discovered beside the one on PATH and
|
||||||
|
becomes a provider called `llama-cpp-<name>`, with its own router, preset
|
||||||
|
and model settings. That is how a model whose kernels are not upstream is
|
||||||
|
served -- Prism ML's ternary Bonsai is the one here, built from the
|
||||||
|
`prism` branch into `~/.local/share/ai-app/llama/prism` -- without the
|
||||||
|
phone ever naming a command, which is the property this module exists for.
|
||||||
|
Both providers offer the machine's whole models directory, since which
|
||||||
|
build reads which packing is not answerable from the file.
|
||||||
|
**A llama.cpp session runs on its configured machine** (built
|
||||||
|
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
||||||
|
port the server binds *there* and the port that reaches it *here*, and
|
||||||
|
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||||
|
the command. Three things fell out of it and are easy to get wrong again —
|
||||||
|
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
||||||
|
because `llama-server` never reads the stdin whose closing ends a CLI and
|
||||||
|
the same kill left it loaded on the far machine; the model is looked for on
|
||||||
|
the machine that will serve it, so the spawn screen offers
|
||||||
|
`GET /machines/{id}/providers/{p}/models` rather than any list of this
|
||||||
|
backend's own; and the
|
||||||
|
readiness poll watches the process as well as the port, since a model that
|
||||||
|
will not load exits in a second and was being reported as "gave up after
|
||||||
|
300s". See PLAN.md's "Transport" and "llama-server management".
|
||||||
|
**A llama session has tools and runs the loop itself** (2026-09-19):
|
||||||
|
`--tools all` gives the router `llama-server`'s built-in set, which it also
|
||||||
|
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
|
||||||
|
web search comes from an MCP server this backend connects to directly
|
||||||
|
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
|
||||||
|
`mcpServers`). Driving the loop is what makes the permission gate ours:
|
||||||
|
`manual` asks before every call and remembers a tool you answer
|
||||||
|
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
|
||||||
|
folded back out of the transcript. Which tools a *session* offers is a
|
||||||
|
filter applied to those definitions here, not a flag over there: one shared
|
||||||
|
server has one set, and the filter costs no reload (2,181 tokens of prompt
|
||||||
|
with all seven, 698 with none). Three more things fall out of it and are
|
||||||
|
easy to get wrong again — a model change **asks for another model** and
|
||||||
|
stops nothing, since the one being left may be another session's;
|
||||||
|
`parallel = 1` unless that model's settings say otherwise, and it is what
|
||||||
|
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
|
||||||
|
`spec-type = draft-mtp` is conditional on the file actually having a head,
|
||||||
|
because asking for one that is not there makes `llama-server` **exit**.
|
||||||
|
**A llama session takes a picture only where the model natively reads one**
|
||||||
|
(2026-09-20): a multimodal model is loaded with the `mmproj` found beside
|
||||||
|
its weights (overridable per model, `off` included), an attachment rides in
|
||||||
|
the request as an `image_url` data URI, and nothing at all is done for a
|
||||||
|
model without a projector. Four things fall out of it and are easy to get
|
||||||
|
wrong again -- whether a session takes pictures is `/props`'s
|
||||||
|
`modalities.vision` from the loaded server and never a guess from this side,
|
||||||
|
with three states because a loading model has not answered yet
|
||||||
|
(`Images::Unknown` is *offered*, since a control withheld because nobody
|
||||||
|
could ask is missing from sessions that would have taken it); a message
|
||||||
|
carrying an image a model cannot read is **stopped rather than stripped**,
|
||||||
|
refused at the door, at the queue and at the steering boundary, because
|
||||||
|
`llama-server` refuses the whole request over one part and a message sent
|
||||||
|
without its picture is a different message; an earlier turn's image folds
|
||||||
|
into a line of words for a model without vision, so switching models does
|
||||||
|
not end the conversation; and a projector is filtered out of the models a
|
||||||
|
provider *offers*, while staying in the machine's own model list.
|
||||||
|
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
|
||||||
|
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
|
||||||
|
the span the *driver* measured, and the phone draws a card that spins while
|
||||||
|
the block is open and says "Thought for 12.4s" once it is not. The reasoning
|
||||||
|
is deliberately not part of the next prompt (`conversation` ignores it), and
|
||||||
|
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
|
||||||
|
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
|
||||||
|
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
|
||||||
|
measures either, so every other driver sends `None`, and the clock is last so
|
||||||
|
that it does not move when a provider reports fewer of them.
|
||||||
|
**A wait that can be measured says how far along it is** (2026-09-21):
|
||||||
|
`GET /sessions/{id}/progress` answers `{of, fraction, stage?}` and `null`
|
||||||
|
for a session that is not in one -- runtime state, asked for twice a second
|
||||||
|
by the session screen while it is drawing a wait, and deliberately never an
|
||||||
|
event, since a load reports five times a second and every event is a
|
||||||
|
transcript line for ever. The two sources are the router's `/models/sse`
|
||||||
|
stream, which is the **only** place a model's load progress appears (`GET
|
||||||
|
/models` says "loading" and no more), and `prompt_progress` chunks that
|
||||||
|
`"return_progress": true` adds to the generation stream. The sample says
|
||||||
|
which status it measures so it cannot be drawn under another one, and
|
||||||
|
`/loading [seconds] [stages]` / `/reading [seconds]` in an echo session are
|
||||||
|
the rig for the phone's half. **Zero is never reported**, being the absence
|
||||||
|
of a sample rather than a measurement -- which matters because
|
||||||
|
`llama-server` reports a model's load as 0 and then 1 with nothing between
|
||||||
|
(measured 2026-09-21 on both the 0.6B and the 27B), and reports prompt
|
||||||
|
processing once a batch; so the bar usually draws for a long prompt and not
|
||||||
|
for a load.
|
||||||
|
**A turn's wait has two halves and says which** (2026-09-19):
|
||||||
|
`SessionStatus::Loading` is the model coming off disk and
|
||||||
|
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
|
||||||
|
when the request goes out and cleared by the first thing the model says, of
|
||||||
|
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
|
||||||
|
14k), and as `running` it looked exactly like thinking. The phone draws
|
||||||
|
both with the working spinner and its own words, "loading model" and
|
||||||
|
"reading prompt".
|
||||||
|
**Thinking effort is a param, and which levels exist is the model's answer**
|
||||||
|
(2026-09-19): the `thinking` param rides on the request as a chat-template
|
||||||
|
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
|
||||||
|
needs no restart -- and the driver asks the loaded server which levels its
|
||||||
|
template actually takes rather than trusting the offered list, because the
|
||||||
|
27B raises on `high` and answers to `xhigh`. A level it cannot take is
|
||||||
|
dropped and said in the transcript, naming the ones it can.
|
||||||
|
**Every one of those is a default rather than a constant** (2026-09-19):
|
||||||
|
`DriverKind::params` declares what a provider takes — key, label, shape,
|
||||||
|
and whether a change waits for a restart — and the phone renders whatever
|
||||||
|
arrives, on the spawn form and in the session settings dialog. Adding a
|
||||||
|
setting to a driver is one entry in that table and no app change. `tools`
|
||||||
|
is in there too, because the seven built-in definitions are ~1,500 tokens
|
||||||
|
of every prompt, which on a small window is the difference between a usable
|
||||||
|
session and one that overruns. `DriverKind::model_params` is the same table
|
||||||
|
for a provider's **models**, drawn in the machines tab's provider view —
|
||||||
|
the settings that decide how a model is loaded, which belong to the machine
|
||||||
|
because one loaded copy answers every session using it.
|
||||||
|
**A model is downloaded onto the machine that will serve it** (2026-09-19,
|
||||||
|
replacing the fetch this backend used to do onto its own disk, and the
|
||||||
|
Models tab that went with it). `models.rs` writes a script and a detached
|
||||||
|
`curl` runs it *there*; the state of a run is a file beside the partial
|
||||||
|
(`x.gguf.download`), so nothing about it is held in this process — it
|
||||||
|
survives the phone closing, this backend restarting and a second device
|
||||||
|
watching, and `kill -0` at each listing is what stops a machine that was
|
||||||
|
rebooted from leaving a download claiming to be running. The progress is
|
||||||
|
`wc -c` of the partial against the size HuggingFace published, the sha256
|
||||||
|
it publishes is what makes a resume safe, and a finished download is not a
|
||||||
|
state: it is a model, in the list beside the one still going.
|
||||||
|
Codex is one persistent `codex app-server --stdio` process per session; its
|
||||||
|
driver uses native turn steering and interruption, persists the protocol
|
||||||
|
state and thread id, and reads subscription limits through the same CLI
|
||||||
|
protocol.
|
||||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
**Every text field is `LabelledField`** (`Field.kt`): the label is a line
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
above the box rather than a thing floating inside it, and the padding is one
|
||||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
line's worth. Material's outlined field spends the height of three lines to
|
||||||
|
hold one, which on a form of a dozen settings is a screen and a half of
|
||||||
|
scrolling. What is *not* shrunk is the value -- the framing is what was
|
||||||
|
expensive. A field's `hint` is what leaving it blank means, above the box
|
||||||
|
with the label, because inside it is gone the moment anybody types.
|
||||||
|
**Session settings is a screen with two tabs** (`SessionSettingsScreen.kt`),
|
||||||
|
drawn over the session like the file explorer so the session stays composed.
|
||||||
|
The second tab is `ProviderScreen` itself -- the same composable the machines
|
||||||
|
tab opens -- so a provider's settings have two ways in and one
|
||||||
|
implementation; it takes `onBack = null` there, since the screen around it
|
||||||
|
has one.
|
||||||
|
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
|
||||||
|
slides the whole main screen over a session from the left (`MainPanel.kt`)
|
||||||
|
and what it has running beside the turn -- its background tasks over its
|
||||||
|
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
|
||||||
|
the session composed underneath; `MainScreen.kt` the root's three tabs
|
||||||
|
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
|
||||||
|
lazy list, used by the session list's handles -- **the order of that list is
|
||||||
|
the reader's own and nothing sorts it** (`POST /sessions/order`);
|
||||||
|
`MachineModels.kt` the models on one machine
|
||||||
|
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
|
||||||
|
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
|
||||||
|
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||||
the Keystore-sealed token.
|
the Keystore-sealed token.
|
||||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||||
@@ -39,17 +214,23 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
build without it, since it is a path dependency, which is what keeps the two
|
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
|
projects version-locked to the commit this repo pins. What deliberately did
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||||
sessions and setups are what makes this project itself.
|
sessions and machines are what makes this project itself.
|
||||||
|
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||||
|
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||||
|
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||||
|
choices made there that are still awaiting review.
|
||||||
|
**The transcript file itself is readable from the session settings dialog**
|
||||||
|
(2026-09-21): *View raw* opens the file explorer on it, from
|
||||||
|
`transcriptFile` on `GET /sessions/{id}` -- which names the machine *this
|
||||||
|
backend* runs on, not the session's. It is the explorer's third caller and
|
||||||
|
needed no new screen; a transcript past `FILE_LIMIT` (1 MiB) is refused the
|
||||||
|
way any other large file is.
|
||||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
||||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
||||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
||||||
and stream effects in `SessionScreen.kt`.
|
and stream effects in `SessionScreen.kt`.
|
||||||
- `TODO.md` — the working list.
|
- `TODO.md` — the working list.
|
||||||
- `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.
|
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here) and the APK, in parallel. It points at
|
||||||
@@ -142,153 +323,6 @@ two icon buttons the same width without either being given one — and why
|
|||||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
involved. That is how to verify the wg0-only posture.
|
||||||
|
|
||||||
## The rigs
|
|
||||||
|
|
||||||
Each exists because something was invisible without it.
|
|
||||||
|
|
||||||
- **`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.
|
|
||||||
|
|
||||||
### 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)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||||
@@ -297,7 +331,7 @@ means here:
|
|||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
address the phone can reach is, and where WireGuard terminates.
|
||||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
- **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.
|
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||||
@@ -330,47 +364,99 @@ day to day:
|
|||||||
keep what a development server spawns. The flag decides only what **new**
|
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
|
sessions are marked as; what happens on the way out is decided by the
|
||||||
**mark**.
|
**mark**.
|
||||||
|
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
|
||||||
|
included: it belongs to the machine rather than to a session, and a
|
||||||
|
development server that has loaded a model leaves it loaded — gigabytes of
|
||||||
|
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
|
||||||
|
view, or `pkill -f "[l]lama-server"` when testing.
|
||||||
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
|
- 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
|
`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
|
in `process.json`; removing either by hand while the session is live loses
|
||||||
output or replays it.
|
output or replays it.
|
||||||
|
|
||||||
## Importing
|
## Auto-resume
|
||||||
|
|
||||||
The import list reports each session's **size as well as its line count**,
|
**A session switched to it sends itself a message once the account's usage
|
||||||
because the two disagree in the way that matters: these transcripts embed
|
limit lifts** — off by default, per session, in the session settings dialog.
|
||||||
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
PLAN.md's "Auto-resume" is the design; day to day:
|
||||||
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
|
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
||||||
refuses it — see PLAN.md for the incident that made that a refusal rather
|
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
||||||
than a warning.
|
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
||||||
|
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
||||||
|
the reset time the *meter* now gives.
|
||||||
|
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
||||||
|
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
||||||
|
what the meter answers. They are deliberately separate: the two disagreeing
|
||||||
|
is the case the design exists for. `/usage 20` is the limit lifting.
|
||||||
|
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
||||||
|
backend restart. A day after the limit was hit it gives up and says so in
|
||||||
|
the transcript.
|
||||||
|
|
||||||
**One Claude Code session id can name two files, and the listing offers it
|
## A session waiting on its own work
|
||||||
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** —
|
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
|
||||||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
command still running** reports `waiting` rather than `idle` — its own status,
|
||||||
confirmation, and only where the driver keeps a record of its own
|
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
|
||||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
"waiting for a person" and this means the opposite, so it also suppresses the
|
||||||
because leaving that copy is what makes an ordinary delete recoverable — and
|
"finished" notification, which used to arrive at the one moment it was untrue.
|
||||||
the dialog's paragraph is rewritten when it is on rather than appended to,
|
Two things fall out of it and are easy to get wrong again: the queue and the
|
||||||
since the sentence promising the conversation "should still be there to
|
held-command boundary release on **either** end-of-turn status, so a message
|
||||||
import again" is exactly the one the switch makes false. The server deletes
|
sent while a subagent runs is not held until the subagent finishes; and
|
||||||
the machine's copy *first*, so a machine it cannot reach leaves the session
|
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
||||||
where it was instead of half-deleted.
|
written, and the fold uses that same predicate to decide a reply is settled.
|
||||||
|
|
||||||
|
- **Nothing subagent-specific goes in the main agent's transcript** unless a
|
||||||
|
subagent sends it a real message that wakes it — which is the peer path, and
|
||||||
|
already has a row. A row per finished background task was tried and was a
|
||||||
|
screenful of dividers about work nobody was asking after, one of them a whole
|
||||||
|
shell command. A subagent's report is its own transcript's closing text and
|
||||||
|
is read in the subcard.
|
||||||
|
- **A backgrounded command has no subagent, so its report lands in the tool
|
||||||
|
card that launched it** — a `ToolUpdate` against the call's own id, replacing
|
||||||
|
the launch result that says it is still running. `/background [seconds]` in
|
||||||
|
an echo session is that shape end to end.
|
||||||
|
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
|
||||||
|
words. The reply that follows a turn boundary is a **new** message: the fold
|
||||||
|
refuses to grow a settled reply, and without that the two ran together
|
||||||
|
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
|
||||||
|
echo session is the whole rig; the helpers stagger a second apart so each
|
||||||
|
reply is its own.
|
||||||
|
- **Claude's background-task level is authority; two edge sources are the
|
||||||
|
fallback.** Since Claude Code 2.1.261,
|
||||||
|
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
|
||||||
|
a missed ending edge. Its array size is also the measured `backgroundTasks`
|
||||||
|
count exposed on the session row and event stream; the phone draws a nonzero
|
||||||
|
count beside the status rather than deriving one from `waiting` or from the
|
||||||
|
subagent directory. **What those tasks are is `GET /sessions/{id}/background`**,
|
||||||
|
listed in the session's right panel above the subagents: runtime state, so it
|
||||||
|
is never persisted and `null` -- not an empty list -- is what a session with
|
||||||
|
no process answers. **A task is drawn as the command it ran, and tapping it
|
||||||
|
goes to the call that started it** -- both from the transcript rather than
|
||||||
|
from the provider: a driver reports which tool call its task belongs to and
|
||||||
|
`Session::background_tasks` resolves that id into a seq and, for a provider
|
||||||
|
that says nothing (Codex names a terminal by a process id), the command on
|
||||||
|
the call. The phone travels there with `travelTo`, the same journey a
|
||||||
|
reopened session makes to put a reader back where they stopped. An `ambient` task is dropped from both the list and the
|
||||||
|
count, on the CLI's own instruction: a live-update watcher is not activity,
|
||||||
|
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
|
||||||
|
for the current set. Reconcile only between turns or at a result boundary:
|
||||||
|
a foreground agent is legitimately absent from a background-only snapshot.
|
||||||
|
Older CLIs still need both edge sources: `open_tasks` knows about a
|
||||||
|
backgrounded command, while `Subagents::any_open` finds a subagent whose
|
||||||
|
`task_started` is behind an adopted stdout offset.
|
||||||
|
- **A usage limit a subagent hits reaches the session**, not just the
|
||||||
|
subagent's own transcript; auto-resume can only schedule against a session.
|
||||||
|
That is the case where the main agent is idle and a background Task is
|
||||||
|
still burning quota.
|
||||||
|
- **Codex's count is two id sets added together.** Open child thread ids come
|
||||||
|
from the subagent registry; live background command process ids come from
|
||||||
|
app-server's experimental `thread/backgroundTerminals/list`. The command set
|
||||||
|
is runtime state, refreshed at lifecycle edges and once a second while
|
||||||
|
nonempty. Never decrement it from an unmatched completion.
|
||||||
|
- **The status word and its colour are `sessionStatusWord` /
|
||||||
|
`sessionStatusColour`**, shared by the list and the session screen. They
|
||||||
|
were two `when`s, and the second one silently missed `waiting`.
|
||||||
|
|
||||||
## Shared appearance
|
## Shared appearance
|
||||||
|
|
||||||
@@ -383,10 +469,125 @@ where it was instead of half-deleted.
|
|||||||
swallowed the drag along with the tap, so a list could not be scrolled
|
swallowed the drag along with the tap, so a list could not be scrolled
|
||||||
while anything in it was busy.
|
while anything in it was busy.
|
||||||
|
|
||||||
|
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
||||||
|
One machine offers echo, the Claude CLI and a local model at once and only
|
||||||
|
the CLI spends anything, so a session says which meter reports on it
|
||||||
|
(`usageProvider`, from `DriverKind::usage_provider`, which
|
||||||
|
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
||||||
|
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
||||||
|
or echo session, and the phone draws **nothing** for one — not a zero, and
|
||||||
|
not "unknown". Nothing while the first fetch is out either: "checking"
|
||||||
|
under a session that turns out to meter nothing is a row the screen then
|
||||||
|
has to withdraw.
|
||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
- **A server started with no `--tools` answers 403 at `GET /tools`, not an
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
empty list.** The route is off rather than empty, so reading that as a
|
||||||
|
failure made "no tools" — the one setting whose entire purpose is to have
|
||||||
|
none — a session that never started. The router is always given
|
||||||
|
`--tools all` now and the choice is a filter here, so this is a trap for
|
||||||
|
whoever next changes how the server is started.
|
||||||
|
|
||||||
|
- **`POST /models/load` answers 400 for a model that is already loaded**, and
|
||||||
|
that is the *ordinary* case once one server is shared: a second session
|
||||||
|
naming a model somebody else loaded. The router driver asks what is loaded
|
||||||
|
first and treats "it is there" as the answer whatever the request said.
|
||||||
|
|
||||||
|
- **Starting a process from a blocking thread needs the runtime.** Loading a
|
||||||
|
model is minutes of disk, so it runs on a `std::thread` — and tokio's
|
||||||
|
`Command::spawn` registers the child with the reactor, so calling it with no
|
||||||
|
runtime context panics. The panic kills only that thread: the session said
|
||||||
|
`loading` for ever and nothing appeared in the log. `Routers` holds a
|
||||||
|
`tokio::runtime::Handle` and enters it around the spawn.
|
||||||
|
|
||||||
|
- **A llama session reports `loading`, and a message sent into it queues.**
|
||||||
|
Before 2026-09-19 the session showed `running` from the moment the process
|
||||||
|
started, so a minute of reading a model off disk was indistinguishable from
|
||||||
|
a minute of thinking -- and anything sent in that window came back as an
|
||||||
|
error, because `llama-server` refuses everything until the model is in
|
||||||
|
memory. `SessionStatus::Loading` is the state. A driver that reports
|
||||||
|
`Loading` owes the holding as well as the word, and **the queue is where it
|
||||||
|
holds**: held inside the turn instead (until 2026-09-20) the message was
|
||||||
|
recorded as read on arrival, so the phone drew it as sent while nothing was
|
||||||
|
reading it, and the turn then folded it out of the transcript *and*
|
||||||
|
appended it, sending it to the model twice. `Shared::await_ready` is now
|
||||||
|
only for a turn whose model was changed under it.
|
||||||
|
|
||||||
|
- **A llama turn that says nothing said something that was thrown away.** Two
|
||||||
|
silent endings were found on 2026-09-20 and both looked, on the phone, like
|
||||||
|
a message that was sent and never answered: an `{"error": ...}` chunk
|
||||||
|
arriving mid-stream on an otherwise successful response (a GPU that ran out
|
||||||
|
of memory mid-decode), and a stream that simply stops without its `[DONE]`
|
||||||
|
(the model unloaded under the session). Neither is an ordinary end, and
|
||||||
|
`generate` now fails the turn for both -- a reply that stops early is not a
|
||||||
|
reply, and the transcript keeps whatever arrived before it.
|
||||||
|
|
||||||
|
- **A cancel flag is only as prompt as the next place somebody looks.** A
|
||||||
|
llama turn waits on three things that look nowhere at all: a permission
|
||||||
|
question, a tool call `llama-server` is running (a shell command there runs
|
||||||
|
to its own timeout, up to a minute), and the completion itself, which says
|
||||||
|
nothing for as long as the prompt takes to read -- tens of seconds on a long
|
||||||
|
conversation. Setting `cancel` left the turn exactly where it was until
|
||||||
|
whichever it was came back, so Pause did nothing on screen for all of it.
|
||||||
|
**The wait is what ends, not the work**: `awaiting` runs each of those on a
|
||||||
|
thread of its own and `Shared::abandon_turn` answers the wait, so the turn
|
||||||
|
ends in milliseconds (measured 43ms in every state) and the abandoned thread
|
||||||
|
finishes into a channel nobody is reading. Nothing here can stop a shell
|
||||||
|
command or a model mid-reply, and pretending otherwise is what the old code
|
||||||
|
did.
|
||||||
|
**Which is why cancellation is a token per turn** (`Cancel`), not a flag on
|
||||||
|
the session: the abandoned thread wakes up some time later, and a flag the
|
||||||
|
next turn had reset would let it write into a conversation it is no longer
|
||||||
|
part of. Its own token stays set for ever, so it says nothing -- and the
|
||||||
|
open thinking block is closed by `Shared::abandon_turn` rather than by that
|
||||||
|
thread, since the one that knows is not the one that ends the turn.
|
||||||
|
|
||||||
|
- **Stop ends a llama session and takes the model with it, if nobody else
|
||||||
|
wants it** (2026-09-21). Until then Stop did *nothing at all* to one:
|
||||||
|
`stop_session` signals the session's recorded process, and `process::stop`
|
||||||
|
refuses a `Shared` record -- so the session sat at `idle` with no sign
|
||||||
|
anything had happened. A `Shared` record now routes to `Driver::stop`,
|
||||||
|
because what stopping means for a session that borrows somebody else's
|
||||||
|
process is the driver's to say. The llama driver ends the turn, says
|
||||||
|
`exited` itself (nothing else will -- there is no process of its own to
|
||||||
|
die), and asks `Router::release`: each live session claims the model it is
|
||||||
|
on, and the model comes out of memory only when the last claim goes. A model
|
||||||
|
another session is using stays.
|
||||||
|
|
||||||
|
- **A path is stored as it was typed, and `~` is expanded where it is used.**
|
||||||
|
`~/repos/x` and `/home/someone/repos/x` are a path and a snapshot of where it
|
||||||
|
pointed, and the snapshot is what breaks when an account is renamed or the
|
||||||
|
value is read on another machine -- so nothing at the boundary rewrites one
|
||||||
|
in either direction (`machines::tidy` used to expand and `shorten_home` used
|
||||||
|
to contract; both are gone). Expansion belongs to the machine the path is on:
|
||||||
|
`ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
|
||||||
|
`ssh::expand_home` for one here. The exception that proves it is
|
||||||
|
**`llama-server`'s tools**, which take the working directory as an
|
||||||
|
`x-tool-cwd` header and `chdir` to it with no shell in the way: a `~` arrives
|
||||||
|
there as a directory of that name and *every* tool using one answers "failed
|
||||||
|
to spawn process\n[exit code: -1]", which on the phone looks like a session
|
||||||
|
whose tools are all broken. `files::resolve_blocking` is what the llama
|
||||||
|
driver resolves it with at launch, on the machine that will serve the
|
||||||
|
session.
|
||||||
|
|
||||||
|
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
|
||||||
|
adding it made every transcript that had recorded one unreadable, so
|
||||||
|
`launch` failed for those sessions and `SessionManager::new` skipped them —
|
||||||
|
no status, nothing sendable, no new messages, for every live session that
|
||||||
|
had run a background task. **The set of kinds a transcript can hold only
|
||||||
|
ever grows**: a line may come from a newer server or from an older one that
|
||||||
|
wrote a kind since dropped, and one unfamiliar word must never be able to
|
||||||
|
end the file. `Indexed::parse_at` degrades a line it cannot read to
|
||||||
|
`Event::Unreadable { kind }`, keeping its seq — which is what everything
|
||||||
|
downstream is addressed by — and the phone draws it as a placeholder saying
|
||||||
|
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
|
||||||
|
is what retiring looks like, and the phone folds it to no row.
|
||||||
|
|
||||||
|
Project-specific only. A lesson that would bite any project on this machine
|
||||||
|
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
|
||||||
|
subject; one that would bite any project anywhere belongs in the
|
||||||
|
`code-lessons` skill, under the admission test at its end.
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
@@ -480,6 +681,19 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
the reader hit the end of what was loaded on every swipe and stood there
|
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
|
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
||||||
is actually on screen.
|
is actually on screen.
|
||||||
|
- **A page landing while the history observer was fetching it must trigger its
|
||||||
|
own successor.** The observer once collected only `LazyListState.layoutInfo`;
|
||||||
|
while its collector was suspended in `loadOlderPage`, a compact page could
|
||||||
|
be composed and laid out without leaving another change to observe afterward.
|
||||||
|
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
|
||||||
|
changed while `loadingHistory` was true, so the restarted effect declined to
|
||||||
|
overlap it and never noticed the flag returning to false. Codex exposes both
|
||||||
|
failures because a page full of calls collapses into one tool group: loading
|
||||||
|
stopped until expanding that group forced a layout. The observer now collects
|
||||||
|
the cursor, loading, restoring and failure state with the layout, so returning
|
||||||
|
to not-loading always rechecks the settled height. A failed page turns the
|
||||||
|
history boundary into a Try again control rather than retrying in a loop or
|
||||||
|
requiring another scroll.
|
||||||
- **Only `fetchTranscript` was off the main thread; the fold was not.**
|
- **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
|
`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,
|
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
||||||
@@ -487,38 +701,12 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
||||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
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.
|
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||||
|
- **A transcript snapshot cannot survive a suspension and then be assigned.**
|
||||||
## Measurements worth not re-taking
|
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed
|
||||||
|
markdown, and then assigned the joined snapshot. An SSE event arriving in
|
||||||
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
that gap appeared and vanished; reopening brought it back because the
|
||||||
emulator against a real imported transcript with the server at
|
transcript and cache had it all along. Warm against a candidate if needed,
|
||||||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
then join against the current `items` and assign without another suspension.
|
||||||
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
Also keep the page's original `oldestSeq`: a stream reset while the fetch or
|
||||||
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
warm is suspended makes the page stale, and it must be discarded rather
|
||||||
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
than joined into the reset window.
|
||||||
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.
|
|
||||||
EXPLORER.md's "What the measurements said" has the rest.
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
# client-core
|
|
||||||
|
|
||||||
`client-core/` is the app's pure logic held once instead of twice, per
|
|
||||||
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
|
||||||
framework dependency of any kind, so it can outlive whichever one the app
|
|
||||||
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
|
||||||
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
|
||||||
share, extracted from `server/src/session/driver.rs` and
|
|
||||||
`session/transcript.rs` on 2026-09-04.
|
|
||||||
|
|
||||||
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
|
||||||
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
|
||||||
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
|
||||||
next (a Masonry or iris transcript screen, most likely).
|
|
||||||
|
|
||||||
## What's here, and what Kotlin file it replaces
|
|
||||||
|
|
||||||
| `client-core/src/…` | Kotlin original | Status |
|
|
||||||
|------------------------------------------|-------------------------------------------|--------|
|
|
||||||
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
|
||||||
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
|
||||||
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
|
||||||
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
|
||||||
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
|
||||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
|
||||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
|
||||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
|
||||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
|
||||||
|
|
||||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
|
||||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
|
||||||
cases ported alongside it, plus new tests for the pieces that had none
|
|
||||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
|
||||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
|
||||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
|
||||||
them via `session::transcript`'s round-trip coverage).
|
|
||||||
|
|
||||||
## Correspondence notes worth knowing before touching either side
|
|
||||||
|
|
||||||
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
|
||||||
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
|
||||||
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
|
||||||
non-overlapping spans. Whatever UI framework ends up consuming this
|
|
||||||
crate maps `Style` onto its own text-styling type; nothing here should
|
|
||||||
change to accommodate a particular one.
|
|
||||||
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
|
||||||
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
|
||||||
strings. `highlight::span_text` turns a `Span` back into text for a
|
|
||||||
caller working the same way; a caller that wants byte offsets into a
|
|
||||||
`&str` has to convert.
|
|
||||||
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
|
||||||
translation bug while it was being written: an early draft let a
|
|
||||||
*damaged* chunk (one file unreadable, discard just this session) and a
|
|
||||||
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
|
||||||
the same `Err` from one closure, which would have disabled every
|
|
||||||
session's cache over a single corrupt chunk. Fixed by checking a
|
|
||||||
thread-local "was this damage" flag before deciding which failure mode
|
|
||||||
it was -- see the comment on `guard` and the commit message for
|
|
||||||
`transcript_cache.rs`.
|
|
||||||
|
|
||||||
## What `api.rs` covers, and what it does not yet
|
|
||||||
|
|
||||||
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
|
||||||
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
|
||||||
fake transport and no server). `UreqTransport` is the only real
|
|
||||||
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
|
||||||
(blocking, already a project dependency, no extra TLS crate needed since
|
|
||||||
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
|
||||||
|
|
||||||
Covered: session list/read, message send, unqueue, answer, interrupt,
|
|
||||||
stop, start, rename, cwd, model, permission-mode, notify, command,
|
|
||||||
compact, delete, and one transcript page.
|
|
||||||
|
|
||||||
**Not covered, and each is real work rather than a stub to fill in:**
|
|
||||||
setups (`/setups*`, machine and provider discovery), the file explorer
|
|
||||||
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
|
||||||
(`/models*`, HuggingFace browsing and downloads), attachments
|
|
||||||
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
|
||||||
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
|
||||||
the full table to work from when one of these is next.
|
|
||||||
|
|
||||||
## What `transcript_fold.rs` covers, and what it does not yet
|
|
||||||
|
|
||||||
`fold_event` covers every `Event` variant server/ can produce today,
|
|
||||||
including tool-call/question/image attachment and peer-message placement.
|
|
||||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
|
||||||
|
|
||||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
|
||||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
|
||||||
merges a tool call split across two fetched pages and re-merges a run a
|
|
||||||
boundary cut through. This matters the moment paging backward through
|
|
||||||
history is exercised; it is deliberately left rather than rushed, since
|
|
||||||
it is exactly the kind of boundary logic this project's own "things that
|
|
||||||
have bitten" section warns reads fine and is wrong at the edges.
|
|
||||||
|
|
||||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
|
||||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
|
||||||
A server newer than this build that adds an event type will fail to parse
|
|
||||||
that line rather than degrading to a placeholder row. Closing this means
|
|
||||||
deciding how `event_model` itself represents "a shape I don't recognise"
|
|
||||||
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
|
||||||
fix, so it is recorded here rather than silently worked around.
|
|
||||||
|
|
||||||
## `config.rs`: `EnrolledServer`
|
|
||||||
|
|
||||||
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
|
||||||
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
|
||||||
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
|
||||||
parses on the phone -- so any Rust client enrols from the same text a
|
|
||||||
phone would scan as a QR, with no second format invented for it (RUST.md's
|
|
||||||
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
|
||||||
persisted or under what file permissions -- a phone seals its token in the
|
|
||||||
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
|
||||||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
|
||||||
caller-specific (the code rules' "ask for the least you need"). Its only
|
|
||||||
caller today is `desktop-app`; a future Android build of this crate would
|
|
||||||
be a second one, not a reason to move the type.
|
|
||||||
|
|
||||||
## What is not started at all
|
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
|
||||||
from the transcript cache or the server, and stitches the two. Needs
|
|
||||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
|
||||||
which exist now, so this is unblocked whenever picked up.
|
|
||||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
|
||||||
colours a `.md` file or fence for the highlighter, but does not build the
|
|
||||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
|
||||||
renderer walks to lay out prose versus code versus a table.
|
|
||||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
|
||||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
|
||||||
UI layer will want its own block parser or a crate for it, decided
|
|
||||||
alongside the framework choice in RUST.md.
|
|
||||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
|
||||||
it flattens a row into bounded units for a *specific* lazy-list
|
|
||||||
framework's composition cost, which is a fact about that framework
|
|
||||||
rather than about the transcript.
|
|
||||||
|
|
||||||
## Verifying
|
|
||||||
|
|
||||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
|
||||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
|
||||||
same way it always has). From `client-core/` directly: `cargo test`,
|
|
||||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
|
||||||
+41
-27
@@ -1,30 +1,44 @@
|
|||||||
# Decisions taken for Iris to review
|
# Decisions awaiting review
|
||||||
|
|
||||||
Short list of design choices made by the design agent without asking, so
|
Choices made while working autonomously, for Bryan to keep or change. Each
|
||||||
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
says what was picked and why; the detail is in the design doc it names.
|
||||||
for iris API changes); this file is only the summary. Newest first. Items
|
Delete an entry once it has been looked at.
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
|
||||||
|
|
||||||
## 2026-09-05
|
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
||||||
|
|
||||||
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
Made on my own judgement, limited blast radius:
|
||||||
drag pans the list immediately; a stationary press held 500 ms starts a
|
|
||||||
text selection which further dragging extends; a horizontal drag while
|
1. **A subagent is a transcript, not a session.** It has no process,
|
||||||
something is already selected extends that selection without the wait.
|
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
||||||
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
and stored under the session's directory, so deleting the session takes
|
||||||
"text layer always wins" or "list always wins" rule because either loses
|
it. Alternative rejected: registering it as a session of its own, which
|
||||||
one of the two gestures a reader expects.
|
would give it a card in the main list and a driver that can do nothing.
|
||||||
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
2. **Read-only view is the session screen minus its controls**, rather than
|
||||||
holding `transcript-ui`'s screen beside a session list, talking to a real
|
a second, simpler transcript screen. Keeps paging, caching, selection
|
||||||
`ai-server` through `client-core`. It enrols by pasting the same
|
and rendering in one place. Cost: a `readOnly` mode threaded through
|
||||||
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
`SessionScreen`.
|
||||||
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
3. **The list only carries a count.** Each session row says how many
|
||||||
pinned CA is a path given on the command line, not baked in. Chosen so
|
subagents it has; their titles and statuses are fetched when the card is
|
||||||
the phone and desktop share one enrolment format and no second one is
|
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
||||||
invented.
|
Consequence: an expanded card's statuses refresh with the list, not live.
|
||||||
- **Order of remaining work**: finish the two in-flight pieces above, then
|
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
||||||
the transcript screen's Android integration and the `transcript-bench.sh`
|
the server. Collapsed by default, per the transcript convention that new
|
||||||
comparison against Compose — the numbers the recommendation still lacks.
|
things arrive collapsed.
|
||||||
- **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
|
5. **Subagents of imported sessions are not shown.** The import path still
|
||||||
on the bench numbers above; RUST.md's recommendation says what the
|
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
||||||
measurements must show.
|
are not read. Only subagents run while this backend was watching exist.
|
||||||
|
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
||||||
|
paid turn to exercise.
|
||||||
|
|
||||||
|
Deferred, because they reach further than this feature:
|
||||||
|
|
||||||
|
- **Live status on the list.** Whether the session list should follow a
|
||||||
|
stream at all (it refreshes on demand today) decides whether subagent
|
||||||
|
status can ever be live there. Not changed.
|
||||||
|
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
||||||
|
in its transcript and are not given transcripts of their own. Supporting
|
||||||
|
that is the same mechanism one level down, but the UI would need nested
|
||||||
|
expanders.
|
||||||
|
|
||||||
|
- **The subagent status row says "context unknown".** Nothing measures a
|
||||||
|
subagent's context; the row could leave it out rather than admit it.
|
||||||
+48
-19
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
|||||||
## What it is, in one paragraph
|
## What it is, in one paragraph
|
||||||
|
|
||||||
A machine's filesystem, seen from the phone through the backend. The explorer
|
A machine's filesystem, seen from the phone through the backend. The explorer
|
||||||
belongs to a **setup** (a machine), not to a session: a session only says
|
belongs to a **machine** (a machine), not to a session: a session only says
|
||||||
where to start. Every operation — list, read, write, create — is one shell
|
where to start. Every operation — list, read, write, create — is one shell
|
||||||
script run through `Transport`, exactly the way the import listing and the
|
script run through `Transport`, exactly the way the import listing and the
|
||||||
usage fetch already work, so the local and the ssh case are one
|
usage fetch already work, so the local and the ssh case are one
|
||||||
@@ -25,18 +25,30 @@ message. The phone draws what came back.
|
|||||||
|
|
||||||
### 1. Keyed on the machine, opened from the session
|
### 1. Keyed on the machine, opened from the session
|
||||||
|
|
||||||
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
|
Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem
|
||||||
is a property of a machine. The session screen's folder button opens the
|
is a property of a machine. The session screen's folder button opens the
|
||||||
explorer with the session's setup and its `cwd`; a session with no `cwd`
|
explorer with the session's machine and its `cwd`; a session with no `cwd`
|
||||||
opens at the machine's home, which the **machine** resolves (`cd` with no
|
opens at the machine's home, which the **machine** resolves (`cd` with no
|
||||||
argument and `pwd -P`), never a path the phone guessed. Nothing in the
|
argument and `pwd -P`), never a path the phone guessed. Nothing in the
|
||||||
explorer knows what a session is, so a later entry point from the setups tab
|
explorer knows what a session is, so a later entry point from the machines tab
|
||||||
is one more caller and no new code.
|
is one more caller and no new code.
|
||||||
|
|
||||||
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
||||||
find the setup, and "browse this machine" from anywhere else would need a
|
find the machine, and "browse this machine" from anywhere else would need a
|
||||||
session to exist first.
|
session to exist first.
|
||||||
|
|
||||||
|
The third caller arrived 2026-09-21 and cost no code here, which is the
|
||||||
|
property this decision was made for: **View raw** in the session settings
|
||||||
|
dialog opens the explorer on the session's own transcript file
|
||||||
|
(`fileTarget`), so the record can be read as it is on disk rather than only
|
||||||
|
as the conversation drawn from it. The session says where the file is
|
||||||
|
(`transcriptFile` on `GET /sessions/{id}`) because only the backend knows --
|
||||||
|
and it names **this backend's** machine rather than the session's, which for
|
||||||
|
a remote session are two different filesystems. Back from the file lands in
|
||||||
|
the session's own directory, where the log and the process record are.
|
||||||
|
A transcript past `FILE_LIMIT` is refused the same way any other large file
|
||||||
|
is, which is the known limit of this as a debugging tool.
|
||||||
|
|
||||||
### 2. One shell script per operation, over `Transport`, on both transports
|
### 2. One shell script per operation, over `Transport`, on both transports
|
||||||
|
|
||||||
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
||||||
@@ -68,7 +80,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it
|
|||||||
names, so an enrolled token cannot become "read me an arbitrary file". The
|
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||||
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||||
Security section in these terms: the token already gates spawning a
|
Security section in these terms: the token already gates spawning a
|
||||||
bypass-permissions agent in any directory on any machine a setup names, and
|
bypass-permissions agent in any directory on any configured machine, and
|
||||||
that agent can already read and write every file its user can. The explorer
|
that agent can already read and write every file its user can. The explorer
|
||||||
is a shorter path to authority the token already holds, not new authority.
|
is a shorter path to authority the token already holds, not new authority.
|
||||||
The import rule stands where it is, because there a path was unnecessary and
|
The import rule stands where it is, because there a path was unnecessary and
|
||||||
@@ -82,13 +94,14 @@ writing are fixed scripts; the phone chooses only the path and the bytes.
|
|||||||
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
|
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
|
||||||
a relative path would be depends on something the reader cannot see. Every
|
a relative path would be depends on something the reader cannot see. Every
|
||||||
listing answers with `pwd -P` of the directory it listed, so the phone
|
listing answers with `pwd -P` of the directory it listed, so the phone
|
||||||
navigates on a resolved absolute path — the parent is a string operation on
|
navigates on a resolved absolute path. The phone also resolves `~` through the
|
||||||
that, and a `~` the session was spawned with is shown as what it turned out
|
same route, then shortens that directory and every path beneath it back to
|
||||||
to be. The phone never resolves `..` itself.
|
tilde notation for display; it never guesses where a local or ssh user's home
|
||||||
|
is. The phone never resolves `..` itself.
|
||||||
|
|
||||||
### 5. A read is capped and typed, and every state it can be in has a word
|
### 5. A read is capped and typed, and every state it can be in has a word
|
||||||
|
|
||||||
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
|
`GET /machines/{id}/file` answers with one of `text` (content, size, mtime,
|
||||||
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||||
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||||
at), or the machine's own error.
|
at), or the machine's own error.
|
||||||
@@ -101,7 +114,7 @@ what it is.
|
|||||||
|
|
||||||
### 6. A write is conditional on what the reader saw
|
### 6. A write is conditional on what the reader saw
|
||||||
|
|
||||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
`PUT /machines/{id}/file` carries the sha256 the read reported. The script
|
||||||
compares it against the file as it is now and exits distinctly if it differs;
|
compares it against the file as it is now and exits distinctly if it differs;
|
||||||
the server answers **409**. Agents edit files while people read them; this is
|
the server answers **409**. Agents edit files while people read them; this is
|
||||||
the common case, not the exotic one, and silently overwriting an agent's edit
|
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||||
@@ -122,9 +135,9 @@ precondition is fresh without a second read.
|
|||||||
|
|
||||||
### 7. Create refuses to overwrite
|
### 7. Create refuses to overwrite
|
||||||
|
|
||||||
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
`POST /machines/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||||
name that exists fails with the shell's own message rather than truncating
|
name that exists fails with the shell's own message rather than truncating
|
||||||
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
|
somebody's file; `POST /machines/{id}/dir` is `mkdir --` with the same
|
||||||
property. The modal names one thing in the current directory and has a switch
|
property. The modal names one thing in the current directory and has a switch
|
||||||
for "directory"; a created file opens straight into edit mode, because an
|
for "directory"; a created file opens straight into edit mode, because an
|
||||||
empty file is not something to look at.
|
empty file is not something to look at.
|
||||||
@@ -208,17 +221,21 @@ absence the signal. Back with unsaved changes asks, and says the edits will
|
|||||||
be lost. The explorer draws over the session, which deliberately has no
|
be lost. The explorer draws over the session, which deliberately has no
|
||||||
`imePadding`, so the explorer's own box adds it.
|
`imePadding`, so the explorer's own box adds it.
|
||||||
|
|
||||||
### 10. The explorer draws over the session, and back closes it first
|
### 10. The explorer draws over the session, and back follows what is open
|
||||||
|
|
||||||
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
||||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
||||||
the session stays composed under it: its event stream keeps flowing, its
|
the session stays composed under it: its event stream keeps flowing, its
|
||||||
scroll position and draft stay where they were, and returning from a file
|
scroll position and draft stay where they were, and returning from a file
|
||||||
costs nothing. Back — the button and the platform gesture — clears `files`
|
costs nothing. From an open file, both the header's back button and Android back
|
||||||
when set and goes to the list otherwise. Inside the explorer the same back
|
return to its containing directory. From a directory, the header's back button
|
||||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
clears `files` and returns to the session. Android back instead walks toward the
|
||||||
parent directory, and only from the starting directory does it close. "Back
|
session's project directory: upward to the common ancestor, then down one path
|
||||||
returns; it does not exit."
|
segment per press, and at the project it returns to the session. This makes
|
||||||
|
Back from `/etc` visibly travel through `/`, `/home`, and onward to a project
|
||||||
|
under `~/repos`, rather than leading away from it. The `..` row remains explicit
|
||||||
|
parent navigation. An editor with unsaved changes asks before either route
|
||||||
|
discards them. "Back returns; it does not exit."
|
||||||
|
|
||||||
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
|
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
|
||||||
leaf screen goes to Main today, and a session disposed and re-created on each
|
leaf screen goes to Main today, and a session disposed and re-created on each
|
||||||
@@ -267,6 +284,18 @@ The speedometer went; the report is a "Copy render timings" row in
|
|||||||
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
||||||
|
|
||||||
|
### 14. File links in a session open in the explorer
|
||||||
|
|
||||||
|
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
|
||||||
|
session's explorer, on the session's machine. A trailing editor line and optional column are removed;
|
||||||
|
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
|
||||||
|
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
|
||||||
|
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
|
||||||
|
a path on another machine.
|
||||||
|
|
||||||
|
The markdown link handler is provided around the session rather than taught about machines. That
|
||||||
|
keeps the renderer reusable and makes the explorer's existing machine target the one navigation path.
|
||||||
|
|
||||||
## HTTP surface
|
## HTTP surface
|
||||||
|
|
||||||
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
# iris: notable public API changes
|
|
||||||
|
|
||||||
For Iris to read on her own time. Each entry is a change to iris's public
|
|
||||||
surface that a widget author or app author would notice: a trait method
|
|
||||||
added, removed or re-shaped; a type that callers construct differently; a
|
|
||||||
capability that moved. Small and trivial changes do not go here.
|
|
||||||
|
|
||||||
An entry gives the date, what changed, why, and a short before/after where
|
|
||||||
it helps judge the change without the session that made it. Newest first.
|
|
||||||
|
|
||||||
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
|
|
||||||
|
|
||||||
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
|
||||||
as its last step, which is right for a window that *is* the transcript
|
|
||||||
screen (the winit example, an eventual Android cdylib) and wrong for the
|
|
||||||
desktop app, which puts a session list beside it. `build_tree` is `build`
|
|
||||||
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
|
||||||
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
|
||||||
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
|
||||||
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
|
||||||
`build_tree` and doing the `set_root` itself, so existing callers are
|
|
||||||
unaffected.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before, and still available, for a caller that wants to *be* the window:
|
|
||||||
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
|
||||||
|
|
||||||
// new, for a caller embedding the screen beside something else:
|
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
some_widget_ptr(rsc).set(tree);
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
|
||||||
|
|
||||||
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
|
||||||
registers both a list-level pan and a row-level drag-to-select on the same
|
|
||||||
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
|
||||||
`run_sensors` always gives the innermost layer first refusal, so the inner
|
|
||||||
one wins every frame it is pressed, not just the frame the press started
|
|
||||||
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
|
||||||
until now). `DragArbiter` is one small state machine, one instance per
|
|
||||||
gesture surface (a whole list, not per row), that a caller drives with its
|
|
||||||
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
|
||||||
(so it is unit-testable without a real clock or a render harness). It
|
|
||||||
decides the way Android itself does: an ordinary vertical drag pans
|
|
||||||
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
|
||||||
selection, which any further drag then extends; a horizontal drag while
|
|
||||||
something is already selected extends it immediately, skipping the wait.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// One per list, held alongside whatever state coordinates the rows:
|
|
||||||
let mut arbiter = DragArbiter::new();
|
|
||||||
|
|
||||||
// On press-down:
|
|
||||||
arbiter.press_start(pos, Instant::now(), already_selected);
|
|
||||||
// Every frame the button/finger stays down:
|
|
||||||
match arbiter.update(pos, Instant::now()) {
|
|
||||||
DragOutcome::Pan(dy) => list.scroll(-dy),
|
|
||||||
DragOutcome::SelectStart => selection.begin(...),
|
|
||||||
DragOutcome::SelectExtend => selection.extend(...),
|
|
||||||
DragOutcome::Undecided => {}
|
|
||||||
}
|
|
||||||
// On release:
|
|
||||||
arbiter.release();
|
|
||||||
```
|
|
||||||
|
|
||||||
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
|
||||||
the reference caller: every row's `CursorSense::click_or_drag() |
|
|
||||||
CursorSense::unclick()` handler routes through one `Selection`-owned
|
|
||||||
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
|
||||||
on a row's own rendered text now pans the list correctly instead of
|
|
||||||
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
|
||||||
`drag_arbiter_tests` module.
|
|
||||||
|
|
||||||
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
|
|
||||||
|
|
||||||
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
|
|
||||||
family, ...) for its whole string, applied via `push_default` into parley's
|
|
||||||
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
|
|
||||||
whichever of colour/family/font size/bold/italic/underline it overrides,
|
|
||||||
pushed with parley's own `push(property, range)` instead. Why: a transcript
|
|
||||||
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
|
|
||||||
wrapped paragraph needs each to carry its own look while the paragraph
|
|
||||||
still wraps and selects as a single buffer — the thing `masonry`'s
|
|
||||||
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
|
|
||||||
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
|
|
||||||
existed at all.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
|
|
||||||
wtext(text)
|
|
||||||
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
|
|
||||||
.editable(EditMode::MultiLine)
|
|
||||||
.add(rsc);
|
|
||||||
```
|
|
||||||
|
|
||||||
Two things a widget author should know before reaching for it:
|
|
||||||
|
|
||||||
- **Call `.spans()` before or after `.editable()`, both work** — the field
|
|
||||||
lives on `TextBuilder` itself, not either output type, and both
|
|
||||||
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
|
|
||||||
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
|
|
||||||
third `TextBuilderOutput` impl without also calling `set_spans` there
|
|
||||||
reproduces the exact bug this box shipped once already (spans silently
|
|
||||||
dropped for `TextEdit`, found only by screenshotting, not by any test —
|
|
||||||
`markdown.rs`'s own unit tests check string/range logic, which is
|
|
||||||
correct in isolation and proves nothing about whether the render path
|
|
||||||
ever sees it).
|
|
||||||
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
|
|
||||||
`color: UiColor` field (from parley's own per-run `Style::brush`), and
|
|
||||||
`Painter::glyphs` draws each glyph in its own colour instead of
|
|
||||||
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
|
|
||||||
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
|
|
||||||
tint a cursor) but no longer drives what a glyph actually renders as.
|
|
||||||
|
|
||||||
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
|
|
||||||
|
|
||||||
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
|
|
||||||
is now load-bearing: it's the one thing that puts a widget in the AccessKit
|
|
||||||
tree `iris_core::ui::access::AccessTree` builds and both backends push
|
|
||||||
out. A widget author who wants a control to be findable by name (and
|
|
||||||
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
|
|
||||||
on it; nothing else is required, and a widget nobody labels is invisible
|
|
||||||
to this system at zero cost, not just zero UI.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let button = rect(Color::LIME)
|
|
||||||
.on(CursorSense::click(), move |_, rsc| { ... })
|
|
||||||
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
|
|
||||||
```
|
|
||||||
|
|
||||||
Two new things a widget author might touch directly:
|
|
||||||
|
|
||||||
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
|
|
||||||
Override it if your widget has a real platform equivalent —
|
|
||||||
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
|
|
||||||
Only consulted for a widget that also has a `.label()`; an unlabelled
|
|
||||||
widget's `access_role` is never called.
|
|
||||||
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
|
|
||||||
with an explicit label, for anything else that wants to walk the same
|
|
||||||
set `AccessTree` does.
|
|
||||||
|
|
||||||
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
|
|
||||||
this sits entirely beside them, reading `resolved_region`'s output rather
|
|
||||||
than participating in producing it.
|
|
||||||
|
|
||||||
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
|
|
||||||
|
|
||||||
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
|
|
||||||
module doc first), for the transcript's kind of screen: variable-height
|
|
||||||
rows, keyed by a `u64`, composed only while visible, moved rather than
|
|
||||||
re-laid-out on scroll, a scroll anchor that survives a row inserted above
|
|
||||||
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
|
|
||||||
a row's height changes (`note_tap`, resolved in the layout pass).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let mut list = List::new(Axis::Y);
|
|
||||||
list.push_back(ListRow::new(key, row_widget)); // O(1)
|
|
||||||
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
|
|
||||||
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
|
|
||||||
list.note_tap(viewport_y); // before mutating a row's height
|
|
||||||
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
|
|
||||||
```
|
|
||||||
|
|
||||||
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
|
|
||||||
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
|
|
||||||
no new mechanism was added to the render core for it. One correctness
|
|
||||||
lesson worth reading even for other widgets: a row that fills whatever
|
|
||||||
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
|
|
||||||
a throwaway oversized region and then merely `reposition`ed into place --
|
|
||||||
`reposition` only ever writes an offset, never a size, so the oversized
|
|
||||||
primitive stays oversized. `List` fixes this by caching each row's real
|
|
||||||
height once measured and placing an already-known row directly at its
|
|
||||||
exact box; see `list.rs`'s `place` for the full reasoning and
|
|
||||||
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
|
|
||||||
|
|
||||||
## 2026-09-05: a second backend (android-view), and what moved to make room for it
|
|
||||||
|
|
||||||
RUST.md's I2. Three changes a widget or app author would notice, all in
|
|
||||||
service of the same thing: `default` (winit) and the new `android`
|
|
||||||
(android-view) backends sharing what does not depend on windowing.
|
|
||||||
|
|
||||||
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
|
|
||||||
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
|
|
||||||
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
|
|
||||||
implements `FocusHost` for anything that has it — so a winit app's
|
|
||||||
existing code is unaffected. An Android app implements `FocusHost` via
|
|
||||||
`HasAndroidUiState` instead. Affects only an app that referenced
|
|
||||||
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
|
|
||||||
rather than through `.attr::<Selectable>(())`, which nothing in-tree
|
|
||||||
does.
|
|
||||||
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
|
|
||||||
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
|
|
||||||
`fn request_redraw(&self)`; `winit::window::Window` implements it
|
|
||||||
(`default/render.rs`), so `Tasks::init(window)` at a call site is
|
|
||||||
unchanged by inference. Only matters if something constructed a `Tasks`
|
|
||||||
directly rather than through `DefaultRsc`/`AndroidRsc`.
|
|
||||||
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
|
|
||||||
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
|
|
||||||
exist on Android; `android/input.rs` drives the same primitives
|
|
||||||
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
|
|
||||||
`ndk::event::Keycode` directly instead. New unconditional getters on the
|
|
||||||
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
|
|
||||||
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
|
|
||||||
`android/ime.rs`'s `InputConnection` bridge needed and that were not
|
|
||||||
previously exposed publicly.
|
|
||||||
|
|
||||||
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
|
|
||||||
|
|
||||||
A widget used to implement three methods (`draw`, `desired_width`,
|
|
||||||
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
|
|
||||||
Painter) -> Size`, which draws into `painter.region()` and returns how much
|
|
||||||
of it was used. Why: the two extra methods routinely re-simulated what
|
|
||||||
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
|
|
||||||
loop to get cross-axis sizing right) — one visit per widget per frame
|
|
||||||
instead of up to three. A container that needs a child's size before
|
|
||||||
placing it (alignment, centering) draws the child once at a provisional
|
|
||||||
region, reads the returned `Size`, and calls the new `Painter::reposition`
|
|
||||||
to move it into its final spot — an O(1) offset write, not a second draw. A
|
|
||||||
widget whose drawn output never depends on the size it's given (a
|
|
||||||
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
|
|
||||||
is_size_independent(&self) -> bool { false }` to `true`, which skips
|
|
||||||
redrawing it when only its offered region changes shape.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
|
|
||||||
// after
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
|
||||||
design, the move-offset mechanism this shipped alongside, and the file
|
|
||||||
list.
|
|
||||||
|
|
||||||
## 2026-09-04: texture pipeline rebuilt off the binding array
|
|
||||||
|
|
||||||
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
|
|
||||||
changed shape. Why: the old pipeline bound every texture ever drawn in one
|
|
||||||
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
|
|
||||||
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
|
|
||||||
and it failed outright on the Android emulator's software Vulkan. See
|
|
||||||
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
|
|
||||||
|
|
||||||
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
|
|
||||||
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
|
|
||||||
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
|
|
||||||
&queue, &config)`. Nothing replaces it — there are no more
|
|
||||||
binding-array limits to size.
|
|
||||||
- **`src/default/render.rs`'s device request asks for no features and no
|
|
||||||
binding-array limits.** Before: `required_features:
|
|
||||||
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
|
|
||||||
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
|
|
||||||
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
|
|
||||||
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
|
|
||||||
never about the binding array.
|
|
||||||
- **`TextureHandle` has no `primitive()` method any more**; a caller
|
|
||||||
outside `iris` shouldn't have been calling it (it fed the old renderer's
|
|
||||||
internals), but if something did: use `image_index()` for a standalone
|
|
||||||
image's bind-group index. There is no equivalent for a page — a page has
|
|
||||||
no bind group of its own now, see below.
|
|
||||||
- **`GlyphPrimitive` has no public constructor from a struct literal.**
|
|
||||||
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
|
|
||||||
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
|
|
||||||
flags)` — one `layer` (the shared atlas array's layer) instead of a
|
|
||||||
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
|
|
||||||
texture rather than its own bound texture.
|
|
||||||
- **A widget author drawing images is unaffected**: `Painter::texture`/
|
|
||||||
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
|
|
||||||
What changed underneath is that each standalone image now gets its own
|
|
||||||
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
|
|
||||||
invisible from the widget API, visible only in `UiRenderNode`'s internals
|
|
||||||
and in `iris`'s device requirements.
|
|
||||||
-254
@@ -1,254 +0,0 @@
|
|||||||
# iris: known problems and things still to build
|
|
||||||
|
|
||||||
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
|
||||||
matters, so the agents working through RUST.md pick these up in a sensible
|
|
||||||
order rather than rediscovering them. Each item says where it sits in the
|
|
||||||
order and what "done" looks like. Tick and date them in place.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- [x] **Input does not fall through by input type (2026-09-04).**
|
|
||||||
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
|
||||||
stop checking lower layers" from mere hover — a widget registered for
|
|
||||||
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
|
||||||
it, since "the cursor is over this widget" and "this widget handled the
|
|
||||||
event" were the same check. Fixed by judging consumption per input
|
|
||||||
kind: with no button transition and no scroll happening this frame
|
|
||||||
("momentary" activity), the topmost hovered widget still wins, same as
|
|
||||||
before; when something momentary *is* happening, only a widget whose
|
|
||||||
registered senses actually include a matching non-hover one (checked
|
|
||||||
via a new `TypeEventManager::registered`, which lists what a widget
|
|
||||||
registered without running anything) consumes it, so a widget with only
|
|
||||||
`Hovering`/click handlers can no longer block a scroll from reaching a
|
|
||||||
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
|
||||||
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
|
||||||
directions: a scroll over the button reaches the list, and a real click
|
|
||||||
still reaches the button — confirmed to fail on the pre-fix code and
|
|
||||||
pass after.
|
|
||||||
|
|
||||||
- [x] **Appending one image to an already-loaded list rebuilds every other
|
|
||||||
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
|
||||||
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
|
||||||
triggered `rebuild_image_bind_groups` — a loop over *every live
|
|
||||||
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
|
||||||
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
|
||||||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
|
||||||
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
|
||||||
every widget gets one on first draw) could be exactly what grows that
|
|
||||||
buffer. So one new message with one new image, appended to a transcript
|
|
||||||
that already has N images loaded, did not cost O(1): it cost one
|
|
||||||
`create_image` for the new image plus one `make_image_bind_group` per
|
|
||||||
*existing* image, because the new widget's own move slot pushed the
|
|
||||||
arena past its capacity. Measured directly in
|
|
||||||
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
|
||||||
already-settled ones reported **1,001** bind-group creates for that one
|
|
||||||
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
|
||||||
|
|
||||||
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
|
||||||
bind group (group 2) in the first place — the group also holds that
|
|
||||||
image's own texture view, which is the only thing that is genuinely
|
|
||||||
per-image, so a buffer shared by *everything* forced a rebuild of
|
|
||||||
*every* group the moment it moved. Gave masks/move_offsets their own
|
|
||||||
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
|
||||||
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
|
||||||
once per draw call, instead of duplicating them into every per-image
|
|
||||||
group. `GpuTextures` and its image bind groups now know nothing about
|
|
||||||
either buffer — `rebuild_image_bind_groups` is called only from
|
|
||||||
`grow_array` (the atlas array texture growing, which genuinely does
|
|
||||||
change what every image's own bind group must reference) — so a
|
|
||||||
masks/move_offsets resize now touches exactly one bind group, ever,
|
|
||||||
regardless of how many images are live. Numbers after the fix, same
|
|
||||||
benchmark and command:
|
|
||||||
|
|
||||||
./run-bench.sh images
|
|
||||||
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
|
||||||
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1 (was 1001)
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
|
||||||
confirming the bind-group restructuring changed nothing about what is
|
|
||||||
drawn.
|
|
||||||
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
|
||||||
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
|
||||||
loading 1,000 images cold used to report 1,000 creates on frame 1
|
|
||||||
(expected — `create_image`, one per new image) *and again* 1,000 on
|
|
||||||
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
|
||||||
firing a second time for the same masks/move-offsets buffer-growth
|
|
||||||
reason as the item above, confirming the guess recorded here — the two
|
|
||||||
were exactly the same root cause measured two different ways. Frame 2
|
|
||||||
now reports 0 (see the numbers above); not a separate fix.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
|
||||||
`benches/` or a script under `iris/`, never in `cargo test`). The
|
|
||||||
scenario that matters most is a **message list** — chat apps and this
|
|
||||||
app's transcript alike — stressed with many messages and many images.
|
|
||||||
One case in particular: **resizing an input box** (typing enough text to
|
|
||||||
grow it) that pushes a long list of messages above it must stay very
|
|
||||||
fast and recalculate almost nothing — a move of everything above, not a
|
|
||||||
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
|
||||||
benchmark is what proves it. Done when the numbers are in this file with
|
|
||||||
the command, and the input-box case reports draws re-run, not just frame
|
|
||||||
time.
|
|
||||||
|
|
||||||
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
|
||||||
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
|
||||||
most of this runs as an ordinary binary — the same property
|
|
||||||
`layout_tests.rs` relies on):
|
|
||||||
|
|
||||||
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
|
||||||
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
|
||||||
the file's own header for why (short version — every scenario here
|
|
||||||
reduces to a *count* `UiRenderState::take_counters` already produces,
|
|
||||||
which criterion's statistical machinery adds nothing to and which a
|
|
||||||
new dependency is not worth pulling in for). Covers (a) first-frame
|
|
||||||
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
|
||||||
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
|
||||||
of scrolling that list, 200 ticks; (c) the input-box case — a
|
|
||||||
fixed-height field at the bottom of the screen growing by a line 40
|
|
||||||
times, with the message list above it filling the rest of the screen.
|
|
||||||
Run: `cd iris && cargo bench --bench message_list` (always release —
|
|
||||||
`cargo bench` builds the `bench` profile, which is optimized).
|
|
||||||
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
|
||||||
through `iris/run-headless.sh bench_images`, printing
|
|
||||||
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
|
||||||
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
|
||||||
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
|
||||||
image rows, checked both cold (does bind-group creation reach zero
|
|
||||||
once loaded) and after appending one more image once settled (does
|
|
||||||
*that* stay cheap) — the second question is what actually matters for
|
|
||||||
a live transcript and is what turned up the two Fix items above.
|
|
||||||
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
|
||||||
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
|
||||||
chain, or `GpuTextures`.
|
|
||||||
|
|
||||||
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
|
||||||
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
|
||||||
|
|
||||||
cd iris && cargo bench --bench message_list
|
|
||||||
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
|
||||||
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
|
||||||
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
|
||||||
(b) scroll, N=100/1000/10000, 200 ticks each:
|
|
||||||
draws=200 rewrites=0 moves=200 (identical at every N)
|
|
||||||
per-tick average: 0.0002ms (identical at every N)
|
|
||||||
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
|
||||||
draws=320 rewrites=40 moves=160 (identical at every N)
|
|
||||||
per-line average: 0.0012-0.0013ms (identical at every N)
|
|
||||||
|
|
||||||
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
|
||||||
frame=1 bind_group_creates=1000 (cold load)
|
|
||||||
frame=2 bind_group_creates=1000 (see Fix item above)
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1001 (see Fix item above)
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
|
||||||
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
|
||||||
frame=2 bind_group_creates=0
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
|
||||||
never-before-seen text rows — and scales with N as it must, ~10x cost
|
|
||||||
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
|
||||||
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
|
||||||
move chain holds for both scrolling and for a growing input box pushing
|
|
||||||
the message list — draws/moves per tick or per line do not grow with
|
|
||||||
list size, and the per-operation cost (a fraction of a microsecond) is
|
|
||||||
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
|
||||||
behave as designed; its *append* half did not, until the fix above moved
|
|
||||||
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
|
||||||
the same way (b) and (c) are.
|
|
||||||
|
|
||||||
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
|
||||||
left, each recorded at the point in the code it would go rather than
|
|
||||||
silently dropped. See RUST.md's I5 box for the full account of what
|
|
||||||
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
|
||||||
composer).**
|
|
||||||
- [ ] **Android integration for this screen does not exist yet.** No
|
|
||||||
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
|
|
||||||
so `transcript-bench.sh`'s render-number pass condition against the
|
|
||||||
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
|
|
||||||
`event_stream::follow_session_events` wiring against
|
|
||||||
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
|
|
||||||
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
|
|
||||||
module, then the bench script pointed at it.
|
|
||||||
- [x] **Touch-drag panning over a row's own rendered text — done,
|
|
||||||
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
|
||||||
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
|
||||||
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
|
||||||
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
|
||||||
arbitration on every frame it was pressed, not just the frame the
|
|
||||||
press started, so a list pan gesture registered on `List` itself never
|
|
||||||
got a turn while a row was under the finger. Fixed with
|
|
||||||
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
|
||||||
machine per list deciding pan vs. select the way Android does (a
|
|
||||||
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
|
||||||
(500ms) starts a selection which further drag extends; a horizontal
|
|
||||||
drag while something is already selected extends immediately) —
|
|
||||||
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
|
||||||
every row's drag now routes through. 8 new unit tests
|
|
||||||
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
|
||||||
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
|
||||||
clean; `run-headless.sh` screenshot byte-identical to before the
|
|
||||||
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
|
||||||
- [ ] **Row-level accessibility names.** The composer carries
|
|
||||||
`.label("Message")`; transcript rows do not carry a `.label()` of
|
|
||||||
their own yet, so `Widgets::named()` (I4) does not include them —
|
|
||||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
|
||||||
stable per row (its sender + a short excerpt, matching what a screen
|
|
||||||
reader announcing a chat message would say).
|
|
||||||
- [ ] **A tappable link and a background chip behind inline code.**
|
|
||||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
|
||||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
|
||||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
|
||||||
would take (the same primitive `TextEdit::draw`'s own selection
|
|
||||||
highlight already uses internally,
|
|
||||||
`iris/src/widget/text/edit.rs:99`).
|
|
||||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
|
||||||
is selected in full (`select_all`) the moment the drag leaves it,
|
|
||||||
rather than "from the click point to whichever edge points away from
|
|
||||||
the drag" — needs the same private `layout()` access as the item
|
|
||||||
above. `selection.rs`'s module doc has the exact reasoning.
|
|
||||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
|
||||||
`client_core::highlight` exists (built for the file explorer) and
|
|
||||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
|
||||||
in was not attempted this pass.
|
|
||||||
|
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
|
||||||
mask, the way the move chain references a parent offset. Today masks
|
|
||||||
are independent regions. Design it beside the move chain (same shape:
|
|
||||||
a parent index and a bounded walk in the shader); do it when a real
|
|
||||||
widget needs it, not before.
|
|
||||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
|
||||||
rejected, letting a scroll update one float rather than positions:
|
|
||||||
input handling cares about most elements in a list, so absolute
|
|
||||||
positions must be computed on the CPU anyway. LAYOUT.md's design
|
|
||||||
already lands here (GPU walks the chain, CPU resolves on demand for
|
|
||||||
hit tests). Keep the CPU resolution lazy and per query; do not
|
|
||||||
materialise every row's absolute position per frame.
|
|
||||||
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
|
||||||
**modular — a piece of the library rather than a core part forced into
|
|
||||||
everything, the same way input is**. Whatever the mechanism, a widget
|
|
||||||
that does not animate must pay nothing and import nothing for it.
|
|
||||||
|
|
||||||
## Reconsider
|
|
||||||
|
|
||||||
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
|
||||||
to compose a widget from others (a button is the main case). With
|
|
||||||
sizing folded into `draw`, composing may be easy enough that `View` is
|
|
||||||
redundant. Decide after the layout change lands, by writing a button
|
|
||||||
both ways and keeping the one that is shorter to explain; delete the
|
|
||||||
other rather than keeping two ways.
|
|
||||||
@@ -1,897 +0,0 @@
|
|||||||
# iris: one `draw` that reports a size
|
|
||||||
|
|
||||||
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
|
||||||
any design or code so that it survives a cleared session. **Status: implemented
|
|
||||||
2026-09-04, against every pass condition in §8** (measured, not assumed — see
|
|
||||||
that section). Every widget listed in §7 was migrated in one change; none
|
|
||||||
kept `desired_width`/`desired_height`. Five points needed correction or
|
|
||||||
refinement beyond what this file originally specified — see "Deviations
|
|
||||||
found during implementation" below, added right before "For IRIS.md" — read
|
|
||||||
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
|
|
||||||
move-slot lifecycle in `render_state.rs`, since each of those five is a real
|
|
||||||
bug this file's first draft would have reproduced if implemented literally.
|
|
||||||
|
|
||||||
## What Iris asked for
|
|
||||||
|
|
||||||
> I don't like that widgets need both a draw and size functions. I'd much
|
|
||||||
> rather them have a single draw that reports a size, and if it needs to be
|
|
||||||
> moved then that can be done after the fact efficiently, or resized just
|
|
||||||
> done after as well. This should be done efficiently like everything else
|
|
||||||
> tries to do right now.
|
|
||||||
|
|
||||||
She added, a few minutes later: "single draw is not a requirement. It
|
|
||||||
just seems more efficient from what I've heard. Feel free to override any
|
|
||||||
decision I've made if you can find a genuinely better & still clean
|
|
||||||
alternative." So the single-draw model is the default to design against,
|
|
||||||
and the design below may reject it, but only with a written comparison
|
|
||||||
showing the alternative does less work per frame and is no harder to use.
|
|
||||||
|
|
||||||
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
|
|
||||||
little processing as possible per frame, but the model must cover every
|
|
||||||
layout need a real app has (the transcript's virtualised list, wrapped
|
|
||||||
text whose height depends on width, rows and columns that size to their
|
|
||||||
children, overlays, masks).
|
|
||||||
|
|
||||||
## What exists today
|
|
||||||
|
|
||||||
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
|
|
||||||
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
|
|
||||||
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
|
|
||||||
is memoised per widget id and axis in `Cache.size` keyed on the outer
|
|
||||||
size, then places the child with `Painter::widget_within(region)`. So a
|
|
||||||
child is visited twice (sized, then drawn), every widget implements sizing
|
|
||||||
twice (one per axis), and a widget whose size depends on what it draws
|
|
||||||
(wrapped text, a laid-out paragraph) does the layout in the size pass and
|
|
||||||
again in the draw pass unless it caches by hand.
|
|
||||||
|
|
||||||
Primitives are already positioned by `UiRegion` values whose scalars have
|
|
||||||
a `rel` and an `abs` part, resolved against the window in the vertex
|
|
||||||
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
|
|
||||||
exists to rewrite one instance's region in place. That is the mechanism a
|
|
||||||
"move after the fact" can build on.
|
|
||||||
|
|
||||||
## What the design must answer
|
|
||||||
|
|
||||||
1. **Parent-before-child ordering.** A row has to know each child's width
|
|
||||||
to place the next one, but under "one draw" the child's size only
|
|
||||||
exists after it has drawn. The answer is meant to be: the child draws
|
|
||||||
at a provisional origin, reports its size, and the parent *moves* it.
|
|
||||||
The move must be O(1) per moved subtree, not O(primitives in the
|
|
||||||
subtree). One way: every instance carries an index into a small
|
|
||||||
per-widget offset buffer, so moving a widget writes one entry and the
|
|
||||||
vertex shader adds it. Other ways may be better; the design should say
|
|
||||||
what was considered.
|
|
||||||
2. **Move vs resize are different costs and must be kept apart.** A move
|
|
||||||
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
|
|
||||||
whose size input changed, and a widget whose output does not depend on
|
|
||||||
its size (an icon, a fixed rect) must be able to say so and be skipped.
|
|
||||||
3. **Size-dependent content.** Wrapped text is the hard case: its height
|
|
||||||
is a function of its width. A single `draw` receives the available
|
|
||||||
size (what `SizeCtx.outer` is today) and reports what it used, so the
|
|
||||||
two-pass "measure then draw" collapses into one for the common case.
|
|
||||||
The design must say what happens when a parent wants the child's
|
|
||||||
height *before* deciding the width it will offer (rare; say whether it
|
|
||||||
is supported, or is done by drawing twice as an explicit, opt-in cost).
|
|
||||||
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
|
|
||||||
replacement should memoise the whole draw result by (id, available
|
|
||||||
size) so that an unchanged subtree costs nothing on the next frame,
|
|
||||||
which is what makes a virtualised list cheap.
|
|
||||||
5. **Everything currently written against `desired_width`/`desired_height`
|
|
||||||
moves over in one change**, per the code rules: two names for one
|
|
||||||
concept is not an intermediate state to leave behind. The widgets are
|
|
||||||
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
|
|
||||||
whatever else is there when the change is made).
|
|
||||||
|
|
||||||
## Order relative to the texture work
|
|
||||||
|
|
||||||
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
|
|
||||||
`Primitives`, `Painter`'s texture calls). This change touches the widget
|
|
||||||
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
|
|
||||||
mechanism the vertex shader needs. They overlap in `Painter` and the
|
|
||||||
shader, so they are done **in sequence, textures first**, and the layout
|
|
||||||
design here is written (not implemented) while the texture work is in
|
|
||||||
progress, then implemented on top of it.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### 1. The new `Widget` trait
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait Widget: Any {
|
|
||||||
/// Draw within `painter.region()` (the space the parent offered) and
|
|
||||||
/// report how much of it was actually used, per axis.
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
|
||||||
|
|
||||||
/// True if `draw`'s output (both the primitives it writes and the
|
|
||||||
/// `Size` it returns) is the same for any `painter.region()` of the
|
|
||||||
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
|
||||||
/// image at its natural size. Default `false` (redraw on any change to
|
|
||||||
/// the offered region) because assuming independence wrongly produces
|
|
||||||
/// a stale draw; a widget must opt in.
|
|
||||||
fn is_size_independent(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No `available` parameter: `Painter` already carries the region the parent
|
|
||||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
|
||||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
|
||||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
|
||||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
|
||||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
|
||||||
deprecated, not kept as a fallback — because a widget that implements both
|
|
||||||
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
|
||||||
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
|
||||||
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
|
||||||
own comment: "this literally copies draw so that the lengths are correctly
|
|
||||||
set in the context, which makes this slow and not cool." Folding sizing into
|
|
||||||
`draw` deletes that duplicate simulation, not just moves it.
|
|
||||||
|
|
||||||
**No single-draw alternative was found that does less work per frame.** The
|
|
||||||
two-method trait was checked against three properties a real screen needs —
|
|
||||||
a row placing children in sequence, a widget centering on its own content,
|
|
||||||
and wrapped text — and in every one, `draw` already has to visit the child
|
|
||||||
to get a size that is *this specific one's* answer, which today's
|
|
||||||
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
|
||||||
the same layout the draw pass will do again. So the two-method trait is not
|
|
||||||
"measure once, draw once" in the general case; it is "measure once per axis,
|
|
||||||
then draw once," i.e. up to three visits per widget per frame, against one
|
|
||||||
under the design here. The single-draw model is therefore adopted as
|
|
||||||
proposed, not merely accepted as a preference.
|
|
||||||
|
|
||||||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
|
||||||
|
|
||||||
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
|
||||||
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
|
||||||
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
|
||||||
`active.region.size() == region.size()` after excluding the exact-match
|
|
||||||
case). It rewrites every primitive's `region` field via
|
|
||||||
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
|
||||||
widget's own primitives, then recurses into every child — O(primitives in
|
|
||||||
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
|
||||||
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
|
||||||
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
|
||||||
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
|
||||||
re-touches every glyph in every visible row, every frame of the drag, and
|
|
||||||
I3's target is 800 rows on screen.
|
|
||||||
|
|
||||||
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
|
||||||
resolved in the vertex shader.**
|
|
||||||
|
|
||||||
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
|
||||||
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
|
||||||
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
|
||||||
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
|
||||||
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
|
||||||
add nothing more"). A pure abs-pixel translation, not a general
|
|
||||||
`UiRegion` remap — sufficient for every existing call site (above).
|
|
||||||
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
|
||||||
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
|
||||||
kind of per-instance handle.
|
|
||||||
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
|
||||||
assigned **when the widget is first drawn** (`draw_inner`, beside
|
|
||||||
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
|
||||||
`Painter` threads a `move_slot` field down exactly as it already threads
|
|
||||||
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
|
||||||
correct from its first frame — nothing is ever retrofitted onto an
|
|
||||||
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
|
||||||
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
|
||||||
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
|
||||||
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
|
||||||
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
|
||||||
recursion, since descendants already reference this slot transitively.
|
|
||||||
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
|
||||||
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
|
||||||
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
|
||||||
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
|
||||||
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
|
||||||
paid every frame regardless of whether anything moved — negligible next
|
|
||||||
to the per-fragment texture sampling TEXTURES.md already measures this
|
|
||||||
GPU as not bound by.
|
|
||||||
|
|
||||||
**Why the chain, not the flatter thing first proposed.** Iris's own
|
|
||||||
phrasing — "every instance carries an index into a small per-widget offset
|
|
||||||
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
|
||||||
no parent link. It breaks the moment two such subtrees nest — a row inside
|
|
||||||
a scrolling list, itself later given its own animated offset (a
|
|
||||||
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
|
||||||
pick one slot and lose the other's contribution. The chain costs one extra
|
|
||||||
field and a bounded shader loop in exchange for no such gap, and since
|
|
||||||
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
|
||||||
no more at the common depth of one than the flat version would.
|
|
||||||
|
|
||||||
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
|
||||||
O(primitives in the subtree) — the cost this section removes — but kept
|
|
||||||
for a resize that changes a region's `rel` component (a genuine reflow,
|
|
||||||
§3) and for a size-independent widget's resize (§3), where the content's
|
|
||||||
shape doesn't change and one field write already suffices.
|
|
||||||
|
|
||||||
### 2b. Two more readers of "where is this widget," and masks
|
|
||||||
|
|
||||||
Moving the offset into the vertex shader means `ActiveData.region` is no
|
|
||||||
longer the on-screen truth once a widget has been moved — it is where the
|
|
||||||
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
|
||||||
if it still were, and both must move to a resolved query or they silently
|
|
||||||
answer with the pre-move position: a click landing on a scrolled row would
|
|
||||||
be routed to whatever used to be there, with nothing on screen to say so —
|
|
||||||
exactly the "wrong answer that looks like a right one" case the code rules
|
|
||||||
single out.
|
|
||||||
|
|
||||||
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
|
||||||
does the actual pointer routing, and line 170 is the read in question:
|
|
||||||
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
|
||||||
immediately turned into pixels and tested against the cursor at `:171-172`.
|
|
||||||
Under this design that region must be resolved through the same chain the
|
|
||||||
GPU walks before it means anything. Add to `UiRenderState`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
|
||||||
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
|
||||||
/// walk, over the same arena, so the two cannot disagree about where a
|
|
||||||
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
|
||||||
/// `move_offsets`, bounded by the same constant the shader loop uses
|
|
||||||
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
|
||||||
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
|
||||||
/// const across the language boundary).
|
|
||||||
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
|
||||||
```
|
|
||||||
|
|
||||||
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
|
||||||
coordinate query already used outside hit-testing
|
|
||||||
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
|
||||||
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
|
||||||
before `.to_px(...)` instead of reading `.region` directly — one change
|
|
||||||
covers both call sites listed there. `sense.rs:170` changes to
|
|
||||||
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
|
||||||
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
|
||||||
`resolved_region` costs one arena read to find that out, so there is no
|
|
||||||
version of this design where skipping the fix is a legitimate
|
|
||||||
optimization — it is a correctness gap, not a performance one.
|
|
||||||
|
|
||||||
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
|
||||||
painter's *current* region into a `Mask` pushed onto
|
|
||||||
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
|
||||||
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
|
||||||
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
|
||||||
widget that called `set_mask` — `Masked::draw`,
|
|
||||||
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
|
||||||
is itself later moved, its clip rectangle stays where it was drawn while
|
|
||||||
its content moves out from under it: a visibly wrong clip, immediately on
|
|
||||||
screen, not a latency question.
|
|
||||||
|
|
||||||
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
|
||||||
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
|
||||||
the mask-owning widget's own primitives already get (§2), not a second
|
|
||||||
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
|
||||||
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
|
||||||
currently computes the mask's `top_left`/`bot_right` inline from
|
|
||||||
`masks[in.mask_idx]`; that computation is extended to walk the same
|
|
||||||
move-offset chain §2 added, via one shared function —
|
|
||||||
|
|
||||||
```wgsl
|
|
||||||
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
|
||||||
for its mask's corners, so the walk is written once and the two stages
|
|
||||||
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
|
||||||
hand-copied second one in the other shader stage).
|
|
||||||
|
|
||||||
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
|
||||||
primitive's mask is frequently owned by a *different* widget than the
|
|
||||||
primitive itself — often several levels up a subtree, with its own,
|
|
||||||
independent move slot — so a primitive's resolved offset and its mask's
|
|
||||||
resolved offset are two different chain sums, both needed, and only the
|
|
||||||
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
|
||||||
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
|
||||||
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
|
||||||
mean, for every `mov()` call, walking forward to every mask instance the
|
|
||||||
moved widget's slot could affect and rewriting its raw region — exactly
|
|
||||||
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
|
||||||
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
|
||||||
frame (`:148`); one more arena read to resolve its chain costs nothing
|
|
||||||
extra in kind.
|
|
||||||
|
|
||||||
**The scroll-container case, checked rather than assumed.** A masked,
|
|
||||||
scrollable region is built as a `Masked` wrapping a `Scroll`
|
|
||||||
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
|
||||||
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
|
||||||
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
|
||||||
every frame the user drags. Because each widget's move slot is its own
|
|
||||||
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
|
||||||
its own, stationary slot, while the scrolled content underneath references
|
|
||||||
a separate, deeper slot whose `parent` chain passes through — but does not
|
|
||||||
write to — the viewport's slot. Moving the content therefore never touches
|
|
||||||
the mask's resolved position, and the mask staying still while its content
|
|
||||||
slides past it is what this design already produces with no special case,
|
|
||||||
not an extra rule that had to be added for it.
|
|
||||||
|
|
||||||
### 3. Resize scope
|
|
||||||
|
|
||||||
A resize is "the region a widget's parent offers it changes such that the
|
|
||||||
widget's draw might produce different output" — as opposed to a move, which
|
|
||||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
|
||||||
narrowings apply, and both are real, measured properties of the code as it
|
|
||||||
stands rather than new machinery:
|
|
||||||
|
|
||||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
|
||||||
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
|
||||||
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
|
||||||
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
|
||||||
call to `px_size()`, `output_size()`, or anything else that reads a
|
|
||||||
concrete pixel count) is therefore already correct after a resize with zero
|
|
||||||
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
|
||||||
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
|
||||||
tree on every `resized`, which was the safe default while sizing and
|
|
||||||
drawing were two passes; it should be narrowed to only the widgets that
|
|
||||||
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
|
||||||
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
|
||||||
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
|
||||||
pixel-dependent by calling through `Painter` methods that read
|
|
||||||
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
|
||||||
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
|
||||||
walks only that set instead of unconditionally setting `resized = true`
|
|
||||||
for a full `redraw_all`. This turns "every resize redraws everything" into
|
|
||||||
"every resize redraws what depends on pixels" — a real behavior change
|
|
||||||
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
|
||||||
resize regression (that fix depended on `redraw_all`'s completeness)
|
|
||||||
before narrowing this.
|
|
||||||
|
|
||||||
**(b) A widget's `available` (its parent's offered region) can change
|
|
||||||
without the widget's *content* changing — this is what
|
|
||||||
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
|
||||||
(a sibling grew or shrank, changing this widget's offered box), a widget
|
|
||||||
that returns `true` from `is_size_independent` is not redrawn: its
|
|
||||||
primitives are unaffected by size, only by placement, so the parent
|
|
||||||
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
|
||||||
the primitive's `region` fields directly via `region_mut` if the box
|
|
||||||
changed shape too (still O(primitives owned directly by this widget, not
|
|
||||||
its subtree, since a size-independent widget by definition has no
|
|
||||||
size-dependent descendants worth distinguishing — in practice this is
|
|
||||||
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
|
||||||
`false` (the default) is redrawn in full whenever `available` changes,
|
|
||||||
which is correct always, just not free.
|
|
||||||
|
|
||||||
**Ancestor propagation** (a resized child changing its own reported size,
|
|
||||||
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
|
||||||
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
|
||||||
ancestors whose cached size differs from the new one and stops as soon as
|
|
||||||
a size is unchanged (`:274-286`). That loop moves from consulting
|
|
||||||
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
|
||||||
|
|
||||||
### 4. Wrapped text, and "needs child height before choosing width"
|
|
||||||
|
|
||||||
**Wrapped text is not a special case any more; it already reads as one
|
|
||||||
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
|
||||||
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
|
||||||
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
|
||||||
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
|
||||||
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
|
||||||
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
|
||||||
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
|
||||||
`self.view.draw(painter)` once, which internally still calls `render`
|
|
||||||
once, hits its own cache, and returns the size it already computed. No
|
|
||||||
new caching is needed here; the two now-redundant call sites
|
|
||||||
(`desired_width`/`desired_height` each separately calling `render`) simply
|
|
||||||
disappear, which is a second `render` avoided per frame per text widget
|
|
||||||
that is being measured by a parent.
|
|
||||||
|
|
||||||
**"Parent wants the child's height before deciding the width it will
|
|
||||||
offer"** — the genuinely circular case named in the brief, e.g. a column
|
|
||||||
that sizes its own width to its widest child, where that child is wrapped
|
|
||||||
text whose height (which the column's *own* height depends on) depends on
|
|
||||||
the width the column has not yet decided. This is not solvable in one pass
|
|
||||||
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
|
||||||
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
|
||||||
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
|
||||||
by an explicit second, throwaway pass (its own comment: "this literally
|
|
||||||
copies draw ... which makes this slow and not cool"). The design keeps that
|
|
||||||
resolution, made explicit rather than accidental: `Painter` gets
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Draw `child` at a provisional region to learn its size under one
|
|
||||||
/// axis's worth of assumption, discard everything it wrote, then draw it
|
|
||||||
/// again at the region that assumption produced. For the rare parent that
|
|
||||||
/// cannot pick an offered size without already knowing the answer.
|
|
||||||
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
|
||||||
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
|
||||||
```
|
|
||||||
|
|
||||||
implemented as: draw at `first`, record `Size`, remove the widget and its
|
|
||||||
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
|
||||||
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
|
||||||
which already frees the old primitives before redrawing) — reusing that
|
|
||||||
path rather than adding a second one — draw again at `second(size)`, return
|
|
||||||
the final `Size`. It is opt-in and named for its cost, so a widget only
|
|
||||||
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
|
||||||
call site converted to it, replacing the hand-rolled duplicate loop.
|
|
||||||
|
|
||||||
### 5. Caching and invalidation
|
|
||||||
|
|
||||||
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
|
||||||
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
|
||||||
answer, independent of drawing) no longer exists as a separate query, so
|
|
||||||
there is nothing left to cache at that layer. What already provides "an
|
|
||||||
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
|
||||||
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
|
||||||
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
|
||||||
immediately — no `Painter` constructed, no primitive touched, no shader
|
|
||||||
work beyond what the GPU already redraws from the unchanged instance
|
|
||||||
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
|
||||||
and it already operates at (id, region) granularity, which subsumes "(id,
|
|
||||||
available size)" once size *is* what a region change means.
|
|
||||||
|
|
||||||
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
|
||||||
returned, stored the moment it is (`draw_inner`, alongside building the
|
|
||||||
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
|
||||||
widget for a second frame without redrawing it (because nothing changed)
|
|
||||||
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
|
||||||
size question without a full draw" with "read the size of the last actual
|
|
||||||
draw," which is always available because `draw_inner`'s skip path is only
|
|
||||||
reachable once the widget has been drawn at least once. `Cache::remove`/
|
|
||||||
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
|
||||||
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
|
||||||
`render_state.rs:171-198`, freed with the widget).
|
|
||||||
|
|
||||||
### 6. Before / after
|
|
||||||
|
|
||||||
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
impl Widget for Rect {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
|
||||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
|
||||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
|
||||||
}
|
|
||||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
|
||||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// after
|
|
||||||
impl Widget for Rect {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
||||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
|
||||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
|
||||||
Size::REST // fills whatever it was given -- used == available
|
|
||||||
}
|
|
||||||
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**A container that needs the child's size before placing it,
|
|
||||||
`iris/src/widget/position/align.rs`**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
impl Widget for Aligned {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
|
||||||
let region = match self.align.tuple() {
|
|
||||||
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
|
||||||
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
|
||||||
UiRegion::new(x, UiSpan::FULL) }
|
|
||||||
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
|
||||||
UiRegion::new(UiSpan::FULL, y) }
|
|
||||||
(None, None) => UiRegion::FULL,
|
|
||||||
};
|
|
||||||
painter.widget_within(&self.inner, region);
|
|
||||||
}
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// after
|
|
||||||
impl Widget for Aligned {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
||||||
let full = painter.region();
|
|
||||||
// Draw once at the full region to learn the child's real size --
|
|
||||||
// this placement is provisional and corrected below without a
|
|
||||||
// second draw.
|
|
||||||
let used = painter.widget_within(&self.inner, full);
|
|
||||||
let region = match self.align.tuple() {
|
|
||||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
|
||||||
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
|
||||||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
|
||||||
(None, None) => full,
|
|
||||||
};
|
|
||||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
|
||||||
used
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
|
||||||
return type from `()` to `Size`, carrying the child's `draw` result back —
|
|
||||||
the only signature change needed to let a parent see what its child used.
|
|
||||||
`Painter::reposition` is new, computing the delta between where a child
|
|
||||||
was actually drawn and where it belongs and calling the O(1) `mov` from
|
|
||||||
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
|
||||||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
|
||||||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
|
||||||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
|
||||||
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
|
||||||
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
|
||||||
|
|
||||||
### 7. Migration — every file and widget that changes
|
|
||||||
|
|
||||||
One change, in dependency order (rename-and-move-together, per the code
|
|
||||||
rules — no intermediate state with both trait shapes):
|
|
||||||
|
|
||||||
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
|
||||||
`WidgetAxisFns`, update `impl Widget for ()`.
|
|
||||||
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
|
||||||
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
|
||||||
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
|
||||||
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
|
||||||
`len_axis`; `primitive_at` writes `move_idx`.
|
|
||||||
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
|
||||||
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
|
||||||
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
|
||||||
instead of `Cache.size`.
|
|
||||||
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
|
||||||
`move_slot: MoveIdx`.
|
|
||||||
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
|
||||||
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
|
||||||
new `MoveOffset` struct.
|
|
||||||
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
|
||||||
and `Primitives::write`, matching `mask_idx`.
|
|
||||||
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
|
||||||
(group 2, beside `masks`) and its update path.
|
|
||||||
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
|
||||||
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
|
||||||
function (§2b) called from both `vs_main` (a primitive's own corners)
|
|
||||||
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
|
||||||
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
|
||||||
and `window_region` (`:264-267`) reimplemented on top of it.
|
|
||||||
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
|
||||||
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
|
||||||
(§2b) — the pointer-routing fix this design requires, not an optional
|
|
||||||
follow-up.
|
|
||||||
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
|
||||||
`move_idx: u32` (§2b).
|
|
||||||
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
|
||||||
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
|
||||||
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
|
||||||
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
|
||||||
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
|
||||||
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
|
||||||
never depends on the region it is offered, same as `Rect`),
|
|
||||||
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
|
||||||
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
|
||||||
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
|
||||||
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
|
||||||
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
|
||||||
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
|
||||||
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
|
||||||
`iris/src/widget/position/pad.rs` (`Pad`),
|
|
||||||
`iris/src/widget/position/stack.rs` (`Stack`),
|
|
||||||
`iris/src/widget/position/offset.rs` (`Offset`),
|
|
||||||
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
|
||||||
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
|
||||||
`iris/src/widget/position/sized.rs` (`Sized`).
|
|
||||||
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
|
||||||
across `core/` and `src/`; re-run it before starting, since it is the
|
|
||||||
authoritative check that nothing was missed, not this paragraph.
|
|
||||||
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
|
||||||
`impl Widget` found in any example (verified by the same grep); they use
|
|
||||||
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
|
||||||
source change, which is itself part of the pass condition below.
|
|
||||||
|
|
||||||
### 8. Pass conditions
|
|
||||||
|
|
||||||
1. **Every example under `iris/examples` renders identically.** Run
|
|
||||||
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
|
||||||
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
|
||||||
"looks right," since a subtle wrap or alignment regression is exactly
|
|
||||||
what a diff catches and a glance does not.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
|
|
||||||
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
|
|
||||||
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
|
|
||||||
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
|
|
||||||
file bytes (`cmp` alone is not conclusive across two separately-encoded
|
|
||||||
PNGs, though it happened to agree here for `minimal`). Before-shots were
|
|
||||||
taken with `git stash` at the pre-change commit; `tabs` needed two real
|
|
||||||
fixes (deviations 1 and 2 below) before it stopped differing — the other
|
|
||||||
three matched on the first try.
|
|
||||||
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
|
||||||
the existing `debug_layers`/`active_widgets` instrumentation
|
|
||||||
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
|
||||||
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
|
||||||
one example (`tabs`, since it already has multiple widgets and an
|
|
||||||
interactive element) through one frame with nothing changed and report
|
|
||||||
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
|
||||||
for a frame in which nothing was marked dirty, resized, or moved.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, 0 and 0.** Implemented as
|
|
||||||
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
|
|
||||||
rewrites, `move_offsets` writes — a third counter, for condition 3
|
|
||||||
below), reset on read. Measured in
|
|
||||||
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
|
|
||||||
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
|
|
||||||
see the note on condition 3 for why this runs as a plain unit test
|
|
||||||
instead).
|
|
||||||
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
|
||||||
which exactly one widget is moved (not resized) with N primitives in its
|
|
||||||
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
|
||||||
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
|
||||||
the case with a `tabs`-style example holding a deliberately large text
|
|
||||||
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
|
||||||
an O(N) regression would show up as a non-trivial write count rather
|
|
||||||
than being lost in noise.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
|
|
||||||
write, N = 500.** Built with rects rather than glyphs
|
|
||||||
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
|
|
||||||
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
|
|
||||||
this runs as a plain `cargo test`, not through `run-headless.sh` — a
|
|
||||||
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
|
|
||||||
faster and more precise than reading counters out of a real example's
|
|
||||||
stderr. Getting a clean single move took two follow-up fixes beyond the
|
|
||||||
design as written (deviation 3, the `parent_move_slot` threading; and
|
|
||||||
the `Scroll` design decision below about offering last frame's content
|
|
||||||
length) — without either, the count was in the thousands (every rect in
|
|
||||||
the subtree redrawing) rather than 1.
|
|
||||||
4. **Hit-testing follows the move, not just the render.** In the same
|
|
||||||
scrolled-`tabs` construction as condition 3, scroll the content, then
|
|
||||||
send a synthetic cursor position over a widget that moved and assert
|
|
||||||
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
|
||||||
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
|
||||||
This is a correctness check, not a timing one — §2b's fix is required
|
|
||||||
before §2 can ship at all, and this is what would fail silently
|
|
||||||
(nothing on screen indicates a missed or misrouted hit) if it were
|
|
||||||
skipped.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass**, but checked one level below
|
|
||||||
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
|
|
||||||
scrolls a widget and asserts `UiRenderState::resolved_region` (the
|
|
||||||
query `run_sensors`'s hit-test and `window_region` both now go through,
|
|
||||||
per §2b) reports the moved, not the pre-scroll, position — within
|
|
||||||
0.01px of the exact expected delta. `run_sensors` itself needs a
|
|
||||||
`HasEvents`/window/cursor-state harness this pass did not build; the
|
|
||||||
coverage that matters (does the position query the router uses reflect
|
|
||||||
the move) is exercised directly instead.
|
|
||||||
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
|
||||||
both before and after scrolling it (`iris/run-headless.sh` against a
|
|
||||||
small purpose-built example, or an addition to `tabs`), and diff the
|
|
||||||
two frames: the clipped edge of the content must have moved with the
|
|
||||||
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
|
||||||
stays put — the specific case worked through in §2b. A mask rectangle
|
|
||||||
that stayed at its pre-scroll position while its content slid past it
|
|
||||||
is the regression this checks for, and it is visible in a single
|
|
||||||
screenshot, not just in a counter.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, checked numerically rather than by
|
|
||||||
screenshot.** No example in this repository builds a `Masked`-wrapped
|
|
||||||
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
|
|
||||||
scrolling, not this widget), so there was nothing to screenshot without
|
|
||||||
first authoring a new example. Checked instead in
|
|
||||||
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
|
|
||||||
on the exact data the fragment shader's `resolve_move` reads: the
|
|
||||||
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
|
|
||||||
and after scrolling its content, because `Masked` is never itself the
|
|
||||||
target of a move — only its child is, on a separate, deeper slot in the
|
|
||||||
chain (§2b's "scroll-container case, checked rather than assumed"). A
|
|
||||||
pixel-level screenshot check of this remains open; see RUST.md's next
|
|
||||||
step.
|
|
||||||
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
|
||||||
stay clean at the defaults (iris has no tests today per I0b, so this is
|
|
||||||
presently only clippy/fmt; add the first real widget-layer tests here if
|
|
||||||
the move-offset chain or `draw_twice` are non-trivial enough to want
|
|
||||||
one, per "match the codebase's testing posture" — judge that once the
|
|
||||||
code exists rather than pre-committing to a number of tests here).
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
|
|
||||||
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
|
|
||||||
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
|
|
||||||
`winit` future-incompatibility, from dependencies, not this change).
|
|
||||||
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
|
|
||||||
ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0
|
|
||||||
failed — the move-offset chain turned out non-trivial enough (three real
|
|
||||||
bugs found only by writing it) to clearly clear the "match the testing
|
|
||||||
posture" bar this section left open.
|
|
||||||
|
|
||||||
### 9. Rejected, and why
|
|
||||||
|
|
||||||
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
|
||||||
phrasing — rejected in §2 for breaking under nested independent moves
|
|
||||||
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
|
||||||
chain is the same mechanism with one more field.
|
|
||||||
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
|
||||||
as the steady-state path (O(primitives in subtree), exactly what a
|
|
||||||
transcript scroll must not pay every frame) but kept for resize-shaped
|
|
||||||
changes (§3) where the content's own region field, not an ancestor
|
|
||||||
chain, is what has to change.
|
|
||||||
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
|
||||||
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
|
||||||
implement to skip a draw when a cheap answer exists) — considered and
|
|
||||||
rejected: it reintroduces exactly the "two names for one concept" split
|
|
||||||
this change removes, for a saving `is_size_independent` (§1, §3b)
|
|
||||||
already covers for the cases where it would actually help (fixed-size
|
|
||||||
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
|
||||||
not (unlikely in this codebase's widget set, but conceivable) is better
|
|
||||||
served by that widget caching its own draw output internally — exactly
|
|
||||||
the pattern `TextView::render` already uses (§4) — than by a second
|
|
||||||
trait method every implementor has to reason about.
|
|
||||||
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
|
||||||
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
|
||||||
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
|
||||||
which already carries the same information into every widget that needs
|
|
||||||
it; adding a parameter would just be a second route to a value already
|
|
||||||
reachable, and would invite the two drifting apart.
|
|
||||||
- **Eagerly propagating a moved widget's delta into every descendant's own
|
|
||||||
offset value** (rather than chaining and resolving in the shader) —
|
|
||||||
rejected as O(descendant widgets), which is smaller than O(primitives)
|
|
||||||
but still not O(1), and the shader-side chain costs nothing extra to get
|
|
||||||
the better bound.
|
|
||||||
|
|
||||||
## Deviations found during implementation (2026-09-04)
|
|
||||||
|
|
||||||
Five corrections this file's first draft did not anticipate, each found by
|
|
||||||
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
|
|
||||||
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
|
|
||||||
`draw_inner`/`reposition` — not by reasoning about the design in the
|
|
||||||
abstract. Recorded here rather than silently fixed in place, per the code
|
|
||||||
rules' escape-hatch requirement.
|
|
||||||
|
|
||||||
1. **`Aligned`'s provisional draw must call `painter.widget`, not
|
|
||||||
`widget_within(&self.inner, painter.region())`.** §6's original text drew
|
|
||||||
the sample as the latter. `widget_within` composes its `region` argument
|
|
||||||
as *local*, `UiRegion::FULL`-relative coordinates against
|
|
||||||
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
|
|
||||||
self.region` relies on); handing it `painter.region()` itself —
|
|
||||||
already-resolved, window-relative coordinates — composes that frame a
|
|
||||||
second time. For the root widget this is silently the identity (its
|
|
||||||
region already is `[0,1]`), which is why it can look correct in a
|
|
||||||
trivial case and only breaks once something is nested — i.e. always, in
|
|
||||||
practice. Symptom: a centered child rendered at a wildly wrong offset
|
|
||||||
nested more than one level deep. Fixed by using `painter.widget`, which
|
|
||||||
hands the child `self.region` unmodified, with no second composition.
|
|
||||||
|
|
||||||
2. **A widget that reports a size smaller than its offered region must
|
|
||||||
actually paint at that size, anchored top-left of what it was given —
|
|
||||||
not fill the full offered region while merely *reporting* a smaller
|
|
||||||
number.** `Sized` and `MaxSize` both had exactly this bug: their
|
|
||||||
`desired_width`/`desired_height` predecessors capped the *reported*
|
|
||||||
value but their `draw` bodies called `painter.widget(&self.inner)`
|
|
||||||
unconstrained, which was harmless under the old two-pass model (a parent
|
|
||||||
always queried the size *before* drawing, so by the time `draw` ran the
|
|
||||||
offered region already matched) but wrong under `Aligned`'s new
|
|
||||||
provisional-draw-then-reposition pattern, which offers the *whole*
|
|
||||||
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
|
|
||||||
rendered stretched to fill its whole row instead of a 100×100 square.
|
|
||||||
Fixed by having both widgets carve the declared sub-region (`UiSpan`
|
|
||||||
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
|
|
||||||
they were offered before drawing the child in it. `Image` needed the
|
|
||||||
same treatment from the start (`texture_within` at its own natural size,
|
|
||||||
not `texture()` at the full offered region) and was written that way in
|
|
||||||
the first pass, once this was understood; `Rect`'s "fill whatever I'm
|
|
||||||
given" is the one case where painting the *whole* offered region really
|
|
||||||
is the declared behavior, so it needed no change.
|
|
||||||
|
|
||||||
3. **The move-offset chain's `parent` link cannot be found by looking up
|
|
||||||
the parent's `ActiveData` in `draw_inner`, because the parent's
|
|
||||||
`ActiveData` does not exist yet while its own `Widget::draw` is still
|
|
||||||
running.** `ActiveData` is inserted only after `draw` returns
|
|
||||||
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
|
|
||||||
through its parent's `draw` body — the ordinary case, since every
|
|
||||||
composite widget draws its children from inside its own `draw` — would
|
|
||||||
always read "no parent" from `self.active`, silently orphaning it at the
|
|
||||||
root of the chain. Fixed by threading the parent's `move_slot` down
|
|
||||||
through `Painter` (it already carries `mask`/`layer` the same way) and
|
|
||||||
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
|
|
||||||
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
|
|
||||||
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
|
|
||||||
target's parent genuinely is already active at that call site — the
|
|
||||||
doc comment on it says which is which. Symptom: `reposition` computed
|
|
||||||
the right delta and wrote it to the right slot, but the shader never
|
|
||||||
saw it, because the primitive doing the actual painting chained to
|
|
||||||
`u32::MAX` one level too early.
|
|
||||||
|
|
||||||
4. **`Painter::reposition` cannot reuse `active.region` as "where the
|
|
||||||
widget currently is," because for a widget offered more room than it
|
|
||||||
used, `active.region` is the *offered* box, not the *painted* one.**
|
|
||||||
This only matters for `reposition` (used by `Aligned`); `mov` (used by
|
|
||||||
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
|
|
||||||
and `Offset`) has no such gap, because there the offered region *is*
|
|
||||||
the visual footprint — content is sized to fill exactly what it is
|
|
||||||
given. `reposition` instead reconstructs "from" as `active.size`
|
|
||||||
(already tracked, per §5) anchored at `AxisAlign::Neg` within
|
|
||||||
`active.region` — i.e. it assumes the child painted itself top-left of
|
|
||||||
whatever it was offered, per point 2's convention — and **overwrites**
|
|
||||||
the slot's delta rather than accumulating it the way `mov` does, since
|
|
||||||
"from" is recomputed fresh from stable inputs every call and repeating
|
|
||||||
the same `reposition` (an unrelated redraw elsewhere re-running this
|
|
||||||
widget's parent) must not drift further each time. The one shape this
|
|
||||||
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
|
|
||||||
`reposition` may have moved its content away from top-left already. No
|
|
||||||
widget or example in this codebase builds that today; if one needs to,
|
|
||||||
`reposition` would need the child to report *where* it painted, not
|
|
||||||
just how big, which is a larger change than this pass's scope.
|
|
||||||
|
|
||||||
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
|
|
||||||
draw, and reused in place — never reallocated — for every later redraw
|
|
||||||
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
|
|
||||||
spelled out in §2's original text, which only said slots are assigned
|
|
||||||
"when the widget is first drawn." Reallocating a fresh slot on every
|
|
||||||
redraw would leave any *retained* (not-redrawn) descendant's `parent`
|
|
||||||
link pointing at a now-orphaned old slot — a permanent leak, and worse,
|
|
||||||
a descendant that silently stops tracking its ancestor's future moves.
|
|
||||||
Resetting the delta on reuse (rather than carrying it forward) is
|
|
||||||
required because a full redraw bakes the widget's correct absolute
|
|
||||||
position into the fresh `region` argument directly; a stale delta left
|
|
||||||
over from before the redraw would double-offset it.
|
|
||||||
|
|
||||||
Two further points worth recording because they were *design decisions*
|
|
||||||
made while implementing, not bugs — `LAYOUT.md`'s own text left them
|
|
||||||
unspecified rather than getting them wrong:
|
|
||||||
|
|
||||||
- **`Scroll` offers its content a region sized by the *previous* frame's
|
|
||||||
measured content length, not a fresh one.** A fresh measurement would
|
|
||||||
require drawing the content once to learn its size and — since that
|
|
||||||
provisional size essentially never matches the previously active one —
|
|
||||||
redrawing it a second time at the real size, on every single scroll
|
|
||||||
tick, which is exactly the cost §2 exists to remove. Using the stale
|
|
||||||
length means an ordinary scroll (position changes, content does not)
|
|
||||||
offers the same *size* as last frame, only shifted, which is what makes
|
|
||||||
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
|
|
||||||
change lags one frame before the container's scroll range reflects it,
|
|
||||||
self-correcting the frame after (the content length itself, read from
|
|
||||||
what was actually drawn, is never stale — only the offered *region* used
|
|
||||||
for placement is). No example in this repository builds a `Scroll` yet,
|
|
||||||
so this could not be checked against a pixel diff; it is covered instead
|
|
||||||
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
|
|
||||||
build a tree and drive `UiRenderState` directly with no GPU or window
|
|
||||||
needed.
|
|
||||||
- **`redraw()`'s parent-relayout check draws the widget first, then
|
|
||||||
compares the fresh `ActiveData.size` the draw produced against the size
|
|
||||||
from before removal** — the mirror image of the old code's "query size,
|
|
||||||
compare, decide whether to draw," which no longer has a size query to
|
|
||||||
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
|
|
||||||
along with `desired_width`/`desired_height`). This can occasionally draw
|
|
||||||
a widget once more than the old code would have (if the parent it
|
|
||||||
bubbles up to ends up redrawing the same widget again as part of its own
|
|
||||||
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
|
|
||||||
redundancy for free, and this path is not one of §8's measured
|
|
||||||
conditions, so the remaining slack was accepted rather than chased
|
|
||||||
further.
|
|
||||||
|
|
||||||
## For IRIS.md
|
|
||||||
|
|
||||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
|
||||||
|
|
||||||
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
|
||||||
> `desired_height` are gone.** A widget used to implement three methods
|
|
||||||
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
|
||||||
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
|
||||||
> `painter.region()` and returns how much of it was used. Why: the two
|
|
||||||
> extra methods routinely re-simulated what `draw` was about to do anyway
|
|
||||||
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
|
||||||
> right) — one visit per widget per frame instead of up to three. A
|
|
||||||
> container that needs a child's size before placing it (alignment,
|
|
||||||
> centering) draws the child once at a provisional region, reads the
|
|
||||||
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
|
||||||
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
|
||||||
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
|
||||||
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
|
||||||
> bool { false }` to `true`, which skips redrawing it when only its
|
|
||||||
> offered region changes shape.
|
|
||||||
>
|
|
||||||
> ```rust
|
|
||||||
> // before
|
|
||||||
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
|
||||||
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
>
|
|
||||||
> // after
|
|
||||||
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
|
||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
|
||||||
> list.
|
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
# Subagents
|
||||||
|
|
||||||
|
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
|
||||||
|
collaboration tools -- each get a transcript of their own, listed in a panel
|
||||||
|
over the open session and readable in the same transcript view the session has.
|
||||||
|
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
|
||||||
|
2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
||||||
|
|
||||||
|
## What a subagent is here
|
||||||
|
|
||||||
|
**A subagent is a second transcript owned by a session, in the same event
|
||||||
|
model, with no process and no controls.** It is not a session: it cannot be
|
||||||
|
messaged, stopped or started, and it has no machine, model or usage of its
|
||||||
|
own. Everything it shares with a session -- the transcript file format, the
|
||||||
|
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||||
|
by addressing, not by copying.
|
||||||
|
|
||||||
|
Claude reports a subagent's messages on the parent's own stream-json output,
|
||||||
|
each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started
|
||||||
|
it. Before this the translator dropped those lines
|
||||||
|
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||||
|
them to that subagent's own translator and transcript. The parent's
|
||||||
|
transcript still shows only the Task call itself.
|
||||||
|
|
||||||
|
Codex app-server multiplexes every thread in the session tree onto the root
|
||||||
|
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
|
||||||
|
items name the child thread and its lifecycle, and `collabAgentToolCall`
|
||||||
|
items carry the spawn prompt. The Codex translator routes a non-root
|
||||||
|
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
|
||||||
|
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
|
||||||
|
a `PeerMessage`, not assistant text from the recipient. Its delta notification
|
||||||
|
does not repeat the completed item's `delivery` field, so the translator
|
||||||
|
remembers that field from `item/started` and suppresses those deltas. Letting
|
||||||
|
one into the recipient's provisional assistant row makes its next completed
|
||||||
|
message replace the combined row, visibly erasing text that Codex still has.
|
||||||
|
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
|
||||||
|
closes it when the matching `subAgentActivity.started` arrives. The remaining
|
||||||
|
collaboration calls remain visible as coordination -- waiting, messaging,
|
||||||
|
listing and lifecycle controls -- rather than being mistaken for generic task
|
||||||
|
output. Null optional fields and a bare `completed` status carry no information
|
||||||
|
and are omitted; their useful result is the child transcript, status or peer
|
||||||
|
message beside them.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Under the session directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
<session>/subagents/<subagent_id>/meta.json {title, created}
|
||||||
|
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||||
|
```
|
||||||
|
|
||||||
|
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
|
||||||
|
Both are unique, stable across a backend restart, and already the key their
|
||||||
|
parent-side lifecycle uses.
|
||||||
|
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||||
|
id becomes a path.
|
||||||
|
|
||||||
|
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||||
|
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||||
|
|
||||||
|
Its path out: deleting the session deletes its directory, subagents included,
|
||||||
|
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
|
||||||
|
-- all or nothing, and refused while any named one is still running, since its
|
||||||
|
transcript is still being written to and its process is the session's to stop.
|
||||||
|
|
||||||
|
## Lifecycle, as events in the subagent's transcript
|
||||||
|
|
||||||
|
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
|
||||||
|
`task_started` with `task_type: local_agent` is a recovery source when an
|
||||||
|
adopted stream begins after that call. A bare `parent_tool_use_id` is not
|
||||||
|
enough: other operations can also parent nested lines, and treating one as
|
||||||
|
proof created false subagents named after their first subcommand.
|
||||||
|
First lines written:
|
||||||
|
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||||
|
the prompt is known -- it genuinely is the subagent's first user turn.
|
||||||
|
2. Every child line is translated by that subagent's own `Translator`
|
||||||
|
(one per subagent: tool ids are unique but streaming deltas are by
|
||||||
|
content-block index, and parallel subagents interleave).
|
||||||
|
3. **What ends a subagent is the CLI's own task lifecycle**, on top-level
|
||||||
|
`system` lines that carry no `parent_tool_use_id`: `task_started`
|
||||||
|
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
|
||||||
|
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
|
||||||
|
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
|
||||||
|
-- the agent's own report). `translate_task` keeps the
|
||||||
|
`task_id -> tool_use_id` mapping from the first so the update can be
|
||||||
|
attributed, records the summary as the subagent's closing text, and writes
|
||||||
|
`Status Exited`. A
|
||||||
|
`completed` update is deliberately not the end: its notification carries
|
||||||
|
the summary and would otherwise land after the ending. Any other terminal
|
||||||
|
status ends it from the update, since the failure to avoid is a subagent
|
||||||
|
nothing ever finishes.
|
||||||
|
|
||||||
|
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
|
||||||
|
the authoritative level beside those edges: its set replaces the previous
|
||||||
|
set, so a missed terminal edge cannot leave a subagent running forever. Its
|
||||||
|
ids are deliberately not correlated with the edge stream; what is read off
|
||||||
|
each entry is its own description and kind, and what is read off the set is
|
||||||
|
whether it is empty and how large. The session API and stream expose that
|
||||||
|
size as `backgroundTasks`, which the phone draws beside the status, and
|
||||||
|
`GET /sessions/{id}/background` serves the entries themselves -- listed in
|
||||||
|
the session's panel *above* the subagents and never as subagent cards. An
|
||||||
|
`ambient` entry is excluded from both, on the CLI's own instruction: a
|
||||||
|
live-update watcher is not activity. A backgrounded subagent is legitimately
|
||||||
|
in both lists, since it is both running and a transcript. The edges still
|
||||||
|
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
|
||||||
|
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
|
||||||
|
leaving the edge-based path unchanged. A snapshot is reconciled immediately
|
||||||
|
when the persisted parent status proves it is between turns, and otherwise
|
||||||
|
at the next `result` boundary -- while a turn is open, a foreground agent is
|
||||||
|
legitimately absent from the background set. Reconciliation writes
|
||||||
|
`Status Exited`, which is also what makes a formerly stale row deletable;
|
||||||
|
a task notification ordered after the level can still add its summary.
|
||||||
|
|
||||||
|
The two rules this replaces were both wrong, in opposite directions. The
|
||||||
|
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
|
||||||
|
("Async agent launched..."), so ending there truncated a running agent's
|
||||||
|
transcript at the moment it started. Nor is the subagent's own
|
||||||
|
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
|
||||||
|
carry no `stream_event` at all** -- they are whole `user`/`assistant`
|
||||||
|
lines with a null `stop_reason`, no `result` line is sent for one, and the
|
||||||
|
sub's final report never appears as a child line -- so that rule could
|
||||||
|
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
|
||||||
|
kept as a second detector for a dialect that does say either, and must
|
||||||
|
never be the only one again.
|
||||||
|
|
||||||
|
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
|
||||||
|
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
||||||
|
session is dropped rather than written.
|
||||||
|
|
||||||
|
**The ending reaches the parent's transcript as nothing at all**
|
||||||
|
(2026-09-06). It was tried, and a row per finished subagent is a screenful
|
||||||
|
of dividers about work the reader was not asking after; the closing report
|
||||||
|
is *this* transcript's last line and here is where somebody reads it. What
|
||||||
|
the parent gets a row for is a message a subagent genuinely sends it, which
|
||||||
|
arrives by the peer path. A backgrounded *command* is the other half of
|
||||||
|
this and goes the other way: it has no transcript of its own, so its report
|
||||||
|
updates the tool card that launched it, which was still saying the command
|
||||||
|
was running. The two lifecycle shapes are still handled once:
|
||||||
|
whichever gets there first is the one that finds the task still open, and
|
||||||
|
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
|
||||||
|
one".
|
||||||
|
|
||||||
|
**While any task is outstanding the session's turn ends in
|
||||||
|
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
||||||
|
and a session with a backgrounded subagent is not doing that. The edge
|
||||||
|
fallback has two sources: the translator's `open_tasks`, and
|
||||||
|
`Subagents::any_open` -- which covers a subagent launched before a backend
|
||||||
|
restart adopted the session, whose `task_started` is behind the durable
|
||||||
|
stdout offset. On current Claude versions the replace-semantics level above
|
||||||
|
reconciles both at a safe turn boundary.
|
||||||
|
|
||||||
|
**A limit the account hits inside a subagent is hoisted to the session**
|
||||||
|
as well as recorded here, because `resume.rs` can only schedule against a
|
||||||
|
session, and a background subagent outliving its parent's turn is the
|
||||||
|
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
|
||||||
|
4. **A child line for a subagent that already finished reopens it**
|
||||||
|
(`Status Running`) rather than being dropped: a background Task can be
|
||||||
|
sent another message long after its first turn ended, and that is
|
||||||
|
exactly what a further line for it means. Same transcript, same child
|
||||||
|
`Translator`, just picking back up.
|
||||||
|
5. When the parent session's process exits (`Status Exited` on the
|
||||||
|
session), every subagent still `Running` gets `Status Exited` too: its
|
||||||
|
process was the parent's. Read from the directory rather than from the
|
||||||
|
live map, because one left `Running` by a previous run of the server is
|
||||||
|
precisely the one nothing in this process has touched -- and it would
|
||||||
|
otherwise read `running` again every time its session was started.
|
||||||
|
|
||||||
|
For Codex the same lifecycle is expressed by app-server rather than Claude's
|
||||||
|
task notices: `subAgentActivity.started` creates the child,
|
||||||
|
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
|
||||||
|
finishes it. A child's own `turn/completed` is not its end; it remains running
|
||||||
|
until that activity edge. The root's `turn/completed` reports `waiting` while
|
||||||
|
the registry contains an open child, and the last activity completion reports
|
||||||
|
`idle` if the root is between turns. Because the child thread id is also the
|
||||||
|
on-disk id, an adopted driver can route and finish a child whose spawn record
|
||||||
|
is already behind the durable stdout offset. The registry's open count is also
|
||||||
|
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
|
||||||
|
same event and session-summary fields as Claude's provider snapshot. The other
|
||||||
|
part of that measurement is app-server's runtime
|
||||||
|
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
|
||||||
|
and added to the open-child count; the driver refreshes the set at terminal
|
||||||
|
boundaries and while it remains nonempty, rather than decrementing for an
|
||||||
|
unmatched ending edge.
|
||||||
|
|
||||||
|
A subagent that was mid-flight when the backend restarted keeps working:
|
||||||
|
the registry reopens the existing transcript on the next child line, and
|
||||||
|
the file continues its sequence -- the same reopening #4 describes, whether
|
||||||
|
what closed it was a restart or its own `end_turn`. If its turn ended while
|
||||||
|
the backend was down nothing recorded that until the next line arrives, so
|
||||||
|
its last status stays `Running`, which the list reports as **unknown**
|
||||||
|
rather than as running (see the wire shape) until then.
|
||||||
|
|
||||||
|
Title: for Claude, the Task call's `description` input, then
|
||||||
|
` (<subagent_type>)` when one is given; falling back to `Task` when the
|
||||||
|
description is absent. An adopted current CLI can recover the same fields from
|
||||||
|
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
|
||||||
|
the spawned thread's name or the last segment of `agentPath`, with underscores
|
||||||
|
shown as spaces, then falls back to `subagent`.
|
||||||
|
|
||||||
|
## Server layout
|
||||||
|
|
||||||
|
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||||
|
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||||
|
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||||
|
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk, and
|
||||||
|
`delete(ids)` -- its path out. Drivers get an
|
||||||
|
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||||
|
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||||
|
one child `Translator` per subagent, remembers pending Task calls'
|
||||||
|
description/prompt/subagent_type.
|
||||||
|
- `session/codex/translate.rs` -- routes multiplexed app-server notifications
|
||||||
|
by thread id, remembers collaboration prompts, and translates activity
|
||||||
|
edges into the same registry lifecycle.
|
||||||
|
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
||||||
|
subagents at once, each named "helper k". Each writes the prompt as its
|
||||||
|
user message, streams a few words of text, runs one `Bash` tool call, then
|
||||||
|
finishes about three seconds after starting, and the parent's Task calls
|
||||||
|
end when their subagent does. Three seconds so the running state can be
|
||||||
|
seen on the phone.
|
||||||
|
- `routes.rs` -- four routes, in the doc table.
|
||||||
|
|
||||||
|
## Wire shape
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
||||||
|
GET /sessions same field on each row
|
||||||
|
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
||||||
|
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
||||||
|
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
||||||
|
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
|
||||||
|
```
|
||||||
|
|
||||||
|
The delete is a batch rather than a `DELETE` per id for the reason the import
|
||||||
|
list's is: the phone deletes what a reader selected, and one request per row
|
||||||
|
means a batch can half-arrive, leaving the rows that were missed looking
|
||||||
|
exactly like rows nobody picked. Unlike an import delete it is local file
|
||||||
|
removal, so it is done by the time the reply is sent and there is no per-row
|
||||||
|
state to follow afterwards. What decides "running" is
|
||||||
|
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
|
||||||
|
list and the delete cannot disagree about it.
|
||||||
|
|
||||||
|
`status` is the transcript's last `Status` event, serialised like a session's
|
||||||
|
(`running`, `exited`), except that a subagent whose session is not itself
|
||||||
|
running cannot be running: the list answers `unknown` for that one. A
|
||||||
|
subagent never reports `waiting`: that is a session's word for having
|
||||||
|
outstanding work of its own, and a subagent has none. The
|
||||||
|
phone words these as *running*, *finished* and *unknown* on the subcard.
|
||||||
|
|
||||||
|
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||||
|
The per-subagent status is only read when the list route is asked for.
|
||||||
|
|
||||||
|
## Phone
|
||||||
|
|
||||||
|
- The subcards are ordered **still running first, then most recently
|
||||||
|
active** -- a display decision made on the phone (`subagentOrder`), over the
|
||||||
|
server's stable oldest-first answer. Two keys rather than activity alone
|
||||||
|
because a subagent that is thinking reports nothing meanwhile and would sink
|
||||||
|
below one that just finished.
|
||||||
|
- **Holding a subcard selects it, and several at a time**, exactly as the
|
||||||
|
import list works, with the selection bar drawn inside the panel rather than
|
||||||
|
at the bottom of the session: this selection belongs to the subagent list,
|
||||||
|
and a bar under the composer would read as acting on the conversation.
|
||||||
|
Delete is
|
||||||
|
*disabled*, with the reason in words, while anything selected is still
|
||||||
|
running. Deleting confirms first, dims the rows it is acting on
|
||||||
|
(`BusyItem`), and on success takes them out of the panel without refetching
|
||||||
|
anything else. The phone's cached copy of
|
||||||
|
a deleted subagent's transcript is purged with it.
|
||||||
|
- The main session list does not expand or count subagents. Swiping left over
|
||||||
|
an open session pulls an 88%-wide panel in from the right and fetches
|
||||||
|
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
|
||||||
|
then the status word and a relative time. The transcript remains composed
|
||||||
|
under the panel, so its event stream, draft and scroll position stay live.
|
||||||
|
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
|
||||||
|
or starting over any ordinary part of the session, gives the gesture back to
|
||||||
|
the panel; Android keeps its own edge Back gesture. Swiping right on the
|
||||||
|
panel, tapping outside it, or Back closes it.
|
||||||
|
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
|
||||||
|
same transcript, paging, cache, selection,
|
||||||
|
images and status row, with the composer, the process button, the model
|
||||||
|
picker, the files button, the settings cog and the usage bar left out.
|
||||||
|
The header shows the subagent's title with the session's title beneath it.
|
||||||
|
It is another layer over the still-composed session and its panel; Back
|
||||||
|
returns to the panel.
|
||||||
|
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||||
|
cache take a transcript address rather than a session id --
|
||||||
|
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||||
|
subagent's copy under its session's and the same code serves both.
|
||||||
-496
@@ -1,496 +0,0 @@
|
|||||||
# How iris should render an unbounded number of images
|
|
||||||
|
|
||||||
## Status (2026-09-04)
|
|
||||||
|
|
||||||
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
|
||||||
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
|
||||||
what landed, what differs from the proposal below and why, and what was
|
|
||||||
verified versus merely reasoned about. The short version: the binding array
|
|
||||||
is gone, `request_device` asks for no features and no binding-array limits,
|
|
||||||
and that is now proven on the emulator's software Vulkan
|
|
||||||
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
|
||||||
is resolved.
|
|
||||||
|
|
||||||
Iris (the person) asked whether iris's (the library's) approach to
|
|
||||||
"draw however many images happen to be on screen" — relevant here because a
|
|
||||||
transcript can hold an unbounded number of attached screenshots — actually
|
|
||||||
works on mobile, her recollection being that it does not. Checked rather
|
|
||||||
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
|
||||||
file is that investigation and the resulting recommendation, written for a
|
|
||||||
second agent to review before anything in iris's render core changes — no
|
|
||||||
code has been written against this yet.
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
Every texture iris ever creates — every `Image` widget
|
|
||||||
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
|
||||||
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
|
||||||
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
|
||||||
array by index: `core/src/render/shader.wgsl:56` declares
|
|
||||||
`var views: binding_array<texture_2d<f32>>`, sized by
|
|
||||||
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
|
||||||
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
|
||||||
features — `TEXTURE_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
|
||||||
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
|
||||||
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
|
||||||
|
|
||||||
A transcript with an unbounded number of image attachments is exactly the
|
|
||||||
case that grows this array without bound: each attachment becomes its own
|
|
||||||
`Image` widget, which takes its own permanent array slot until dropped.
|
|
||||||
|
|
||||||
## What was measured
|
|
||||||
|
|
||||||
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
|
||||||
and limits with no window and no APK — a plain executable pushed with
|
|
||||||
`adb push` and run from `/data/local/tmp`. It has two parts:
|
|
||||||
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
|
||||||
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
|
||||||
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
|
||||||
detect it."
|
|
||||||
|
|
||||||
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
|
||||||
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
|
||||||
discrete desktop GPU — but it is why the design was never checked before
|
|
||||||
now: it always worked in the one place it was tried.
|
|
||||||
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
|
||||||
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
|
||||||
`request_device` **fails** —
|
|
||||||
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
|
||||||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
|
||||||
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
|
||||||
driver itself reporting all seven descriptor-indexing sub-features as
|
|
||||||
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
|
||||||
being more conservative than the driver here, for a reason not chased
|
|
||||||
further (a likely instance-version negotiation gap, since the extension
|
|
||||||
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
|
||||||
not the finding that matters, and is **not** why this design is rejected.
|
|
||||||
|
|
||||||
**The finding that matters is about real phones, sourced rather than
|
|
||||||
recalled:**
|
|
||||||
|
|
||||||
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
|
||||||
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
|
||||||
of October 2025
|
|
||||||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
|
||||||
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
|
||||||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
|
||||||
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
|
||||||
unrelated to bindless) and stops there; true of the 2021 and 2022
|
|
||||||
profiles as well.
|
|
||||||
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
|
||||||
is supported on all Valhall and 5th Gen GPUs"**
|
|
||||||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
|
||||||
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
|
||||||
for Bifrost, Midgard or Utgard, which are still common in budget and
|
|
||||||
older Android phones that are still in daily use.
|
|
||||||
- A search engine's summarized claim of "1% support on Android" for this
|
|
||||||
extension was checked against its cited source (an Arm blog post from
|
|
||||||
2021) and **was not actually there** — that number does not appear in
|
|
||||||
any primary source found and should not be repeated. The baseline-
|
|
||||||
profile finding above is the one with an attributable source; use it
|
|
||||||
instead.
|
|
||||||
|
|
||||||
So this is not a software-renderer artifact. A real, currently-shipping
|
|
||||||
share of the Android fleet lacks the feature iris's texture pipeline asks
|
|
||||||
for unconditionally, and neither the emulator's failure nor the current
|
|
||||||
official hardware baseline gives any reason to expect that to change soon.
|
|
||||||
|
|
||||||
## What growth already costs today, before any redesign
|
|
||||||
|
|
||||||
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
|
||||||
because "does this redesign make things worse" needs the current baseline
|
|
||||||
first:
|
|
||||||
|
|
||||||
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
|
||||||
rebuilt for any reason related to texture count — its bind group
|
|
||||||
*layouts* declare fixed slot counts (`limits.max_textures`,
|
|
||||||
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
|
||||||
was never at risk of recreating the pipeline, in the current design or
|
|
||||||
any redesign discussed below.
|
|
||||||
- What **does** get rebuilt: `UiRenderNode::update` calls
|
|
||||||
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
|
||||||
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
|
||||||
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
|
||||||
fresh over **every currently-live texture**, plus the sampler array and
|
|
||||||
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
|
||||||
— an image added anywhere in the whole app rebuilds one shared structure
|
|
||||||
referencing every other image too.
|
|
||||||
- The one path already excluded from this, on purpose, is a `Patch` —
|
|
||||||
writing into an existing texture's pixels without changing which
|
|
||||||
textures exist. The code says why directly
|
|
||||||
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
|
||||||
changes texture contents, not the binding array, so it must not report
|
|
||||||
`changed` — rebuilding the bind group per glyph is the cost this exists
|
|
||||||
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
|
||||||
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
|
||||||
nothing else.
|
|
||||||
|
|
||||||
So today, growth that stays inside an existing texture (glyphs added to an
|
|
||||||
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
|
||||||
page, or any standalone image — already rebuilds the one shared array
|
|
||||||
regardless of how the array is populated, before any change discussed
|
|
||||||
below. That existing cost is O(live texture count) in CPU work to collect
|
|
||||||
the view list and in however expensive the driver finds a
|
|
||||||
descriptor-set-sized-for-N-descriptors to be.
|
|
||||||
|
|
||||||
## Prior art, checked rather than assumed
|
|
||||||
|
|
||||||
Two independent projects were checked to see whether "atlas for images"
|
|
||||||
is actually how this is normally done, rather than a guess:
|
|
||||||
|
|
||||||
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
|
||||||
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
|
||||||
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
|
||||||
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
|
||||||
no array, no descriptor indexing of any kind. Draw calls are batched by
|
|
||||||
texture id and the bind group is switched between batches within the
|
|
||||||
render pass.
|
|
||||||
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
|
||||||
through — hit the identical problem and wrote down why in their own
|
|
||||||
roadmap document
|
|
||||||
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
|
||||||
*"The number of images that may appear in a scene is not bounded, which
|
|
||||||
is not a good fit for the basic descriptor binding model... Until then,
|
|
||||||
we'll do a workaround of having a single atlas image containing all the
|
|
||||||
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
|
||||||
has no descriptor indexing at all — but it reaches the same conclusion
|
|
||||||
for the same shape of problem: atlas, not a bigger bindless array.
|
|
||||||
|
|
||||||
**This is also a live hazard, not a solved one.** Vello's own changelog
|
|
||||||
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
|
||||||
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
|
||||||
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
|
||||||
in the renderer Masonry is built on. The same release added
|
|
||||||
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
|
||||||
utilization, fragmentation) because growth needed instrumenting in
|
|
||||||
production, not because it turned out to be free.
|
|
||||||
|
|
||||||
## Recommendation (not yet implemented)
|
|
||||||
|
|
||||||
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
|
||||||
downscaled attachment previews, icons — go through a shared atlas, the
|
|
||||||
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
|
||||||
Adding one to an existing page is a `Patch`, already free per the
|
|
||||||
section above.
|
|
||||||
2. **Large or one-off images** — a photo attachment opened at full
|
|
||||||
resolution, anything that would fragment a shared page — get their
|
|
||||||
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
|
||||||
is O(1): it references only itself, and does not touch any other
|
|
||||||
texture's binding, unlike today's shared array where every push
|
|
||||||
rebuilds a structure listing everything.
|
|
||||||
3. **Opening a new atlas page** is the one case that still resembles
|
|
||||||
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
|
||||||
not by how many images have ever been attached) but not free, and
|
|
||||||
Vello's Mali-G52 fix says this specifically deserves care: it should
|
|
||||||
never be allowed to block a frame, and it is worth having the
|
|
||||||
equivalent of Vello's atlas diagnostics before trusting it under load.
|
|
||||||
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
|
||||||
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
|
||||||
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
|
||||||
This is also what fixes the emulator failure measured above, regardless
|
|
||||||
of the unresolved wgpu-hal question: a device that never asks for the
|
|
||||||
feature cannot be refused for lacking it.
|
|
||||||
|
|
||||||
## What this touches, and what is still open
|
|
||||||
|
|
||||||
Implementing this reworks iris's rendering core: the shader's binding
|
|
||||||
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
|
||||||
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
|
||||||
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
|
||||||
batching (today one draw call can reference any texture by index; the
|
|
||||||
per-texture-bind-group path needs draws grouped by which bind group they
|
|
||||||
use). Nothing has been started.
|
|
||||||
|
|
||||||
Open questions a reviewer should weigh in on:
|
|
||||||
|
|
||||||
- **The size threshold** between "goes in an atlas page" and "gets its own
|
|
||||||
bind group." Too low and ordinary attachment thumbnails end up as
|
|
||||||
one-off bind groups, losing the batching benefit the atlas exists for;
|
|
||||||
too high and a page fragments on a handful of medium images.
|
|
||||||
- **Eviction policy** for atlas pages once the working set does not fit —
|
|
||||||
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
|
||||||
and bounded; images are not. An LRU at the page level, or at the
|
|
||||||
individual-image level within a page, has not been designed.
|
|
||||||
- **Whether iris should keep any binding array at all**, even a small
|
|
||||||
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
|
||||||
or whether every atlas page should also be its own ordinary bind group
|
|
||||||
like standalone images — the array's only remaining justification would
|
|
||||||
be avoiding a bind-group-per-draw-call switch cost that has not been
|
|
||||||
measured on this project's actual target hardware.
|
|
||||||
- **How this interacts with I2/E2's virtualised list** (I3): a
|
|
||||||
bottom-anchored transcript composes only visible rows, so the live
|
|
||||||
texture set should already be bounded by what is on screen rather than
|
|
||||||
by the whole conversation — worth confirming that invariant holds before
|
|
||||||
relying on it to keep atlas/bind-group churn small.
|
|
||||||
|
|
||||||
## Review, 2026-09-04
|
|
||||||
|
|
||||||
A second pass over the file above against the code, done before anything
|
|
||||||
is implemented. Iris's worry going in: a bind group per texture means a
|
|
||||||
draw call per image, and she wants this as efficient as it can be.
|
|
||||||
|
|
||||||
### What checked out
|
|
||||||
|
|
||||||
Every code reference above is accurate as of this commit: the 100,000 /
|
|
||||||
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
|
||||||
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
|
||||||
asks for the three features is `iris/src/default/render.rs:96`, which the
|
|
||||||
text above does not name. egui-wgpu and Vello are described correctly.
|
|
||||||
|
|
||||||
### The emulator refusal is a wgpu-hal gap, now located
|
|
||||||
|
|
||||||
The file guessed "a likely instance-version negotiation gap." It is
|
|
||||||
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
|
||||||
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
|
||||||
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
|
||||||
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
|
||||||
that has descriptor indexing as core need not list the extension, and
|
|
||||||
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
|
||||||
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
|
||||||
The properties query beside it (line 1486) correctly accepts
|
|
||||||
`device_api_version >= 1.2 || extension`; the features query does not.
|
|
||||||
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
|
||||||
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
|
||||||
design: on real phones the gate that matters is stricter still.
|
|
||||||
|
|
||||||
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
|
||||||
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
|
||||||
sampled images, storage images and storage buffers, all together, because
|
|
||||||
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
|
||||||
"the extension is supported on Valhall" is necessary but not sufficient;
|
|
||||||
a driver with sampled-image indexing and without storage-buffer
|
|
||||||
update-after-bind is refused too. That widens the excluded set beyond
|
|
||||||
what the Arm quote suggests and strengthens the conclusion.
|
|
||||||
|
|
||||||
### A live bug in the current code, found on the way
|
|
||||||
|
|
||||||
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
|
||||||
"a patch must not report changed" as `changed = false`, unconditionally,
|
|
||||||
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
|
||||||
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
|
||||||
pushes the page and `insert` patches it in the same frame, so the bind
|
|
||||||
group is not rebuilt and the new page's view is not bound until some
|
|
||||||
unrelated texture change happens to rebuild it. It is hidden today only
|
|
||||||
because the masks path also sets `changed`. The fix is one line
|
|
||||||
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
|
||||||
the redesign since that code is being replaced, and it is recorded here
|
|
||||||
so it is not rediscovered.
|
|
||||||
|
|
||||||
### In-layer draw order is already undefined
|
|
||||||
|
|
||||||
Relevant to any batching redesign: `Primitives::apply_free`
|
|
||||||
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
|
||||||
order within a layer is permuted whenever anything is freed. Overlap order
|
|
||||||
inside one layer is therefore not something the renderer promises today;
|
|
||||||
ordering is done with layers. That means grouping a layer's draws by
|
|
||||||
texture, or drawing a layer's images after its rects and glyphs, loses
|
|
||||||
nothing that currently exists. It should be written down as an invariant
|
|
||||||
when the redesign lands, because the new code will depend on it.
|
|
||||||
|
|
||||||
### On "a draw call per image"
|
|
||||||
|
|
||||||
Two corrections to the worry. First, it is a draw per *distinct texture per
|
|
||||||
layer*, not per image primitive: every glyph quad in a layer shares the
|
|
||||||
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
|
||||||
for previews. Second, the count is bounded by what is on screen, which I3's
|
|
||||||
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
|
||||||
bound at tens of draws per frame; egui ships exactly this on Android. What
|
|
||||||
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
|
||||||
the current code already creates a `primitive_group` bind group every time
|
|
||||||
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
|
||||||
regression in kind.
|
|
||||||
|
|
||||||
### Recommended shape (proposal, for Iris to accept or change)
|
|
||||||
|
|
||||||
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
|
||||||
|
|
||||||
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
|
||||||
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
|
||||||
constraint an array texture imposes. A layer index is an ordinary
|
|
||||||
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
|
||||||
(and any future atlased-image primitive) carries a layer instead of a
|
|
||||||
`view_idx` and all of a layer's text stays **one draw**. This answers
|
|
||||||
the open question above about keeping a small binding array: no. Cost
|
|
||||||
of opening a page: recreate the array with one more layer and
|
|
||||||
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
|
||||||
with headroom (double) so it is rare. wgpu's default
|
|
||||||
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
|
||||||
rather than the API.
|
|
||||||
2. **Every standalone image is its own texture with its own bind group**,
|
|
||||||
and its instances live in a **separate per-layer instance list**, not
|
|
||||||
the main one. Then the main instance buffer never contains an image,
|
|
||||||
there is nothing to sort, no handle remapping beyond what
|
|
||||||
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
|
||||||
its bind group set first. Group 2's layout becomes `{atlas array,
|
|
||||||
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
|
||||||
image in the image slot, each image draw binds its own. One pipeline,
|
|
||||||
one shader, one layout.
|
|
||||||
3. **No thumbnail atlas in the first version.** With images on their own
|
|
||||||
textures, the threshold and eviction questions above disappear: an
|
|
||||||
image is freed when the row that owns its `TextureHandle` scrolls out.
|
|
||||||
Add an image atlas only if a measured screen shows enough small images
|
|
||||||
to matter, which a transcript rarely does.
|
|
||||||
4. **Drop the three features and the two `max_binding_array_*` limits from
|
|
||||||
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
|
||||||
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
|
||||||
downscaled attachment will alias. Either request a filtering sampler
|
|
||||||
for the image slot or downscale on the CPU before upload; decide when
|
|
||||||
the image widget is touched, not as part of this.
|
|
||||||
|
|
||||||
What this costs against the file's original recommendation: `Textures`
|
|
||||||
needs to know an image from a page (two kinds of handle, or a kind on
|
|
||||||
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
|
||||||
What it saves: the sort, the size threshold, the eviction policy, and any
|
|
||||||
per-page bind group switch.
|
|
||||||
|
|
||||||
## Implemented, 2026-09-04
|
|
||||||
|
|
||||||
The shape above, built as proposed with one structural addition the proposal
|
|
||||||
didn't need to spell out and one bug it predicted made moot rather than
|
|
||||||
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
|
||||||
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
|
||||||
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
|
||||||
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
|
||||||
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
|
||||||
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
|
||||||
`rigs/gpu-probe/src/main.rs`.
|
|
||||||
|
|
||||||
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
|
||||||
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
|
||||||
(`grow_array`): a new texture is created at twice the layer capacity, the
|
|
||||||
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
|
||||||
readback), and every bind group that referenced the old view — the main
|
|
||||||
one and every live standalone image's — is rebuilt, since the view's
|
|
||||||
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
|
||||||
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
|
||||||
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
|
||||||
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
|
||||||
any GPU sync happens — the renderer only finds out later, when it
|
|
||||||
processes the queued `Push`.
|
|
||||||
|
|
||||||
**2. Standalone images, one bind group each.** `TextureKind` on
|
|
||||||
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
|
||||||
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
|
||||||
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
|
||||||
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
|
||||||
than through the generic `Primitive` trait, since an image has nowhere in
|
|
||||||
`PrimitiveData` to put a per-instance entry once the bind group already
|
|
||||||
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
|
||||||
once as before, then walks `image_instance` one entry at a time, binding
|
|
||||||
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
|
||||||
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
|
||||||
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
|
||||||
1x1 null view in the image slot.
|
|
||||||
|
|
||||||
**The one addition beyond the proposal**: the masks storage buffer lives
|
|
||||||
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
|
||||||
recreates its buffer whenever the mask count changes size
|
|
||||||
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
|
||||||
A resize invalidates every bind group holding the old buffer, not just the
|
|
||||||
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
|
||||||
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
|
||||||
array-growth path already needed. This wasn't a design question the
|
|
||||||
proposal had to answer (it treated bind-group construction as a given),
|
|
||||||
but it's exactly the shape of trap layer growth already had, so it uses
|
|
||||||
the same fix.
|
|
||||||
|
|
||||||
**3. No thumbnail atlas.** Not built, as proposed.
|
|
||||||
|
|
||||||
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
|
||||||
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
|
||||||
itself, not just its binding-array methods — once its two fields were
|
|
||||||
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
|
||||||
a limits parameter). `binding_array` no longer appears anywhere in
|
|
||||||
`shader.wgsl`.
|
|
||||||
|
|
||||||
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
|
||||||
note that this is a separate decision for whenever the image widget itself
|
|
||||||
is touched.
|
|
||||||
|
|
||||||
**The `changed = false` bug is structurally gone, not patched.** The old
|
|
||||||
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
|
||||||
unconditionally, which could erase an earlier `Push` in the same batch (a
|
|
||||||
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
|
||||||
`Patch`, both queued before the renderer ever runs). The new `update`
|
|
||||||
computes the rebuild signal by OR-ing each event's own answer
|
|
||||||
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
|
||||||
contributes to it — there is no shared mutable flag left for a `Patch` to
|
|
||||||
stomp on. Documented at the call site
|
|
||||||
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
|
||||||
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
|
||||||
the mechanism that could go wrong no longer exists.
|
|
||||||
|
|
||||||
**In-layer draw order is an explicit invariant now, not just a fact about
|
|
||||||
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
|
||||||
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
|
||||||
directly that both of a layer's lists (`instances` and `images`) free with
|
|
||||||
`swap_remove` and that nothing may assume adjacency survives a free —
|
|
||||||
recorded there because `apply_free` is the one place a change to either
|
|
||||||
list's ordering would have to be reconciled.
|
|
||||||
|
|
||||||
**Verified:**
|
|
||||||
|
|
||||||
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
|
||||||
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
|
||||||
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
|
||||||
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
|
||||||
test to — it's all GPU resource wiring).
|
|
||||||
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
|
||||||
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
|
||||||
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
|
||||||
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
|
||||||
- The standalone-image path specifically: a throwaway example (not
|
|
||||||
committed) with an `image(...)` widget as part of the root, run the same
|
|
||||||
way, rendered the image next to glyph-atlas text in one frame —
|
|
||||||
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
|
||||||
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
|
||||||
"image span" tab exercises the same widget but needs a click to reach,
|
|
||||||
which the headless compositor can't deliver (no seat devices, per I1's
|
|
||||||
own note on this file) — the throwaway example is what stood in for it.
|
|
||||||
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
|
||||||
Rather than building a purpose-made glyph flood, `PAGE`
|
|
||||||
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
|
||||||
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
|
||||||
exotic: a handful of `Text` widgets at a few sizes, one at
|
|
||||||
`Family::Monospace`) already exceeds one page's worth of distinct
|
|
||||||
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
|
||||||
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
|
||||||
glyphs landed on at least a third layer), and
|
|
||||||
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
|
||||||
correctly with no corruption or missing glyphs — confirming the
|
|
||||||
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
|
||||||
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
|
||||||
both work. Command:
|
|
||||||
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
|
||||||
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
|
||||||
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
|
||||||
throwaway diagnostic value, never a committed change, since a real
|
|
||||||
1024px page holding only a handful of glyphs at a time would be mostly
|
|
||||||
wasted space in normal use. Confirmed the revert left `tabs` and
|
|
||||||
`minimal` byte-identical to the pre-check screenshots afterward.
|
|
||||||
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
|
||||||
(empty) feature/limit set and run on this checkout's own emulator
|
|
||||||
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
|
||||||
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
|
||||||
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
|
||||||
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
|
||||||
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
|
||||||
at all, which reads exactly like the old bindless failure if you don't
|
|
||||||
know to ask for `EMU_GPU=software`):
|
|
||||||
|
|
||||||
cd rigs/gpu-probe
|
|
||||||
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
|
||||||
cargo ndk -t arm64-v8a -P 26 build --release
|
|
||||||
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
|
||||||
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
|
||||||
adb shell chmod 755 /data/local/tmp/gpu-probe
|
|
||||||
adb shell /data/local/tmp/gpu-probe
|
|
||||||
|
|
||||||
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
|
||||||
`features iris requires:` (none listed — the set is empty),
|
|
||||||
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
|
||||||
measured working, on the exact rig that first measured it failing.
|
|
||||||
Emulator stopped afterward (`emu down`); nothing was left running.
|
|
||||||
@@ -5,6 +5,10 @@ one in place when it turns out to need a decision.
|
|||||||
|
|
||||||
## App — transcript
|
## App — transcript
|
||||||
|
|
||||||
|
- [ ] Decide how running background tasks can be inspected. For now the session
|
||||||
|
status shows only the provider-reported count; command details stay in
|
||||||
|
their existing tool cards and must not become subagent cards.
|
||||||
|
|
||||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
||||||
measured: a live Claude session only learns of a peer message from the
|
measured: a live Claude session only learns of a peer message from the
|
||||||
@@ -33,3 +37,21 @@ one in place when it turns out to need a decision.
|
|||||||
that would work today, for Claude sessions, and it is the option that was
|
that would work today, for Claude sessions, and it is the option that was
|
||||||
not chosen.
|
not chosen.
|
||||||
|
|
||||||
|
|
||||||
|
## A session the server could not load
|
||||||
|
|
||||||
|
- [ ] **A session whose transcript will not parse is skipped with nothing but a
|
||||||
|
log line, and from the phone it looks exactly like an idle unresponsive
|
||||||
|
one.** `SessionManager::new` catches a failing `launch` and logs
|
||||||
|
"couldn't relaunch session <id>", so the session has no pump and no
|
||||||
|
driver: no status, no history, nothing sendable. That is what the
|
||||||
|
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
|
||||||
|
needed a report from him rather than being visible in the app.
|
||||||
|
`Event::Unreadable` removes the cause that time, but not the class — an
|
||||||
|
unreadable `process.json`, a provider edited away and an unreachable host
|
||||||
|
all reach the same place.
|
||||||
|
This is the "design the unknown state first" rule: a session the server
|
||||||
|
could not load is not a session with nothing to say, and only the phone
|
||||||
|
can show the difference. It needs a status the wire can carry for it —
|
||||||
|
the failure with its reason, reported on the session itself — rather than
|
||||||
|
the reader having to tell it apart from silence.
|
||||||
Generated
-1081
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "android-shell"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
|
||||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
|
||||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
|
||||||
# the manifest by name). Everything they would otherwise have done in
|
|
||||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
|
||||||
# picking a session for a share -- is here instead, built on `client-core`
|
|
||||||
# so the networking and parsing are not duplicated a third time next to the
|
|
||||||
# server and the Kotlin app.
|
|
||||||
#
|
|
||||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
|
||||||
# on a normal host target without an Android NDK toolchain, the same
|
|
||||||
# posture `client-core` and `server` already have.
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "android_shell"
|
|
||||||
crate-type = ["cdylib", "lib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
client-core = { path = "../client-core" }
|
|
||||||
jni = "0.22"
|
|
||||||
log = "0.4"
|
|
||||||
|
|
||||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
|
||||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
|
||||||
# without a backend installed -- so without this, every recoverable error
|
|
||||||
# at a native entry point would be silently dropped rather than reaching
|
|
||||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
|
||||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
|
||||||
# is used).
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
android_logger = "0.15"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
|
||||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
|
||||||
//!
|
|
||||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
|
||||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
|
||||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
|
||||||
//! literal macro call by call -- these are a handful of short, one-off
|
|
||||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
|
||||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
|
||||||
//! `FieldSignature`, which is why those go through
|
|
||||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
|
||||||
//! parsed form is what lets these calls skip re-validating the signature
|
|
||||||
//! against the arguments on every call, which is the whole reason `jni`
|
|
||||||
//! moved to it.
|
|
||||||
//!
|
|
||||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
|
||||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
|
||||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
|
||||||
//! `call_method` on an object it already has, which needs no such lookup)
|
|
||||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
|
||||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
|
||||||
//! That default is fine on a thread the JVM itself started -- an
|
|
||||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
|
||||||
//! from `android-shell`'s own background thread (the notification
|
|
||||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
|
||||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
|
||||||
//! never gave an app classloader. Framework classes
|
|
||||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
|
||||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
|
||||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
|
||||||
//! packaged inside this app's own APK. The failure was
|
|
||||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
|
||||||
//! as "failed to resolve Java class ... (class not found or linkage
|
|
||||||
//! error)" -- on a real device this reads as "the notification silently
|
|
||||||
//! never arrives," since the whole call is inside the follow loop and the
|
|
||||||
//! ongoing foreground notification (built on the main thread, in
|
|
||||||
//! `try_start`, before the background thread exists) posts fine either
|
|
||||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
|
||||||
//! first time any entry point has a `Context` to ask, and every class
|
|
||||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
|
||||||
//! rather than the thread-dependent default -- so it is correct on the
|
|
||||||
//! main thread and on this crate's own background threads alike.
|
|
||||||
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
|
||||||
use jni::refs::{Global, LoaderContext};
|
|
||||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
|
||||||
use jni::strings::JNIString;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
|
||||||
/// Cheap to call from every entry point that has a `Context` on hand
|
|
||||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
|
||||||
/// a `OnceLock::get` and nothing else.
|
|
||||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
|
||||||
if CLASS_LOADER.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
|
||||||
// real objects throughout, so this needs no class-name lookup of its
|
|
||||||
// own and has nothing to bootstrap.
|
|
||||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
|
||||||
let loader_obj = call_method(
|
|
||||||
env,
|
|
||||||
&class_obj,
|
|
||||||
"getClassLoader",
|
|
||||||
"()Ljava/lang/ClassLoader;",
|
|
||||||
&[],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
|
||||||
let global = env.new_global_ref(&loader)?;
|
|
||||||
// Lost the race with another entry point calling this concurrently --
|
|
||||||
// both loaders name the same app, so either one is fine and there is
|
|
||||||
// nothing to reconcile.
|
|
||||||
let _ = CLASS_LOADER.set(global);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
|
||||||
/// through the cached app classloader when one has been remembered, and
|
|
||||||
/// through the ordinary default otherwise -- which is every call made
|
|
||||||
/// before any entry point has run, and is also correct for a main-thread
|
|
||||||
/// caller, so there is no case this makes worse.
|
|
||||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
match CLASS_LOADER.get() {
|
|
||||||
Some(loader) => {
|
|
||||||
let binary_name = name.replace('/', ".");
|
|
||||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
|
||||||
}
|
|
||||||
None => env.find_class(JNIString::new(name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
resolve_class(env, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A new Java string as a plain `JObject` -- what every call site here
|
|
||||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
|
||||||
/// `JString: Into<JObject>` is the documented way across).
|
|
||||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
|
||||||
Ok(env.new_string(text)?.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_object<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JObject<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.new_object(class, sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
obj: &JObject,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_static_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_static_field<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
field: &str,
|
|
||||||
sig: &str,
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
|
||||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
|
||||||
//! design decisions.
|
|
||||||
//!
|
|
||||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
|
||||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
|
||||||
//! the macro derives the mangled export name and the JNI signature from the
|
|
||||||
//! Rust function itself, so the two cannot drift apart the way a
|
|
||||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
|
||||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
|
||||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
|
||||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
|
||||||
//! as an exception that would crash the app over something recoverable.
|
|
||||||
//!
|
|
||||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
|
||||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
|
||||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
|
||||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
|
||||||
//! warning for a binding nothing reads.
|
|
||||||
|
|
||||||
mod jcall;
|
|
||||||
mod notify;
|
|
||||||
mod settings;
|
|
||||||
mod share;
|
|
||||||
|
|
||||||
use jni::errors::LogErrorAndDefault;
|
|
||||||
use jni::objects::{JClass, JObject};
|
|
||||||
use jni::sys::jint;
|
|
||||||
use jni::{Env, NativeMethod, native_method};
|
|
||||||
|
|
||||||
/// Installs the `log` backend that routes to logcat, once per process.
|
|
||||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
|
||||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
|
||||||
/// call into the `log` facade's default no-op logger, and a real failure
|
|
||||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
|
||||||
/// crashing, since nothing on screen or in the log says a notification was
|
|
||||||
/// dropped. Called from every entry point below rather than a Java-side
|
|
||||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
|
||||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
|
||||||
fn ensure_logger() {
|
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
|
||||||
ONCE.call_once(|| {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
android_logger::init_once(
|
|
||||||
android_logger::Config::default()
|
|
||||||
.with_max_level(log::LevelFilter::Debug)
|
|
||||||
.with_tag("android-shell"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The parameters are spelled as their Java types, not as `JObject`: the
|
|
||||||
// macro encodes each argument into the exported symbol's JNI signature
|
|
||||||
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
|
||||||
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
|
||||||
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
|
||||||
// symbols that never resolve to each other, silently, with no compiler
|
|
||||||
// error on either side. `android.app.Activity` etc. have no dedicated
|
|
||||||
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
|
||||||
// implementation functions below (the "Built-in Types" note in
|
|
||||||
// `native_method!`'s docs).
|
|
||||||
const _: NativeMethod = native_method! {
|
|
||||||
java_type = "com.example.aiapp.shell.MainActivity",
|
|
||||||
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
|
||||||
error_policy = LogErrorAndDefault,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
|
||||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
|
||||||
fn native_handle_intent<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
_class: JClass<'local>,
|
|
||||||
activity: JObject<'local>,
|
|
||||||
intent: JObject<'local>,
|
|
||||||
) -> Result<(), jni::errors::Error> {
|
|
||||||
ensure_logger();
|
|
||||||
jcall::remember_class_loader(env, &activity)?;
|
|
||||||
share::handle_intent(env, &activity, &intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
const _: NativeMethod = native_method! {
|
|
||||||
java_type = "com.example.aiapp.shell.NotificationService",
|
|
||||||
static extern fn native_sync(context: android.content.Context) -> (),
|
|
||||||
error_policy = LogErrorAndDefault,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
|
||||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
|
||||||
/// itself. See `notify::sync`.
|
|
||||||
fn native_sync<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
_class: JClass<'local>,
|
|
||||||
context: JObject<'local>,
|
|
||||||
) -> Result<(), jni::errors::Error> {
|
|
||||||
ensure_logger();
|
|
||||||
jcall::remember_class_loader(env, &context)?;
|
|
||||||
notify::sync(env, &context)
|
|
||||||
}
|
|
||||||
|
|
||||||
const _: NativeMethod = native_method! {
|
|
||||||
java_type = "com.example.aiapp.shell.NotificationService",
|
|
||||||
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
|
||||||
error_policy = LogErrorAndDefault,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
|
||||||
fn native_on_start_command<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
_class: JClass<'local>,
|
|
||||||
service: JObject<'local>,
|
|
||||||
) -> Result<jint, jni::errors::Error> {
|
|
||||||
ensure_logger();
|
|
||||||
jcall::remember_class_loader(env, &service)?;
|
|
||||||
Ok(notify::on_start_command(env, service))
|
|
||||||
}
|
|
||||||
|
|
||||||
const _: NativeMethod = native_method! {
|
|
||||||
java_type = "com.example.aiapp.shell.NotificationService",
|
|
||||||
static extern fn native_on_destroy() -> (),
|
|
||||||
error_policy = LogErrorAndDefault,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
|
||||||
fn native_on_destroy<'local>(
|
|
||||||
_env: &mut Env<'local>,
|
|
||||||
_class: JClass<'local>,
|
|
||||||
) -> Result<(), jni::errors::Error> {
|
|
||||||
ensure_logger();
|
|
||||||
notify::on_destroy();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
//! Where a notification is said, and the foreground service that keeps
|
|
||||||
//! the connection open while the app is closed. Ported from
|
|
||||||
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
|
||||||
//! screen" / "hand to the app as a banner" branches: those read
|
|
||||||
//! process-wide state that only exists because a screen is drawn to
|
|
||||||
//! register against, and this experiment draws no screen yet (that is
|
|
||||||
//! E4's job, on iris). So every notification here takes the third branch
|
|
||||||
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
|
||||||
//! also exactly the case E3's pass condition asks for: **a notification
|
|
||||||
//! arrives with the app closed.**
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use client_core::api::UreqTransport;
|
|
||||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JObject, JValue};
|
|
||||||
use jni::sys::{JNI_TRUE, jint};
|
|
||||||
|
|
||||||
use crate::settings::{self, ServerSettings};
|
|
||||||
|
|
||||||
const ALERT_CHANNEL: &str = "sessions";
|
|
||||||
const ONGOING_CHANNEL: &str = "connection";
|
|
||||||
const ONGOING_ID: i32 = 1;
|
|
||||||
const ALERT_ID: i32 = 2;
|
|
||||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
|
||||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
|
||||||
|
|
||||||
/// Whether the follow-loop thread is already running. **A deviation from
|
|
||||||
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
|
||||||
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
|
||||||
/// on *every* call, with nothing to notice a previous one is still going --
|
|
||||||
/// and `sync()` calling `startForegroundService` when the service is
|
|
||||||
/// already running is an ordinary Android start, not a restart, so
|
|
||||||
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
|
||||||
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
|
||||||
/// the token) hits exactly this path and was observed opening **two**
|
|
||||||
/// concurrent connections to `/notifications` from one process -- caught
|
|
||||||
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
|
||||||
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
|
||||||
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
|
||||||
/// a live connection is a resource leak with no upside; worth carrying the
|
|
||||||
/// same guard back to `Notifications.kt` separately.
|
|
||||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
|
||||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
|
||||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
|
||||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
|
||||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
|
||||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
|
||||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
|
||||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
|
||||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
|
||||||
/// which is a decision affecting every caller of that trait, not just this
|
|
||||||
/// one -- left for whoever next depends on prompt shutdown.
|
|
||||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
|
||||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
|
||||||
"from",
|
|
||||||
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
|
||||||
&[JValue::Object(context)],
|
|
||||||
)?
|
|
||||||
.l()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_channel(
|
|
||||||
env: &mut Env,
|
|
||||||
manager: &JObject,
|
|
||||||
id: &str,
|
|
||||||
name: &str,
|
|
||||||
importance: i32,
|
|
||||||
) -> Result<()> {
|
|
||||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
|
||||||
let builder = crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
|
||||||
"(Ljava/lang/String;I)V",
|
|
||||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
|
||||||
)?;
|
|
||||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setName",
|
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
|
||||||
&[JValue::Object(&name_j)],
|
|
||||||
)?;
|
|
||||||
let channel = crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"build",
|
|
||||||
"()Landroidx/core/app/NotificationChannelCompat;",
|
|
||||||
&[],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
manager,
|
|
||||||
"createNotificationChannel",
|
|
||||||
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
|
||||||
&[JValue::Object(&channel)],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two channels, because they are two different things to be told -- see
|
|
||||||
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
|
||||||
/// importances here are copied from it exactly, since a phone that has
|
|
||||||
/// seen both apps should not learn two different vocabularies for the
|
|
||||||
/// same fact.
|
|
||||||
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
|
||||||
let manager = notification_manager(env, context)?;
|
|
||||||
let default = static_int(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
|
||||||
"IMPORTANCE_DEFAULT",
|
|
||||||
)?;
|
|
||||||
let min = static_int(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
|
||||||
"IMPORTANCE_MIN",
|
|
||||||
)?;
|
|
||||||
create_channel(
|
|
||||||
env,
|
|
||||||
&manager,
|
|
||||||
ALERT_CHANNEL,
|
|
||||||
"Sessions needing attention",
|
|
||||||
default,
|
|
||||||
)?;
|
|
||||||
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_intent_for<'l>(
|
|
||||||
env: &mut Env<'l>,
|
|
||||||
context: &JObject,
|
|
||||||
class_name: &str,
|
|
||||||
) -> Result<JObject<'l>> {
|
|
||||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
|
||||||
crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
"android/content/Intent",
|
|
||||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&target_class)],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
|
||||||
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
|
||||||
/// rather than string concatenation, for the same reason: an id needing
|
|
||||||
/// escaping must survive the round trip.
|
|
||||||
fn session_intent<'l>(
|
|
||||||
env: &mut Env<'l>,
|
|
||||||
context: &JObject,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<JObject<'l>> {
|
|
||||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
|
||||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&intent,
|
|
||||||
"setAction",
|
|
||||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
|
||||||
&[JValue::Object(&action_view)],
|
|
||||||
)?;
|
|
||||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
|
||||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"scheme",
|
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
|
||||||
&[JValue::Object(&scheme)],
|
|
||||||
)?;
|
|
||||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"authority",
|
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
|
||||||
&[JValue::Object(&authority)],
|
|
||||||
)?;
|
|
||||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"appendPath",
|
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
|
||||||
&[JValue::Object(&path)],
|
|
||||||
)?;
|
|
||||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&intent,
|
|
||||||
"setData",
|
|
||||||
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
|
||||||
&[JValue::Object(&uri)],
|
|
||||||
)?;
|
|
||||||
Ok(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pending_activity<'l>(
|
|
||||||
env: &mut Env<'l>,
|
|
||||||
context: &JObject,
|
|
||||||
intent: &JObject,
|
|
||||||
) -> Result<JObject<'l>> {
|
|
||||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
|
||||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"android/app/PendingIntent",
|
|
||||||
"getActivity",
|
|
||||||
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
|
||||||
&[
|
|
||||||
JValue::Object(context),
|
|
||||||
JValue::Int(0),
|
|
||||||
JValue::Object(intent),
|
|
||||||
JValue::Int(update_current | immutable),
|
|
||||||
],
|
|
||||||
)?
|
|
||||||
.l()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn builder_call<'l>(
|
|
||||||
env: &mut Env<'l>,
|
|
||||||
builder: &JObject<'l>,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<()> {
|
|
||||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The type Android 14+ requires a foreground service to declare, and
|
|
||||||
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
|
||||||
fn foreground_type(env: &mut Env) -> Result<i32> {
|
|
||||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
|
||||||
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
|
||||||
if sdk >= upside_down_cake {
|
|
||||||
static_int(
|
|
||||||
env,
|
|
||||||
"android/content/pm/ServiceInfo",
|
|
||||||
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
|
||||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
|
||||||
let builder = crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
|
||||||
)?;
|
|
||||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setContentTitle",
|
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Object(&title)],
|
|
||||||
)?;
|
|
||||||
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setSmallIcon",
|
|
||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Int(icon)],
|
|
||||||
)?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setOngoing",
|
|
||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
|
||||||
)?;
|
|
||||||
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setPriority",
|
|
||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Int(priority_min)],
|
|
||||||
)?;
|
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts the service if there is a server to connect to, and stops it
|
|
||||||
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
|
||||||
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|
||||||
let service_intent =
|
|
||||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
|
||||||
if settings::load(env, context)?.is_none() {
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
context,
|
|
||||||
"stopService",
|
|
||||||
"(Landroid/content/Intent;)Z",
|
|
||||||
&[JValue::Object(&service_intent)],
|
|
||||||
)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
create_channels(env, context)?;
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"androidx/core/content/ContextCompat",
|
|
||||||
"startForegroundService",
|
|
||||||
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&service_intent)],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
|
||||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
|
||||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
|
||||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
|
||||||
/// silently pick the other behaviour.
|
|
||||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
|
||||||
match try_start(env, &service) {
|
|
||||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
|
||||||
Ok(false) => {
|
|
||||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
|
||||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log_error(env, "onStartCommand", &e);
|
|
||||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|
||||||
let Some(settings) = settings::load(env, service)? else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let ca = settings::load_pinned_ca(env)?;
|
|
||||||
let notification = ongoing_notification(env, service)?;
|
|
||||||
let fg_type = foreground_type(env)?;
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/ServiceCompat",
|
|
||||||
"startForeground",
|
|
||||||
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
|
||||||
&[
|
|
||||||
JValue::Object(service),
|
|
||||||
JValue::Int(ONGOING_ID),
|
|
||||||
JValue::Object(¬ification),
|
|
||||||
JValue::Int(fg_type),
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
|
||||||
// the first is still going -- the ordinary case for this service,
|
|
||||||
// since `sync()` is called from more than one place -- must not open
|
|
||||||
// a second connection.
|
|
||||||
if RUNNING.swap(true, Ordering::SeqCst) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let vm = env.get_java_vm()?;
|
|
||||||
let context = env.new_global_ref(service)?;
|
|
||||||
STOPPING.store(false, Ordering::SeqCst);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("ai-app-notifications".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
// Requests a *permanent* attachment (detached only when this thread
|
|
||||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
|
||||||
// this is the long-lived follow loop, not a one-shot callback.
|
|
||||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
|
||||||
follow_loop(env, &context, settings, &ca);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Follows the backend's notification stream, reconnecting until stopped
|
|
||||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
|
||||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
|
||||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
|
||||||
/// notification saying "I could not tell you whether anything happened" is
|
|
||||||
/// noise about a condition nobody can act on.
|
|
||||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
|
||||||
while !STOPPING.load(Ordering::SeqCst) {
|
|
||||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
|
||||||
let _ = follow_notifications(&transport, |notification| {
|
|
||||||
if let Err(e) = show(env, context, ¬ification) {
|
|
||||||
log_error(env, "show", &e);
|
|
||||||
}
|
|
||||||
!STOPPING.load(Ordering::SeqCst)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if STOPPING.load(Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::thread::sleep(RECONNECT_DELAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One notification per session, replacing that session's previous one --
|
|
||||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
|
||||||
/// branches this module's doc comment explains.
|
|
||||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
|
||||||
let manager = notification_manager(env, context)?;
|
|
||||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
|
||||||
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
|
||||||
let allowed = if sdk < tiramisu {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
|
||||||
let granted = static_int(
|
|
||||||
env,
|
|
||||||
"android/content/pm/PackageManager",
|
|
||||||
"PERMISSION_GRANTED",
|
|
||||||
)?;
|
|
||||||
let result = crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"androidx/core/content/ContextCompat",
|
|
||||||
"checkSelfPermission",
|
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)I",
|
|
||||||
&[JValue::Object(context), JValue::Object(&permission)],
|
|
||||||
)?
|
|
||||||
.i()?;
|
|
||||||
result == granted
|
|
||||||
};
|
|
||||||
let enabled =
|
|
||||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
|
||||||
if !allowed || !enabled {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
|
||||||
let pending = pending_activity(env, context, &intent)?;
|
|
||||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
|
||||||
let builder = crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
|
||||||
)?;
|
|
||||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setContentTitle",
|
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Object(&title)],
|
|
||||||
)?;
|
|
||||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setContentText",
|
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Object(&text)],
|
|
||||||
)?;
|
|
||||||
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setSmallIcon",
|
|
||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Int(icon)],
|
|
||||||
)?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setContentIntent",
|
|
||||||
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Object(&pending)],
|
|
||||||
)?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setAutoCancel",
|
|
||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
|
||||||
)?;
|
|
||||||
let when = (notification.at * 1000.0) as i64;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setWhen",
|
|
||||||
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Long(when)],
|
|
||||||
)?;
|
|
||||||
builder_call(
|
|
||||||
env,
|
|
||||||
&builder,
|
|
||||||
"setShowWhen",
|
|
||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
|
||||||
)?;
|
|
||||||
let built =
|
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
|
||||||
.l()?;
|
|
||||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&manager,
|
|
||||||
"notify",
|
|
||||||
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
|
||||||
&[
|
|
||||||
JValue::Object(&tag),
|
|
||||||
JValue::Int(ALERT_ID),
|
|
||||||
JValue::Object(&built),
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
|
||||||
/// the gap this module's `STOPPING` doc explains.
|
|
||||||
pub fn on_destroy() {
|
|
||||||
STOPPING.store(true, Ordering::SeqCst);
|
|
||||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
|
||||||
// comment): the old thread may still be inside a blocked read when a
|
|
||||||
// new `onStartCommand` follows immediately, which would spawn a
|
|
||||||
// second one before the first has actually stopped. Narrower than not
|
|
||||||
// resetting at all -- a service destroyed and never restarted would
|
|
||||||
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
|
||||||
// known gap already accepted above.
|
|
||||||
RUNNING.store(false, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
|
||||||
let message = format!("android-shell: {where_}: {error}");
|
|
||||||
let _ = (|| -> Result<()> {
|
|
||||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
|
||||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"android/util/Log",
|
|
||||||
"e",
|
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)I",
|
|
||||||
&[JValue::Object(&tag), JValue::Object(&msg)],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
|
||||||
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
|
||||||
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
|
||||||
//! class the production app already uses (see `ServerConfig.kt`), through
|
|
||||||
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
|
||||||
//! tested, and the sealed value on a real phone is keyed to the exact
|
|
||||||
//! Keystore alias that class already uses -- reimplementing the crypto
|
|
||||||
//! here would either duplicate it or invalidate an existing enrollment.
|
|
||||||
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JObject, JString, JValue};
|
|
||||||
|
|
||||||
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
|
||||||
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
|
||||||
/// rather than kept as a live JNI reference, so it can cross a thread
|
|
||||||
/// boundary (a `JObject` is tied to one `Env`/thread).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ServerSettings {
|
|
||||||
pub host: String,
|
|
||||||
pub port: i32,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServerSettings {
|
|
||||||
pub fn base_url(&self) -> String {
|
|
||||||
format!("https://{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
|
||||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
|
||||||
/// installed side by side on the same development device without
|
|
||||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
|
||||||
/// RUST.md's E3 entry for why they are not the same value.
|
|
||||||
pub(crate) const SCHEME: &str = "aiappshell";
|
|
||||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
|
||||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
|
||||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
|
||||||
|
|
||||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
|
||||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
|
||||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
|
||||||
crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
STORE_CLASS,
|
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
|
||||||
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
|
||||||
let host = get_string(env, settings_obj, "getHost")?;
|
|
||||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
|
||||||
let token = get_string(env, settings_obj, "getToken")?;
|
|
||||||
Ok(ServerSettings { host, port, token })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
|
||||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
|
||||||
jstr.try_to_string(env)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The stored enrollment, or `None` when there is not one -- mirrors
|
|
||||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
|
||||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
|
||||||
let store = new_store(env)?;
|
|
||||||
let settings_obj = crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&store,
|
|
||||||
"load",
|
|
||||||
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
|
||||||
&[JValue::Object(context)],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
if settings_obj.is_null() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some(read_settings(env, &settings_obj)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
|
||||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
|
||||||
let store = new_store(env)?;
|
|
||||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
|
||||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
|
||||||
let settings_obj = crate::jcall::new_object(
|
|
||||||
env,
|
|
||||||
SETTINGS_CLASS,
|
|
||||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
|
||||||
&[
|
|
||||||
JValue::Object(&host),
|
|
||||||
JValue::Int(settings.port),
|
|
||||||
JValue::Object(&token),
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&store,
|
|
||||||
"save",
|
|
||||||
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
|
||||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
|
||||||
/// query-parameter rules rather than re-deriving them here.
|
|
||||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
|
||||||
let store = new_store(env)?;
|
|
||||||
let settings_obj = crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
&store,
|
|
||||||
"parseEnrollmentUri",
|
|
||||||
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
|
||||||
&[JValue::Object(uri)],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
if settings_obj.is_null() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some(read_settings(env, &settings_obj)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The CA this build pins, generated at build time the same way
|
|
||||||
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
|
||||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
|
||||||
/// own to generate into.
|
|
||||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
|
||||||
let value = crate::jcall::get_static_field(
|
|
||||||
env,
|
|
||||||
"com/example/aiapp/shell/PinnedCa",
|
|
||||||
"PINNED_CA_PEM",
|
|
||||||
"Ljava/lang/String;",
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
|
||||||
Ok(jstr.try_to_string(env)?.into_bytes())
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
|
||||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
|
||||||
//!
|
|
||||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
|
||||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
|
||||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
|
||||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
|
||||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
|
||||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
|
||||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
|
||||||
//! `content://` stream and no text falls through to a toast saying so,
|
|
||||||
//! rather than silently doing nothing. Closing this gap is the same
|
|
||||||
//! `client-core` work whichever caller needs it next.
|
|
||||||
//!
|
|
||||||
//! **Which session a share lands in** is also a placeholder: with no
|
|
||||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
|
||||||
//! to whichever session has the latest `last_activity` -- the one most
|
|
||||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
|
||||||
//! exists to ask instead of guessing.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, UreqTransport};
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JObject, JString, JValue};
|
|
||||||
|
|
||||||
use crate::notify;
|
|
||||||
use crate::settings;
|
|
||||||
|
|
||||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
|
||||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
|
||||||
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
|
||||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
|
||||||
|
|
||||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
|
||||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
|
||||||
if value.is_null() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
|
||||||
Ok(Some(jstr.try_to_string(env)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
|
||||||
let message = crate::jcall::jstr_obj(env, message)?;
|
|
||||||
crate::jcall::call_static_method(
|
|
||||||
env,
|
|
||||||
"com/example/aiapp/shell/MainActivity",
|
|
||||||
"toast",
|
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
|
||||||
&[JValue::Object(context), JValue::Object(&message)],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
|
||||||
/// `MainActivity.kt`'s `handleIntent`.
|
|
||||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
|
||||||
let action = get_string_method(env, intent, "getAction")?;
|
|
||||||
if matches!(
|
|
||||||
action.as_deref(),
|
|
||||||
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
|
||||||
) {
|
|
||||||
return handle_share(env, activity, intent);
|
|
||||||
}
|
|
||||||
if action.as_deref() != Some(ACTION_VIEW) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
|
||||||
if uri.is_null() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let scheme = get_string_method(env, &uri, "getScheme")?;
|
|
||||||
if scheme.as_deref() != Some(settings::SCHEME) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
|
||||||
Some("session") => handle_session_open(env, activity, &uri),
|
|
||||||
Some("enroll") => handle_enrollment(env, activity, &uri),
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
|
||||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
// There is no session screen yet (E4's job); the toast is this
|
|
||||||
// experiment's stand-in proof that the tap was routed to the right
|
|
||||||
// session id.
|
|
||||||
toast(env, activity, &format!("Opened session {session_id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
|
||||||
match settings::parse_enrollment_uri(env, uri)? {
|
|
||||||
Some(parsed) => {
|
|
||||||
settings::save(env, activity, &parsed)?;
|
|
||||||
notify::sync(env, activity)?;
|
|
||||||
toast(
|
|
||||||
env,
|
|
||||||
activity,
|
|
||||||
&format!("Enrolled with {}", parsed.base_url()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => toast(env, activity, "Not a valid enrollment code"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
|
||||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
|
||||||
/// what happens to it, minus attachments per this module's doc comment.
|
|
||||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
|
||||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
|
||||||
let text = crate::jcall::call_method(
|
|
||||||
env,
|
|
||||||
intent,
|
|
||||||
"getStringExtra",
|
|
||||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
|
||||||
&[JValue::Object(&extra_text)],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
let text = if text.is_null() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let jstr: JString = env.cast_local::<JString>(text)?;
|
|
||||||
Some(jstr.try_to_string(env)?)
|
|
||||||
};
|
|
||||||
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
|
||||||
return toast(
|
|
||||||
env,
|
|
||||||
activity,
|
|
||||||
"Nothing to share -- only shared text is supported so far",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Network I/O must not run on the calling thread: `handle_intent` is
|
|
||||||
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
|
||||||
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
|
||||||
// the actual send happens on a JNI-attached background thread, the
|
|
||||||
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
|
||||||
// thread is safe because `MainActivity.toast` itself hops back to the
|
|
||||||
// main looper (see that method).
|
|
||||||
let vm = env.get_java_vm()?;
|
|
||||||
let activity_ref = env.new_global_ref(activity)?;
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
|
||||||
share_in_background(env, &activity_ref, text);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
|
||||||
let outcome = attach_to_a_session(env, activity, &text);
|
|
||||||
let message = match outcome {
|
|
||||||
Ok(title) => format!("Shared into \"{title}\""),
|
|
||||||
Err(message) => message,
|
|
||||||
};
|
|
||||||
let _ = toast(env, activity, &message);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_to_a_session(
|
|
||||||
env: &mut Env,
|
|
||||||
activity: &JObject,
|
|
||||||
text: &str,
|
|
||||||
) -> std::result::Result<String, String> {
|
|
||||||
let settings = settings::load(env, activity)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
|
||||||
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
|
||||||
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let client = ApiClient::new(transport);
|
|
||||||
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
|
||||||
let target = sessions
|
|
||||||
.into_iter()
|
|
||||||
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
|
||||||
.ok_or_else(|| "No session to share into".to_string())?;
|
|
||||||
client
|
|
||||||
.send_message(&target.id, text, &[])
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(target.title)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -9,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.wgapplink.localNetworkAllowed
|
import com.example.wgapplink.localNetworkAllowed
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -29,25 +33,39 @@ import kotlinx.coroutines.withContext
|
|||||||
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
||||||
* and the back button the only other way between them.
|
* and the back button the only other way between them.
|
||||||
*
|
*
|
||||||
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
|
||||||
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
* of them a step down from another -- and what is left here is only what genuinely is a step down:
|
||||||
* session, spawning one, and settings.
|
* one session, spawning one, and settings.
|
||||||
*/
|
*/
|
||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
data object Main : Screen()
|
data object Main : Screen()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One session, with the file explorer over it when [files] is set.
|
* One session, with the file explorer or a subagent transcript over it when set.
|
||||||
*
|
*
|
||||||
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
* Both are layers on this screen rather than screens of their own, so the session under them
|
||||||
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
|
||||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
|
||||||
* created on every return, refetching the transcript over the tunnel.
|
* return, refetching the transcript over the tunnel.
|
||||||
*/
|
*/
|
||||||
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
data class Session(
|
||||||
|
val summary: SessionSummary,
|
||||||
|
val files: FilesTarget? = null,
|
||||||
|
val subagent: SubagentSummary? = null,
|
||||||
|
) : Screen()
|
||||||
|
|
||||||
data object Spawn : Screen()
|
data object Spawn : Screen()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One provider on one machine: its settings, and what its shared server is holding.
|
||||||
|
*
|
||||||
|
* A step down from the machines tab rather than a tab of its own, because it is about one
|
||||||
|
* machine rather than about the backend. Addressed by ids and names rather than by the
|
||||||
|
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
|
||||||
|
* a second version of the same truth.
|
||||||
|
*/
|
||||||
|
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
|
||||||
|
|
||||||
data object Settings : Screen()
|
data object Settings : Screen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +196,7 @@ fun AppRoot(
|
|||||||
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
||||||
// the cost that made it lag, so it moves only its composer and transcript.
|
// the cost that made it lag, so it moves only its composer and transcript.
|
||||||
when (val here = screen) {
|
when (val here = screen) {
|
||||||
is Screen.Main ->
|
Screen.Main ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding()) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
@@ -191,6 +209,9 @@ fun AppRoot(
|
|||||||
screen = Screen.Session(imported)
|
screen = Screen.Session(imported)
|
||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
|
onProvider = { machineId, provider ->
|
||||||
|
screen = Screen.ProviderSettings(machineId, provider)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
@@ -204,14 +225,107 @@ fun AppRoot(
|
|||||||
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
||||||
// No imePadding here, for the reason above -- the explorer adds its own.
|
// No imePadding here, for the reason above -- the explorer adds its own.
|
||||||
Box {
|
Box {
|
||||||
SessionScreen(
|
val fileLinkHandler = rememberFileLinkHandler { path ->
|
||||||
settings = current,
|
screen = here.copy(files = here.summary.filesTarget(path))
|
||||||
summary = here.summary,
|
}
|
||||||
onBack = goToMain,
|
Box(
|
||||||
onFiles = { screen = here.copy(files = it) },
|
Modifier.then(
|
||||||
share = share,
|
if (here.subagent != null || here.files != null)
|
||||||
onShareTaken = { share = null },
|
Modifier.clearAndSetSemantics {}
|
||||||
)
|
else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// How much background work the session has, from the one subscription
|
||||||
|
// to its events the screen below holds. Here because the panel and that
|
||||||
|
// screen both draw it, and must draw the same number.
|
||||||
|
var backgroundTasks by
|
||||||
|
remember(here.summary.id) {
|
||||||
|
mutableIntStateOf(here.summary.backgroundTasks)
|
||||||
|
}
|
||||||
|
// Where the panel has asked the session screen to put the reader: the
|
||||||
|
// call a background task was started by. Held here rather than inside
|
||||||
|
// either, because the two are siblings -- the panel is the one being
|
||||||
|
// tapped and the transcript is the one that can travel.
|
||||||
|
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
|
||||||
|
// The two panels this session can be pulled aside for: its subagents
|
||||||
|
// from the right, and the whole main screen from the left. Both are here
|
||||||
|
// rather than screens of their own for the same reason the explorer is --
|
||||||
|
// the session under them stays composed. The main panel exists only
|
||||||
|
// inside a session, which is what makes it unswipeable until one has been
|
||||||
|
// opened.
|
||||||
|
SidePanels(
|
||||||
|
left = { active, close ->
|
||||||
|
MainPanel(
|
||||||
|
settings = current,
|
||||||
|
sessionId = here.summary.id,
|
||||||
|
active = active,
|
||||||
|
onOpen = { screen = Screen.Session(it) },
|
||||||
|
onSpawn = { screen = Screen.Spawn },
|
||||||
|
onImported = { imported ->
|
||||||
|
reloadToken++
|
||||||
|
screen = Screen.Session(imported)
|
||||||
|
},
|
||||||
|
onSettings = { screen = Screen.Settings },
|
||||||
|
onProvider = { machineId, provider ->
|
||||||
|
screen = Screen.ProviderSettings(machineId, provider)
|
||||||
|
},
|
||||||
|
onClose = close,
|
||||||
|
onGone = goToMain,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// The whole width: it stands in for the screen Back would have shown,
|
||||||
|
// rather than sitting over the session the way the subagents do.
|
||||||
|
leftFraction = 1f,
|
||||||
|
right = { active, close ->
|
||||||
|
SubagentPanel(
|
||||||
|
settings = current,
|
||||||
|
summary = here.summary,
|
||||||
|
active = active,
|
||||||
|
backgroundTasks = backgroundTasks,
|
||||||
|
onOpenSubagent = { screen = here.copy(subagent = it) },
|
||||||
|
// Closed with it: what the reader asked to see is under this
|
||||||
|
// panel, and a panel left open over the answer is the one
|
||||||
|
// thing the tap cannot have meant.
|
||||||
|
onOpenCall = {
|
||||||
|
goTo = it
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalFileLinkHandler provides fileLinkHandler
|
||||||
|
) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = current,
|
||||||
|
summary = here.summary,
|
||||||
|
onBack = goToMain,
|
||||||
|
onFiles = { screen = here.copy(files = it) },
|
||||||
|
share = share,
|
||||||
|
onShareTaken = { share = null },
|
||||||
|
onBackgroundTasks = { backgroundTasks = it },
|
||||||
|
goTo = goTo,
|
||||||
|
onGoToTaken = { goTo = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
here.subagent?.let { subagent ->
|
||||||
|
BackHandler { screen = here.copy(subagent = null) }
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
|
||||||
|
) {
|
||||||
|
key(subagent.id) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = current,
|
||||||
|
summary = here.summary,
|
||||||
|
onBack = { screen = here.copy(subagent = null) },
|
||||||
|
onFiles = {},
|
||||||
|
subagent = subagent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Its own back handler is registered after this screen's, so it is the one the
|
// Its own back handler is registered after this screen's, so it is the one the
|
||||||
// platform asks first, and it steps back inside itself before closing.
|
// platform asks first, and it steps back inside itself before closing.
|
||||||
here.files?.let { target ->
|
here.files?.let { target ->
|
||||||
@@ -223,6 +337,15 @@ fun AppRoot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is Screen.ProviderSettings ->
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
|
ProviderScreen(
|
||||||
|
settings = current,
|
||||||
|
machineId = here.machineId,
|
||||||
|
provider = here.provider,
|
||||||
|
onBack = goToMain,
|
||||||
|
)
|
||||||
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding()) {
|
||||||
SpawnScreen(
|
SpawnScreen(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import androidx.compose.material3.LocalContentColor
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -335,12 +334,11 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
|||||||
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
||||||
// what sends it. A second send button beside the field made the shorter half of the card look
|
// what sends it. A second send button beside the field made the shorter half of the card look
|
||||||
// like the one that finishes it.
|
// like the one that finishes it.
|
||||||
OutlinedTextField(
|
LabelledField(
|
||||||
|
label = "Other",
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = onText,
|
onValueChange = onText,
|
||||||
label = { Text("Other") },
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a session can be sent a picture, as the server answers it.
|
||||||
|
*
|
||||||
|
* Three states rather than a switch, because for a local model the answer belongs to the server
|
||||||
|
* that loaded it: one still coming off disk genuinely has not said. [UNKNOWN] is offered -- a
|
||||||
|
* control withheld because nobody could ask is a photo button missing from a session that would
|
||||||
|
* have read the photo perfectly well, and the send path says so if the guess was wrong.
|
||||||
|
*/
|
||||||
|
enum class ImageSupport {
|
||||||
|
ACCEPTED,
|
||||||
|
REFUSED,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server called it; anything else -- an older server, a newer word -- is not an answer.
|
||||||
|
*/
|
||||||
|
fun imageSupport(word: String): ImageSupport =
|
||||||
|
when (word) {
|
||||||
|
"accepted" -> ImageSupport.ACCEPTED
|
||||||
|
"refused" -> ImageSupport.REFUSED
|
||||||
|
else -> ImageSupport.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
|
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
|
||||||
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
|
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ suspend fun uploadPickedImage(
|
|||||||
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
|
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
|
||||||
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
|
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
|
||||||
* it, because the session is told that name rather than shown the bytes.
|
* it, because the session is told that name rather than shown the bytes.
|
||||||
|
*
|
||||||
|
* A picture is refused here, before anything is read or sent, when [images] says this session's
|
||||||
|
* model cannot read one. Here rather than beside the photo button because this is where every way
|
||||||
|
* of attaching meets: the picker, the file chooser, and another app's share sheet -- and only the
|
||||||
|
* first of those has a button to disable.
|
||||||
*/
|
*/
|
||||||
suspend fun uploadPicked(
|
suspend fun uploadPicked(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -45,10 +50,16 @@ suspend fun uploadPicked(
|
|||||||
sessionId: String,
|
sessionId: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
maxEdge: Int?,
|
maxEdge: Int?,
|
||||||
|
images: ImageSupport,
|
||||||
): String {
|
): String {
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val mime = resolver.getType(uri)
|
val mime = resolver.getType(uri)
|
||||||
if (mime != null && mime.startsWith("image/")) {
|
if (mime != null && mime.startsWith("image/")) {
|
||||||
|
if (images == ImageSupport.REFUSED) {
|
||||||
|
throw ApiException(
|
||||||
|
"this session's model can't read pictures, so that one wasn't attached"
|
||||||
|
)
|
||||||
|
}
|
||||||
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
||||||
}
|
}
|
||||||
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The background work a session has going, above its subagents in the panel [SidePanels] slides
|
||||||
|
* over it from the right.
|
||||||
|
*
|
||||||
|
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
|
||||||
|
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
|
||||||
|
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
|
||||||
|
* so the two are read together.
|
||||||
|
*
|
||||||
|
* The count is drawn even when it is zero, in the same words. A section that appeared only once
|
||||||
|
* something was running made its own presence the answer, and no heading at all draws "nothing is
|
||||||
|
* running" and "nobody has asked yet" identically. There is then nothing to expand, so the heading
|
||||||
|
* carries no chevron either: it is a statement rather than a control.
|
||||||
|
*/
|
||||||
|
fun LazyListScope.backgroundTaskSection(
|
||||||
|
count: Int,
|
||||||
|
tasks: LoadState<List<BackgroundTaskSummary>?>,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onOpenCall: (CallSite) -> Unit,
|
||||||
|
) {
|
||||||
|
item(key = "background-heading") {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.then(if (count == 0) Modifier else Modifier.clickable(onClick = onToggle)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${backgroundTaskLabel(count)} running",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The count as well as the switch: [tasks] is the last answer anybody got, so a section left
|
||||||
|
// expanded as the work finished would draw cards for tasks that have ended.
|
||||||
|
if (count == 0 || !expanded) return
|
||||||
|
when (tasks) {
|
||||||
|
is LoadState.Loading ->
|
||||||
|
item(key = "background-loading") {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||||
|
}
|
||||||
|
is LoadState.Error ->
|
||||||
|
item(key = "background-error") {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
tasks.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetry) { Text("Try again") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Null is the provider declining to say, which a session whose process has gone answers.
|
||||||
|
// Said in words: the count above came from somewhere, and an empty space under it would
|
||||||
|
// read as the tasks having finished rather than as nobody being left to ask.
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
when (val rows = tasks.value) {
|
||||||
|
null ->
|
||||||
|
item(key = "background-unknown") {
|
||||||
|
Text(
|
||||||
|
"This session isn't saying what these are.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
|
||||||
|
BackgroundTaskCard(
|
||||||
|
task,
|
||||||
|
onOpen = task.call?.let { call -> { onOpenCall(call) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One background task: what it is doing, drawn as one line that says what kind it is by how it
|
||||||
|
* looks.
|
||||||
|
*
|
||||||
|
* The kind used to be a second line under the words, which on a list of backgrounded commands was
|
||||||
|
* "background command" repeated down the panel -- and for a provider that names a task by a process
|
||||||
|
* id it was the *whole* card, so every row said the same two words. A mark carries the same
|
||||||
|
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
|
||||||
|
* description that keeps the words for anybody who cannot see it.
|
||||||
|
*
|
||||||
|
* A command needs no mark: drawn the way every other verbatim thing here is -- highlighted,
|
||||||
|
* monospace, on [rawSurface] -- it says "this is a command" in the same appearance the tool card it
|
||||||
|
* came from uses, and a mark beside that would be the same fact twice.
|
||||||
|
*
|
||||||
|
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
|
||||||
|
* null where the provider never said which call it was, or where that call is no longer in the
|
||||||
|
* transcript, and the card is then a statement rather than a control. The chevron is what says
|
||||||
|
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
|
||||||
|
* that never invited the press.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
|
||||||
|
val look = backgroundTaskLook(task.kind)
|
||||||
|
// Null where a provider named the task by a process id and nothing resolved a command out of
|
||||||
|
// it: there is no code to draw, so the row takes the mark and the words instead.
|
||||||
|
val command = task.description?.takeIf { look.code }
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (onOpen == null) Modifier
|
||||||
|
else
|
||||||
|
Modifier.clickable(
|
||||||
|
onClickLabel = "Show where this started",
|
||||||
|
onClick = onOpen,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
if (command == null) {
|
||||||
|
Glyph(
|
||||||
|
look.glyph,
|
||||||
|
colour = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.semantics { contentDescription = look.words },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
// The kind stands in as the words where the provider gave no description, rather
|
||||||
|
// than the id it named the task by: Codex reports a process number, which says
|
||||||
|
// nothing to the person reading and would look like a name somebody chose.
|
||||||
|
Text(
|
||||||
|
task.description ?: look.words,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else LocalContentColor.current,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Cut at its tail: what identifies a command is the program at its head, and the
|
||||||
|
// long ones are exactly the ones being read closely.
|
||||||
|
Text(
|
||||||
|
// Not cached: one command line lexes in microseconds -- the cache exists for a
|
||||||
|
// fence with two hundred lines in it.
|
||||||
|
remember(command) { highlight(command, Language.SHELL) },
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier.weight(1f)
|
||||||
|
// Smaller than the card's own radius, for the reason [RawBlock] rounds
|
||||||
|
// its corners that way: this sits inside one.
|
||||||
|
.clip(MaterialTheme.shapes.extraSmall)
|
||||||
|
.background(rawSurface)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||||
|
// The fill says "command" to everybody else; this says it to a reader
|
||||||
|
// who cannot see the fill.
|
||||||
|
.semantics { contentDescription = "${look.words} $command" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onOpen != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Chevron(Pointing.Right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one kind of background task is drawn: see [backgroundTaskLook].
|
||||||
|
*
|
||||||
|
* [code] is the kind whose description is verbatim text rather than prose, which is drawn as code
|
||||||
|
* and takes no [glyph]; the glyph is still what a task of that kind falls back to when nothing said
|
||||||
|
* what it ran.
|
||||||
|
*/
|
||||||
|
private data class TaskLook(val glyph: String, val words: String, val code: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
|
||||||
|
*
|
||||||
|
* One rather than three, which is the rule this screen already learned once with the status word
|
||||||
|
* and its colour: three `when`s over one set is two of them waiting to miss a member.
|
||||||
|
*
|
||||||
|
* A kind this build has not heard of takes the question mark and is named by what every one of them
|
||||||
|
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
|
||||||
|
* deciding what the server meant by a word it invented after this build shipped.
|
||||||
|
*/
|
||||||
|
private fun backgroundTaskLook(kind: String) =
|
||||||
|
when (kind) {
|
||||||
|
// A command is drawn in the face a command is drawn in everywhere else here.
|
||||||
|
"command" -> TaskLook(COMMAND_GLYPH, "background command", code = true)
|
||||||
|
"agent" -> TaskLook(AGENT_GLYPH, "subagent", code = false)
|
||||||
|
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", code = false)
|
||||||
|
else -> TaskLook(UNKNOWN_GLYPH, "background task", code = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading over one group in the panel, so neither list is a run of cards with no name.
|
||||||
|
*
|
||||||
|
* The same band as the background section's own heading row above, rather than a gap chosen to look
|
||||||
|
* right here: what separates a heading from the cards above it is that both headings sit in a row
|
||||||
|
* of one height.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PanelSectionHeading(text: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||||
|
Text(text, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
|||||||
* ends that tall would bow its sides.
|
* ends that tall would bow its sides.
|
||||||
*/
|
*/
|
||||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A round button sized to the mark it draws.
|
||||||
|
*
|
||||||
|
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
|
||||||
|
* padding around one glyph was width taken from the pickers beside it: with a long model name on
|
||||||
|
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
|
||||||
|
* platform's minimum touch target rather than a button's shorter default height.
|
||||||
|
*
|
||||||
|
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CircleButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fill: Color? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val sized = modifier.size(CircleButtonSize)
|
||||||
|
if (fill == null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = CircleShape,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = sized,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = actionButtonColors(fill),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = sized,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How wide and tall one of those is; see [CircleButton]. */
|
||||||
|
val CircleButtonSize = 48.dp
|
||||||
@@ -160,6 +160,7 @@ private val FENCE_LANGUAGES: Map<String, Language> =
|
|||||||
"shell" to Language.SHELL,
|
"shell" to Language.SHELL,
|
||||||
"zsh" to Language.SHELL,
|
"zsh" to Language.SHELL,
|
||||||
"console" to Language.SHELL,
|
"console" to Language.SHELL,
|
||||||
|
"diff" to Language.DIFF,
|
||||||
"python" to Language.PYTHON,
|
"python" to Language.PYTHON,
|
||||||
"py" to Language.PYTHON,
|
"py" to Language.PYTHON,
|
||||||
"javascript" to Language.JAVASCRIPT,
|
"javascript" to Language.JAVASCRIPT,
|
||||||
|
|||||||
@@ -60,3 +60,23 @@ fun compactingLabel(seconds: Long?): String =
|
|||||||
seconds < 60 -> "compacting ${seconds}s"
|
seconds < 60 -> "compacting ${seconds}s"
|
||||||
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How full the session is, as the status row says it.
|
||||||
|
*
|
||||||
|
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
|
||||||
|
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
|
||||||
|
* gets both — the reader can see which they are looking at. What must not happen is a missing
|
||||||
|
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
|
||||||
|
* inventing the very fact it does not have.
|
||||||
|
*
|
||||||
|
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
|
||||||
|
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
|
||||||
|
* bare figure they have always had.
|
||||||
|
*/
|
||||||
|
fun contextLabel(held: Long?, limit: Long?): String =
|
||||||
|
when {
|
||||||
|
held == null -> "context unknown"
|
||||||
|
limit == null -> "context ${tokens(held)}"
|
||||||
|
else -> "context ${tokens(held)} / ${tokens(limit)}"
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A line across the transcript saying what left the session's context.
|
* A line across the transcript saying what left the session's context.
|
||||||
@@ -36,6 +40,28 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule between two replies that met with nothing said in between -- see
|
||||||
|
* [TranscriptItem.TurnBreak].
|
||||||
|
*
|
||||||
|
* No words and no colour. Every other divider here reports something that happened and is worth
|
||||||
|
* finding by scanning; this one only says "these are two", and it appears once per turn that
|
||||||
|
* started without anybody typing. Saying more was a screenful of announcements about background
|
||||||
|
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
|
||||||
|
* because the words came from somewhere that had no reason to keep them short.
|
||||||
|
*
|
||||||
|
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
|
||||||
|
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
|
||||||
|
* of the one under it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TurnBreakRow(modifier: Modifier = Modifier) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mark a clear leaves.
|
* The mark a clear leaves.
|
||||||
*
|
*
|
||||||
@@ -47,3 +73,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
TranscriptDivider("Context cleared", clearedColor, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark running out of quota leaves.
|
||||||
|
*
|
||||||
|
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
||||||
|
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
||||||
|
*
|
||||||
|
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
||||||
|
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
||||||
|
* which is not a promise about when the session picks back up. A limit the session was told no
|
||||||
|
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
||||||
|
* number.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
||||||
|
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
||||||
|
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
||||||
|
* that reads the same when it goes wrong.
|
||||||
|
*
|
||||||
|
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
||||||
|
*/
|
||||||
|
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
||||||
|
val at = resetsAt?.let {
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(Instant.ofEpochSecond(it.toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
|
|||||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||||
* saw as the new cursor.
|
* saw as the new cursor.
|
||||||
*/
|
*/
|
||||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|||||||
// one and the screen folds the other, and they have to be the same line.
|
// one and the screen folds the other, and they have to be the same line.
|
||||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
||||||
// A named frame carries no payload and a data frame has no name.
|
// A named frame carries no payload and a data frame has no name.
|
||||||
if (name == RESET_EVENT) onReset()
|
if (name == RESET_EVENT) onReset()
|
||||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ sealed class SessionEvent {
|
|||||||
|
|
||||||
data class AssistantText(val delta: String) : SessionEvent()
|
data class AssistantText(val delta: String) : SessionEvent()
|
||||||
|
|
||||||
|
/** The durable value of the open assistant message, replacing its provisional deltas. */
|
||||||
|
data class AssistantTextFinal(val text: String) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model's working, streamed the way its reply is: its own card, and deliberately not part
|
||||||
|
* of what the session said. Only a provider that actually streams its reasoning sends it.
|
||||||
|
*/
|
||||||
|
data class Thinking(val delta: String) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thinking above this finished, having taken [ms].
|
||||||
|
*
|
||||||
|
* Measured by the driver, because only it can see when the model stopped: this app knows when
|
||||||
|
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
|
||||||
|
* exactly like thinking that went on that long.
|
||||||
|
*/
|
||||||
|
data class ThinkingDone(val ms: Long) : SessionEvent()
|
||||||
|
|
||||||
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
||||||
|
|
||||||
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
||||||
@@ -108,6 +126,26 @@ sealed class SessionEvent {
|
|||||||
val turnStart: Long? = null,
|
val turnStart: Long? = null,
|
||||||
) : SessionEvent()
|
) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
|
||||||
|
* server wrote that has since been dropped.
|
||||||
|
*
|
||||||
|
* [kind] is the word the line called itself, so the row can say what is missing rather than
|
||||||
|
* that something is. The server makes these when reading; no driver sends one.
|
||||||
|
*/
|
||||||
|
data class Unreadable(val kind: String) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
|
||||||
|
* out to be a screenful of notices about work nobody was asking after.
|
||||||
|
*
|
||||||
|
* Kept because a transcript is append-only -- the sessions that ran a background task in that
|
||||||
|
* window have these lines for ever. It draws no row, which is the whole reason it is still
|
||||||
|
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
|
||||||
|
* background task, which is the same wall the row was removed for.
|
||||||
|
*/
|
||||||
|
object RetiredTaskNote : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A command the session was asked to run on itself and cannot run yet. Resolved by
|
* A command the session was asked to run on itself and cannot run yet. Resolved by
|
||||||
* [CommandSent] with the same id; a command that ran straight away has only that one.
|
* [CommandSent] with the same id; a command that ran straight away has only that one.
|
||||||
@@ -117,6 +155,9 @@ sealed class SessionEvent {
|
|||||||
/** The same command, handed to the session. */
|
/** The same command, handed to the session. */
|
||||||
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
||||||
|
|
||||||
|
/** Provider-reported number of background tasks alive now. */
|
||||||
|
data class BackgroundTasks(val count: Int) : SessionEvent()
|
||||||
|
|
||||||
data class Status(val state: String) : SessionEvent()
|
data class Status(val state: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,6 +168,16 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a picture can be sent to this session now, as the thing serving its model answered.
|
||||||
|
*
|
||||||
|
* Only a llama.cpp session says this, and it says it twice per model: unknown the moment the
|
||||||
|
* old one is left, then the loaded server's answer. It carries no row -- it is what the
|
||||||
|
* composer's photo button is drawn from, and a line in the transcript about a control is not
|
||||||
|
* something anybody asked after.
|
||||||
|
*/
|
||||||
|
data class Images(val images: ImageSupport) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a turn cost, and how much the model was holding when it ended.
|
* What a turn cost, and how much the model was holding when it ended.
|
||||||
*
|
*
|
||||||
@@ -135,7 +186,25 @@ sealed class SessionEvent {
|
|||||||
* so adding turns up would report a figure the session stopped being true of. Null where the
|
* so adding turns up would report a figure the session stopped being true of. Null where the
|
||||||
* dialect did not say, which leaves the context unmeasured rather than unchanged.
|
* dialect did not say, which leaves the context unmeasured rather than unchanged.
|
||||||
*/
|
*/
|
||||||
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
|
data class UsageDelta(
|
||||||
|
val tokens: Long,
|
||||||
|
val context: Long?,
|
||||||
|
/**
|
||||||
|
* How fast the reply came out, where the provider measured it -- null everywhere else,
|
||||||
|
* which is most of them. Never worked out here: the time this app watched a reply arrive
|
||||||
|
* over includes the network and whatever the server was doing between tokens.
|
||||||
|
*/
|
||||||
|
val tokensPerSecond: Double? = null,
|
||||||
|
/**
|
||||||
|
* How long the provider spent reading the prompt before it began answering; null where
|
||||||
|
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
|
||||||
|
* nothing at all.
|
||||||
|
*/
|
||||||
|
val prefillMs: Long? = null,
|
||||||
|
) : SessionEvent()
|
||||||
|
|
||||||
|
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
|
||||||
|
data class ContextWindow(val tokens: Long) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compaction that finished, and how much context it recovered.
|
* A compaction that finished, and how much context it recovered.
|
||||||
@@ -157,6 +226,20 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data object Cleared : SessionEvent()
|
data object Cleared : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session stopped because its account's usage limit was reached.
|
||||||
|
*
|
||||||
|
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
|
||||||
|
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
|
||||||
|
* limit reached|1788546972`, which is not readable by the person it is shown to.
|
||||||
|
*
|
||||||
|
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
|
||||||
|
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
|
||||||
|
*/
|
||||||
|
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
||||||
|
|
||||||
|
data class AuthenticationRequired(val message: String) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,6 +276,9 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
)
|
)
|
||||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
||||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||||
|
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
|
||||||
|
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
|
||||||
|
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
|
||||||
"toolStart" ->
|
"toolStart" ->
|
||||||
SessionEvent.ToolStart(
|
SessionEvent.ToolStart(
|
||||||
id = body.getString("id"),
|
id = body.getString("id"),
|
||||||
@@ -240,19 +326,26 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
body.getString("text"),
|
body.getString("text"),
|
||||||
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
||||||
)
|
)
|
||||||
|
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
|
||||||
|
"taskNote" -> SessionEvent.RetiredTaskNote
|
||||||
"commandQueued" ->
|
"commandQueued" ->
|
||||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
||||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
||||||
|
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
|
||||||
"status" -> SessionEvent.Status(body.getString("state"))
|
"status" -> SessionEvent.Status(body.getString("state"))
|
||||||
"settings" ->
|
"settings" ->
|
||||||
SessionEvent.Settings(
|
SessionEvent.Settings(
|
||||||
model = body.optString("model").ifEmpty { null },
|
model = body.optString("model").ifEmpty { null },
|
||||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||||
)
|
)
|
||||||
|
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
|
||||||
|
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
|
||||||
"usageDelta" ->
|
"usageDelta" ->
|
||||||
SessionEvent.UsageDelta(
|
SessionEvent.UsageDelta(
|
||||||
body.getLong("tokens"),
|
body.getLong("tokens"),
|
||||||
if (body.has("context")) body.getLong("context") else null,
|
if (body.has("context")) body.getLong("context") else null,
|
||||||
|
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
|
||||||
|
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
|
||||||
)
|
)
|
||||||
"compacted" ->
|
"compacted" ->
|
||||||
SessionEvent.Compacted(
|
SessionEvent.Compacted(
|
||||||
@@ -261,6 +354,12 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
trigger = body.optString("trigger").ifEmpty { null },
|
trigger = body.optString("trigger").ifEmpty { null },
|
||||||
)
|
)
|
||||||
"cleared" -> SessionEvent.Cleared
|
"cleared" -> SessionEvent.Cleared
|
||||||
|
"limitReached" ->
|
||||||
|
SessionEvent.LimitReached(
|
||||||
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
|
)
|
||||||
|
"authenticationRequired" ->
|
||||||
|
SessionEvent.AuthenticationRequired(body.getString("message"))
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
@@ -275,7 +374,21 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
* first time the server grows a state, and the drift would be a reply that never splits or one
|
* first time the server grows a state, and the drift would be a reply that never splits or one
|
||||||
* split mid-stream.
|
* split mid-stream.
|
||||||
*/
|
*/
|
||||||
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
|
fun sessionWorking(state: String): Boolean =
|
||||||
|
state == "running" || state == "compacting" || state == "loading" || state == "reading"
|
||||||
|
|
||||||
|
/** Whether the latest events still say this session needs an explicit provider login. */
|
||||||
|
internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean =
|
||||||
|
when (event) {
|
||||||
|
is SessionEvent.AuthenticationRequired -> true
|
||||||
|
// A later provider response proves an older authentication failure in a replayed page is
|
||||||
|
// no longer current. Without this, one old failure reopened sign-in after every later
|
||||||
|
// successful turn.
|
||||||
|
is SessionEvent.AssistantText,
|
||||||
|
is SessionEvent.AssistantTextFinal,
|
||||||
|
is SessionEvent.ToolStart -> false
|
||||||
|
else -> open
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The context after [event], given what it was before.
|
* The context after [event], given what it was before.
|
||||||
@@ -299,3 +412,18 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
|||||||
is SessionEvent.Cleared -> null
|
is SessionEvent.Cleared -> null
|
||||||
else -> current
|
else -> current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The context window after [event], mirroring the server's `context_limit_after` for the same
|
||||||
|
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
|
||||||
|
*
|
||||||
|
* A window belongs to the process, so a session whose process has exited has none — left standing,
|
||||||
|
* a session restarted on a different model would draw its occupancy against the old model's
|
||||||
|
* ceiling.
|
||||||
|
*/
|
||||||
|
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
|
||||||
|
when (event) {
|
||||||
|
is SessionEvent.ContextWindow -> event.tokens
|
||||||
|
is SessionEvent.Status -> if (event.state == "exited") null else current
|
||||||
|
else -> current
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field whose label is a line above it rather than a thing floating inside it.
|
||||||
|
*
|
||||||
|
* Every field in this app goes through here, and the reason is vertical space. Material's outlined
|
||||||
|
* field reserves room for a label that animates into its own border and pads the value by half a
|
||||||
|
* line top and bottom, so one setting costs the height of three lines of text to hold one. A form
|
||||||
|
* of ten settings is then a screen and a half of scrolling to read ten short answers.
|
||||||
|
*
|
||||||
|
* What is *not* shrunk is the value itself: it stays at body size, because what is expensive here
|
||||||
|
* is the framing rather than the text, and a field whose contents are smaller than the text beside
|
||||||
|
* it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit.
|
||||||
|
*
|
||||||
|
* [hint] is what leaving it blank means, and it goes above the box with the label for the same
|
||||||
|
* reason the label does: inside, it is gone the moment anybody types, which is exactly when a
|
||||||
|
* reader looks back to check what they are overriding.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LabelledField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
hint: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* How many lines the box is, at rest. One for a value; several for prose, where the reader is
|
||||||
|
* writing rather than filling in -- see `ParamKind::Prose`.
|
||||||
|
*/
|
||||||
|
lines: Int = 1,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
/** What the keyboard's own action key does, which is usually what the button beside it does. */
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
) {
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
|
)
|
||||||
|
hint?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */
|
||||||
|
@Composable
|
||||||
|
private fun FieldBox(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
|
lines: Int,
|
||||||
|
keyboardOptions: KeyboardOptions,
|
||||||
|
keyboardActions: KeyboardActions,
|
||||||
|
) {
|
||||||
|
val interactions = remember { MutableInteractionSource() }
|
||||||
|
val focused by interactions.collectIsFocusedAsState()
|
||||||
|
// The focused border is the accent at the same width as the resting one. Growing it instead
|
||||||
|
// would move the text inside by a pixel on every focus, which is a whole form twitching as the
|
||||||
|
// reader moves down it.
|
||||||
|
val edge =
|
||||||
|
when {
|
||||||
|
!enabled -> MaterialTheme.colorScheme.outlineVariant
|
||||||
|
focused -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.outline
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
val style =
|
||||||
|
LocalTextStyle.current.merge(
|
||||||
|
TextStyle(
|
||||||
|
color =
|
||||||
|
if (enabled) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = lines == 1,
|
||||||
|
minLines = lines,
|
||||||
|
textStyle = style,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
interactionSource = interactions,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, shape)
|
||||||
|
.border(1.dp, edge, shape)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
decorationBox = { field -> Box { field() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -44,26 +43,60 @@ import kotlinx.coroutines.withContext
|
|||||||
/**
|
/**
|
||||||
* Which machine's files to show, and where to start.
|
* Which machine's files to show, and where to start.
|
||||||
*
|
*
|
||||||
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says
|
||||||
* where it was working. That is what makes a second way in -- from the setups tab -- one more
|
* where it was working. That is what makes a second way in -- from the machines tab -- one more
|
||||||
* caller rather than any new code here.
|
* caller rather than any new code here.
|
||||||
*/
|
*/
|
||||||
data class FilesTarget(val setup: String, val setupName: String, val start: String)
|
data class FilesTarget(
|
||||||
|
val machine: String,
|
||||||
|
val machineName: String,
|
||||||
|
val start: String,
|
||||||
|
/** A document to open immediately; [start] remains the fallback directory. */
|
||||||
|
val file: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The explorer opened on one file, wherever that file is.
|
||||||
|
*
|
||||||
|
* Its directory is what the reader lands in on the way back, which for a session's transcript is
|
||||||
|
* that session's own directory -- the log, the process record and the rest of what it wrote.
|
||||||
|
*/
|
||||||
|
fun fileTarget(file: FileOnMachine) =
|
||||||
|
FilesTarget(
|
||||||
|
machine = file.machine,
|
||||||
|
machineName = file.machineName,
|
||||||
|
start = parentOf(file.path) ?: "/",
|
||||||
|
file = file.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The explorer target for this session's machine, optionally opened on [file]. */
|
||||||
|
fun SessionSummary.filesTarget(file: String? = null) =
|
||||||
|
FilesTarget(
|
||||||
|
machine = machine,
|
||||||
|
machineName = machineName,
|
||||||
|
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||||
|
file = file,
|
||||||
|
)
|
||||||
|
|
||||||
/** Where the explorer is: in a directory, or in one file. */
|
/** Where the explorer is: in a directory, or in one file. */
|
||||||
private sealed class Spot(val path: String) {
|
private sealed class Spot(val path: String) {
|
||||||
class Dir(path: String) : Spot(path)
|
class Dir(path: String) : Spot(path)
|
||||||
|
|
||||||
class Doc(path: String) : Spot(path)
|
class Doc(path: String, val directory: Dir) : Spot(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class UnsavedDestination {
|
||||||
|
Directory,
|
||||||
|
Session,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The files on the machine a session runs on: browse them, read one, change one.
|
* The files on the machine a session runs on: browse them, read one, change one.
|
||||||
*
|
*
|
||||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||||
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
|
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
|
||||||
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
|
* directory. In a directory, Android back walks toward the session's project directory and closes
|
||||||
* where it opened.
|
* the explorer once it gets there; the header's back button closes it immediately.
|
||||||
*
|
*
|
||||||
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
||||||
* how one gets asked again on purpose, and creating something refetches the directory it was
|
* how one gets asked again on purpose, and creating something refetches the directory it was
|
||||||
@@ -72,51 +105,82 @@ private sealed class Spot(val path: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
val initialDirectory =
|
||||||
|
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
|
||||||
|
var here by
|
||||||
|
remember(target) {
|
||||||
|
mutableStateOf<Spot>(
|
||||||
|
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||||
// because they are what back has to know about -- and back arrives from two places, the arrow
|
// because both ways out have to ask before discarding it.
|
||||||
// and the platform's own gesture, which must mean the same thing.
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
var editing by remember { mutableStateOf(false) }
|
||||||
var dirty by remember { mutableStateOf(false) }
|
var dirty by remember { mutableStateOf(false) }
|
||||||
var askUnsaved by remember { mutableStateOf(false) }
|
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
|
||||||
|
|
||||||
val here = stack.last()
|
|
||||||
|
|
||||||
fun go(spot: Spot) {
|
fun go(spot: Spot) {
|
||||||
editing = false
|
editing = false
|
||||||
dirty = false
|
dirty = false
|
||||||
stack = stack + spot
|
here = spot
|
||||||
}
|
}
|
||||||
|
|
||||||
fun back() {
|
fun leave(destination: UnsavedDestination) {
|
||||||
when {
|
if (editing && dirty) {
|
||||||
editing && dirty -> askUnsaved = true
|
unsavedDestination = destination
|
||||||
editing -> editing = false
|
} else if (destination == UnsavedDestination.Directory) {
|
||||||
stack.size > 1 -> {
|
go((here as Spot.Doc).directory)
|
||||||
stack = stack.dropLast(1)
|
} else {
|
||||||
editing = false
|
onClose()
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
else -> onClose()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun load(path: String, again: Boolean) {
|
suspend fun load(path: String, again: Boolean) {
|
||||||
if (!again && listings[path] is LoadState.Loaded) return
|
val existing = listings[path]
|
||||||
|
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
|
||||||
listings[path] = LoadState.Loading
|
listings[path] = LoadState.Loading
|
||||||
listings[path] =
|
listings[path] =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
LoadState.Loaded(fetchDir(settings, target.machine, path))
|
||||||
}
|
}
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = ::back)
|
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path
|
||||||
|
val homeDirectory =
|
||||||
|
if (target.start == "~") projectDirectory
|
||||||
|
else (listings["~"] as? LoadState.Loaded)?.value?.path
|
||||||
|
|
||||||
|
fun systemBack() {
|
||||||
|
when (val spot = here) {
|
||||||
|
is Spot.Doc -> leave(UnsavedDestination.Directory)
|
||||||
|
is Spot.Dir -> {
|
||||||
|
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||||
|
when {
|
||||||
|
path == projectDirectory || path == target.start -> onClose()
|
||||||
|
projectDirectory != null ->
|
||||||
|
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
|
||||||
|
?: onClose()
|
||||||
|
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file link can open without visiting the project first, but Back still needs to know where
|
||||||
|
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
|
||||||
|
// it is what lets every path beneath it be displayed with `~`, including over ssh.
|
||||||
|
LaunchedEffect(target.machine, target.start) {
|
||||||
|
if (target.file != null) load(target.start, again = false)
|
||||||
|
if (target.start != "~") load("~", again = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = ::systemBack)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@@ -129,14 +193,15 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
when (val spot = here) {
|
when (val spot = here) {
|
||||||
is Spot.Dir -> {
|
is Spot.Dir -> {
|
||||||
val state = listings[spot.path] ?: LoadState.Loading
|
val state = listings[spot.path] ?: LoadState.Loading
|
||||||
// The resolved path once there is one: a directory opened as `~` is called what
|
// Navigate with the resolved path, but name anything under the machine's home
|
||||||
// it turned out to be, not what it was asked for.
|
// the way somebody working there would write it.
|
||||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||||
|
val shownAt = tildePath(at, homeDirectory)
|
||||||
FilesHeader(
|
FilesHeader(
|
||||||
title = baseName(at),
|
title = baseName(shownAt),
|
||||||
path = at,
|
path = shownAt,
|
||||||
machine = target.setupName,
|
machine = target.machineName,
|
||||||
onBack = ::back,
|
onBack = { leave(UnsavedDestination.Session) },
|
||||||
) {
|
) {
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
REFRESH_GLYPH,
|
REFRESH_GLYPH,
|
||||||
@@ -152,7 +217,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||||
DirectoryBody(state, onOpen = ::go)
|
DirectoryBody(state, directory = spot, onOpen = ::go)
|
||||||
}
|
}
|
||||||
is Spot.Doc ->
|
is Spot.Doc ->
|
||||||
DocPane(
|
DocPane(
|
||||||
@@ -161,22 +226,26 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
path = spot.path,
|
path = spot.path,
|
||||||
name = baseName(spot.path),
|
name = baseName(spot.path),
|
||||||
editing = editing,
|
editing = editing,
|
||||||
|
homeDirectory = homeDirectory,
|
||||||
onEditing = { editing = it },
|
onEditing = { editing = it },
|
||||||
onDirty = { dirty = it },
|
onDirty = { dirty = it },
|
||||||
onBack = ::back,
|
onBack = { leave(UnsavedDestination.Directory) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (askUnsaved) {
|
unsavedDestination?.let { destination ->
|
||||||
UnsavedDialog(
|
UnsavedDialog(
|
||||||
onDiscard = {
|
onDiscard = {
|
||||||
askUnsaved = false
|
unsavedDestination = null
|
||||||
editing = false
|
if (destination == UnsavedDestination.Directory) {
|
||||||
dirty = false
|
go((here as Spot.Doc).directory)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCancel = { askUnsaved = false },
|
onCancel = { unsavedDestination = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +254,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
if (creating && dir != null && listing != null) {
|
if (creating && dir != null && listing != null) {
|
||||||
CreateDialog(
|
CreateDialog(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
setup = target.setup,
|
machine = target.machine,
|
||||||
directory = listing.path,
|
directory = listing.path,
|
||||||
onDismiss = { creating = false },
|
onDismiss = { creating = false },
|
||||||
onCreated = { path, isDirectory ->
|
onCreated = { path, isDirectory ->
|
||||||
@@ -196,7 +265,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
load(dir.path, again = true)
|
load(dir.path, again = true)
|
||||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
go(Spot.Doc(path))
|
go(Spot.Doc(path, dir))
|
||||||
editing = true
|
editing = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +317,11 @@ private fun FilesHeader(
|
|||||||
* looks like a right one.
|
* looks like a right one.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
|
private fun ColumnScope.DirectoryBody(
|
||||||
|
state: LoadState<Listing>,
|
||||||
|
directory: Spot.Dir,
|
||||||
|
onOpen: (Spot) -> Unit,
|
||||||
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||||
is LoadState.Error ->
|
is LoadState.Error ->
|
||||||
@@ -289,7 +362,9 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
|
|||||||
name = entry.name,
|
name = entry.name,
|
||||||
trailing = trailingOf(entry),
|
trailing = trailingOf(entry),
|
||||||
onClick = {
|
onClick = {
|
||||||
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
|
onOpen(
|
||||||
|
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -357,6 +432,7 @@ private fun ColumnScope.DocPane(
|
|||||||
path: String,
|
path: String,
|
||||||
name: String,
|
name: String,
|
||||||
editing: Boolean,
|
editing: Boolean,
|
||||||
|
homeDirectory: String?,
|
||||||
onEditing: (Boolean) -> Unit,
|
onEditing: (Boolean) -> Unit,
|
||||||
onDirty: (Boolean) -> Unit,
|
onDirty: (Boolean) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -381,7 +457,7 @@ private fun ColumnScope.DocPane(
|
|||||||
state = LoadState.Loading
|
state = LoadState.Loading
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
|
||||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||||
LoadState.Loaded(got)
|
LoadState.Loaded(got)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -404,7 +480,7 @@ private fun ColumnScope.DocPane(
|
|||||||
try {
|
try {
|
||||||
val written =
|
val written =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
writeFile(settings, target.setup, path, draft.text, against)
|
writeFile(settings, target.machine, path, draft.text, against)
|
||||||
}
|
}
|
||||||
state =
|
state =
|
||||||
LoadState.Loaded(
|
LoadState.Loaded(
|
||||||
@@ -430,7 +506,12 @@ private fun ColumnScope.DocPane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
|
FilesHeader(
|
||||||
|
title = name,
|
||||||
|
path = tildePath(path, homeDirectory),
|
||||||
|
machine = target.machineName,
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
if (saving) {
|
if (saving) {
|
||||||
GlyphSpinner("Saving")
|
GlyphSpinner("Saving")
|
||||||
@@ -527,7 +608,9 @@ private fun ColumnScope.DocPane(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val fresh =
|
val fresh =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
withContext(Dispatchers.IO) {
|
||||||
|
fetchFile(settings, target.machine, path)
|
||||||
|
}
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
saveError = e.message
|
saveError = e.message
|
||||||
conflict = null
|
conflict = null
|
||||||
@@ -571,7 +654,7 @@ private fun Note(text: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CreateDialog(
|
private fun CreateDialog(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
setup: String,
|
machine: String,
|
||||||
directory: String,
|
directory: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onCreated: (String, Boolean) -> Unit,
|
onCreated: (String, Boolean) -> Unit,
|
||||||
@@ -591,8 +674,8 @@ private fun CreateDialog(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (isDirectory) createDir(settings, setup, path)
|
if (isDirectory) createDir(settings, machine, path)
|
||||||
else createFile(settings, setup, path)
|
else createFile(settings, machine, path)
|
||||||
}
|
}
|
||||||
onCreated(path, isDirectory)
|
onCreated(path, isDirectory)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -609,13 +692,11 @@ private fun CreateDialog(
|
|||||||
title = { Text("Create in ${baseName(directory)}") },
|
title = { Text("Create in ${baseName(directory)}") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
OutlinedTextField(
|
LabelledField(
|
||||||
|
label = "Name",
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -678,6 +759,35 @@ internal fun parentOf(path: String): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next directory on the filesystem path from [current] to [destination], or null when there.
|
||||||
|
*
|
||||||
|
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
|
||||||
|
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
|
||||||
|
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
|
||||||
|
*/
|
||||||
|
internal fun nextDirectoryToward(current: String, destination: String): String? {
|
||||||
|
val here = current.trimEnd('/').ifEmpty { "/" }
|
||||||
|
val there = destination.trimEnd('/').ifEmpty { "/" }
|
||||||
|
if (here == there) return null
|
||||||
|
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
|
||||||
|
if (!beneathHere) return parentOf(here)
|
||||||
|
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
|
||||||
|
return join(here, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
|
||||||
|
internal fun tildePath(path: String, home: String?): String {
|
||||||
|
val at = path.trimEnd('/').ifEmpty { "/" }
|
||||||
|
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
|
||||||
|
return when {
|
||||||
|
at == resolvedHome -> "~"
|
||||||
|
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
|
||||||
|
"~${at.removePrefix(resolvedHome)}"
|
||||||
|
else -> at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** What a path names: its last segment, with `/` naming itself. */
|
/** What a path names: its last segment, with `/` naming itself. */
|
||||||
internal fun baseName(path: String): String {
|
internal fun baseName(path: String): String {
|
||||||
val trimmed = path.trimEnd('/')
|
val trimmed = path.trimEnd('/')
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||||||
|
|
||||||
/** What a span of code is, in the terms the palette has a colour for. */
|
/** What a span of code is, in the terms the palette has a colour for. */
|
||||||
enum class Kind {
|
enum class Kind {
|
||||||
|
ADDITION,
|
||||||
|
DELETION,
|
||||||
KEYWORD,
|
KEYWORD,
|
||||||
STRING,
|
STRING,
|
||||||
LITERAL,
|
LITERAL,
|
||||||
@@ -24,6 +26,8 @@ data class Span(val start: Int, val end: Int, val kind: Kind)
|
|||||||
* one instance and lives with the rest of the palette.
|
* one instance and lives with the rest of the palette.
|
||||||
*/
|
*/
|
||||||
data class SyntaxPalette(
|
data class SyntaxPalette(
|
||||||
|
val addition: Color,
|
||||||
|
val deletion: Color,
|
||||||
val keyword: Color,
|
val keyword: Color,
|
||||||
val string: Color,
|
val string: Color,
|
||||||
val literal: Color,
|
val literal: Color,
|
||||||
@@ -34,6 +38,8 @@ data class SyntaxPalette(
|
|||||||
) {
|
) {
|
||||||
fun of(kind: Kind): Color =
|
fun of(kind: Kind): Color =
|
||||||
when (kind) {
|
when (kind) {
|
||||||
|
Kind.ADDITION -> addition
|
||||||
|
Kind.DELETION -> deletion
|
||||||
Kind.KEYWORD -> keyword
|
Kind.KEYWORD -> keyword
|
||||||
Kind.STRING -> string
|
Kind.STRING -> string
|
||||||
Kind.LITERAL -> literal
|
Kind.LITERAL -> literal
|
||||||
@@ -44,6 +50,26 @@ data class SyntaxPalette(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A unified diff is line-oriented: colour the changed lines and leave context untouched. */
|
||||||
|
fun scanDiff(code: String): List<Span> {
|
||||||
|
val spans = ArrayList<Span>()
|
||||||
|
var start = 0
|
||||||
|
while (start < code.length) {
|
||||||
|
val end = code.indexOf('\n', start).let { if (it == -1) code.length else it }
|
||||||
|
val kind =
|
||||||
|
when {
|
||||||
|
code.startsWith("+++", start) || code.startsWith("---", start) -> Kind.METADATA
|
||||||
|
code.startsWith("+", start) -> Kind.ADDITION
|
||||||
|
code.startsWith("-", start) -> Kind.DELETION
|
||||||
|
code.startsWith("@@", start) -> Kind.METADATA
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (kind != null) spans.add(Span(start, end, kind))
|
||||||
|
start = if (end == code.length) end else end + 1
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
|
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
var chosen by remember { mutableStateOf<Machine?>(null) }
|
||||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
||||||
|
|
||||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
||||||
@@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
||||||
// themselves, not a flag, so the dialog can say what it is about.
|
// themselves, not a flag, so the dialog can say what it is about.
|
||||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
||||||
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
|
// Set from the selected Claude provider rather than repeated in the app.
|
||||||
// times.
|
var permissionMode by remember { mutableStateOf("") }
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
|
||||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
||||||
// so a tap reading it needs no recomposition.
|
// so a tap reading it needs no recomposition.
|
||||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||||
@@ -114,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
* Taken from the answer rather than kept across the load: the server is what knows what is
|
* Taken from the answer rather than kept across the load: the server is what knows what is
|
||||||
* running, and this screen may be opening on work another phone started.
|
* running, and this screen may be opening on work another phone started.
|
||||||
*/
|
*/
|
||||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
|
||||||
try {
|
try {
|
||||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
|
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
|
||||||
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
||||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
||||||
LoadState.Loaded(rows)
|
LoadState.Loaded(rows)
|
||||||
@@ -124,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSessions(setup: Setup) {
|
fun loadSessions(machine: Machine) {
|
||||||
sessions = LoadState.Loading
|
sessions = LoadState.Loading
|
||||||
selected = emptySet()
|
selected = emptySet()
|
||||||
scope.launch { sessions = fetchInto(setup) }
|
scope.launch { sessions = fetchInto(machine) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
||||||
@@ -141,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) {
|
LaunchedEffect(reloadToken) {
|
||||||
setups =
|
machines =
|
||||||
try {
|
try {
|
||||||
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||||
found.firstOrNull()?.let {
|
found.firstOrNull()?.let {
|
||||||
chosen = it
|
chosen = it
|
||||||
loadSessions(it)
|
loadSessions(it)
|
||||||
@@ -171,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
selected = emptySet()
|
selected = emptySet()
|
||||||
running = running + targets.associate { it.id to WAITING }
|
running = running + targets.associate { it.id to WAITING }
|
||||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
||||||
val setup = chosen
|
val machine = chosen
|
||||||
val ids = targets.map { it.id }
|
val ids = targets.map { it.id }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
||||||
@@ -198,25 +197,28 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
// The listing is the repair, because it carries the same state the events do. Only when
|
// The listing is the repair, because it carries the same state the events do. Only when
|
||||||
// something still looks outstanding, so the ordinary case does not pay for a second
|
// something still looks outstanding, so the ordinary case does not pay for a second
|
||||||
// listing, which is the most expensive call this screen makes.
|
// listing, which is the most expensive call this screen makes.
|
||||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
if (machine != null && targets.any { running.containsKey(it.id) }) {
|
||||||
// Quietly: no Loading, because blanking the list to report on rows that are already
|
// Quietly: no Loading, because blanking the list to report on rows that are already
|
||||||
// saying what is happening to them is the flicker this screen avoids everywhere
|
// saying what is happening to them is the flicker this screen avoids everywhere
|
||||||
// else.
|
// else.
|
||||||
sessions = fetchInto(setup)
|
sessions = fetchInto(machine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
||||||
|
LaunchedEffect(chosen?.id, provider?.name) {
|
||||||
|
permissionMode = provider?.defaultPermissionMode.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||||
fun importAll(targets: List<Importable>) {
|
fun importAll(targets: List<Importable>) {
|
||||||
val setup = chosen ?: return
|
val machine = chosen ?: return
|
||||||
val useProvider = provider ?: return
|
val useProvider = provider ?: return
|
||||||
handOver(targets) { ids ->
|
handOver(targets) { ids ->
|
||||||
startImport(
|
startImport(
|
||||||
settings,
|
settings,
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
sessionIds = ids,
|
sessionIds = ids,
|
||||||
provider = useProvider.name,
|
provider = useProvider.name,
|
||||||
permissionMode = permissionMode,
|
permissionMode = permissionMode,
|
||||||
@@ -232,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
* it, which is the case where waiting is the right thing anyway.
|
* it, which is the case where waiting is the right thing anyway.
|
||||||
*/
|
*/
|
||||||
fun importAndOpen(target: Importable) {
|
fun importAndOpen(target: Importable) {
|
||||||
val setup = chosen ?: return
|
val machine = chosen ?: return
|
||||||
val useProvider = provider ?: return
|
val useProvider = provider ?: return
|
||||||
running = running + (target.id to IMPORTING)
|
running = running + (target.id to IMPORTING)
|
||||||
rowErrors = rowErrors - target.id
|
rowErrors = rowErrors - target.id
|
||||||
@@ -242,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
provider = useProvider.name,
|
provider = useProvider.name,
|
||||||
// Nothing to say: the server titles it from the session it continues.
|
// Nothing to say: the server titles it from the session it continues.
|
||||||
title = "",
|
title = "",
|
||||||
@@ -270,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||||
}
|
}
|
||||||
LaunchedEffect(chosen?.id) {
|
LaunchedEffect(chosen?.id) {
|
||||||
val setup = chosen?.id ?: return@LaunchedEffect
|
val machine = chosen?.id ?: return@LaunchedEffect
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
val stream = ImportableStream(settings, setup)
|
val stream = ImportableStream(settings, machine)
|
||||||
liveChanges.set(stream)
|
liveChanges.set(stream)
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -341,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
when (val loaded = setups) {
|
when (val loaded = machines) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded -> {
|
is LoadState.Loaded -> {
|
||||||
// Only worth choosing when there is a choice.
|
// Only worth choosing when there is a choice.
|
||||||
if (loaded.value.size > 1) {
|
if (loaded.value.size > 1) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
loaded.value.forEach { setup ->
|
loaded.value.forEach { machine ->
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
chosen = setup
|
chosen = machine
|
||||||
loadSessions(setup)
|
loadSessions(machine)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
setup.name,
|
machine.name,
|
||||||
color =
|
color =
|
||||||
if (setup.id == chosen?.id)
|
if (machine.id == chosen?.id)
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
} else {
|
} else {
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Permissions",
|
label = "Permissions",
|
||||||
options = PERMISSION_MODES,
|
options = provider?.permissionModes.orEmpty(),
|
||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
onSelect = { permissionMode = it },
|
||||||
)
|
)
|
||||||
@@ -439,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val setup = chosen ?: return@TextButton
|
val machine = chosen ?: return@TextButton
|
||||||
confirming = null
|
confirming = null
|
||||||
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
|
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Coloured by consequence: this takes something away, wherever it appears.
|
// Coloured by consequence: this takes something away, wherever it appears.
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ package com.example.aiapp
|
|||||||
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
||||||
* the next listing.
|
* the next listing.
|
||||||
*/
|
*/
|
||||||
class ImportableStream(settings: ServerSettings, private val setup: String) {
|
class ImportableStream(settings: ServerSettings, private val machine: String) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
|
|
||||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
||||||
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
|
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
|
||||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum class Language {
|
|||||||
CPP,
|
CPP,
|
||||||
CSHARP,
|
CSHARP,
|
||||||
DART,
|
DART,
|
||||||
|
DIFF,
|
||||||
FISH,
|
FISH,
|
||||||
GO,
|
GO,
|
||||||
JAVA,
|
JAVA,
|
||||||
@@ -100,7 +101,7 @@ fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(la
|
|||||||
// Lazy for the same reason [RULES] is, since it reads it.
|
// Lazy for the same reason [RULES] is, since it reads it.
|
||||||
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
||||||
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
||||||
mapOf(Language.MARKDOWN to ::scanMarkdown)
|
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
|
||||||
|
*
|
||||||
|
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
|
||||||
|
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
|
||||||
|
* there is no such thing as "the models", only this machine's, and the screen that decides how a
|
||||||
|
* model is loaded is the screen that should be able to fetch one.
|
||||||
|
*
|
||||||
|
* Everything here is the machine's state rather than this screen's. A download is a process on that
|
||||||
|
* machine with its progress written beside the partial file, so closing the app, locking the phone
|
||||||
|
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class MachineModelsState(
|
||||||
|
private val settings: ServerSettings,
|
||||||
|
private val machineId: String,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var query by mutableStateOf("")
|
||||||
|
|
||||||
|
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var openRepo by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** What the last action said went wrong, shown above the list that action was taken in. */
|
||||||
|
var actionError by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val models: Models?
|
||||||
|
get() = (state as? LoadState.Loaded)?.value
|
||||||
|
|
||||||
|
val downloads: List<Download>
|
||||||
|
get() = models?.downloads.orEmpty()
|
||||||
|
|
||||||
|
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
|
||||||
|
val sizes: Map<String, Long>
|
||||||
|
get() = models?.local.orEmpty().associate { it.key to it.bytes }
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
LoadState.Loaded(fetchMachineModels(settings, machineId))
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
|
||||||
|
private fun act(action: suspend () -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
actionError =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search() {
|
||||||
|
openRepo = null
|
||||||
|
results = LoadState.Loading
|
||||||
|
scope.launch {
|
||||||
|
results =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleRepo(repo: String) {
|
||||||
|
if (openRepo == repo) {
|
||||||
|
openRepo = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openRepo = repo
|
||||||
|
repoFiles = LoadState.Loading
|
||||||
|
scope.launch {
|
||||||
|
repoFiles =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(repo: String, file: String) = act {
|
||||||
|
startDownload(settings, machineId, repo, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
|
||||||
|
|
||||||
|
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One machine's models, asked for again while this screen is open.
|
||||||
|
*
|
||||||
|
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
|
||||||
|
* event stream of its own. Faster while something is downloading, because that is the only thing
|
||||||
|
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
|
||||||
|
* be a progress bar that moved in jumps.
|
||||||
|
*
|
||||||
|
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
|
||||||
|
* around this learns that a download has become a model it must now draw settings for.
|
||||||
|
*
|
||||||
|
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
|
||||||
|
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
|
||||||
|
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
|
||||||
|
* keeps its search results across the moment the provider's kind arrives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberMachineModels(
|
||||||
|
settings: ServerSettings,
|
||||||
|
machineId: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onLocalChange: () -> Unit,
|
||||||
|
): MachineModelsState {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
|
||||||
|
LaunchedEffect(state, enabled) {
|
||||||
|
if (!enabled) return@LaunchedEffect
|
||||||
|
var known: List<String>? = null
|
||||||
|
while (true) {
|
||||||
|
state.reload()
|
||||||
|
val local = state.models?.local?.map { it.key }
|
||||||
|
if (local != null) {
|
||||||
|
if (known != null && known != local) onLocalChange()
|
||||||
|
known = local
|
||||||
|
}
|
||||||
|
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is being fetched onto this machine, above the models it already has. */
|
||||||
|
fun LazyListScope.downloadCards(state: MachineModelsState) {
|
||||||
|
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
|
||||||
|
DownloadCard(
|
||||||
|
download = download,
|
||||||
|
onCancel = { state.cancel(download.key) },
|
||||||
|
onResume = { state.download(download.repo, download.file) },
|
||||||
|
onRemove = { state.remove(download.key) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding a model to fetch: a search, and what it found.
|
||||||
|
*
|
||||||
|
* Below the models this machine has rather than above them, because what is here is what the reader
|
||||||
|
* came for and getting another is the rarer errand.
|
||||||
|
*/
|
||||||
|
fun LazyListScope.modelSearch(state: MachineModelsState) {
|
||||||
|
item("search") {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Get another model", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
"Downloaded onto this machine, which is where llama.cpp reads it from.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
LabelledField(
|
||||||
|
label = "Search HuggingFace",
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = { state.query = it },
|
||||||
|
// The keyboard's own key searches, and puts itself away to show what it found. The
|
||||||
|
// button below this is under the keyboard while it is up, so without this the only
|
||||||
|
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
keyboard?.hide()
|
||||||
|
state.search()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
enabled = state.query.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
state.search()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val found = state.results) {
|
||||||
|
null -> {}
|
||||||
|
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
|
||||||
|
is LoadState.Error ->
|
||||||
|
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
|
||||||
|
val open = state.openRepo == repo.id
|
||||||
|
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
|
||||||
|
// Inside the expanded repository's own item rather than as a section after the
|
||||||
|
// list: drawn after every card, a repository's files read as belonging to
|
||||||
|
// whichever card happened to be last.
|
||||||
|
if (open) {
|
||||||
|
when (val files = state.repoFiles) {
|
||||||
|
null -> {}
|
||||||
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
|
is LoadState.Error ->
|
||||||
|
Text(files.message, color = MaterialTheme.colorScheme.error)
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
Column {
|
||||||
|
val busy = state.downloads.map { it.key }.toSet()
|
||||||
|
files.value.forEach { file ->
|
||||||
|
RepoFileRow(
|
||||||
|
file,
|
||||||
|
downloading = "${repo.id}/${file.path}" in busy,
|
||||||
|
) {
|
||||||
|
state.download(repo.id, file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloadCard(
|
||||||
|
download: Download,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onResume: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val running = download.state == "running" || download.state == "verifying"
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(download.file, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
download.repo,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// A determinate bar only when the size is known. HuggingFace sends no size when it
|
||||||
|
// was never told one, and a bar drawn from a guess is worse than one that admits it
|
||||||
|
// is counting.
|
||||||
|
if (download.total != null && download.total > 0) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { download.done.toFloat() / download.total.toFloat() },
|
||||||
|
// Blue at every value, unlike a quota bar: a download nearing its end is
|
||||||
|
// nearing success, and colouring it like a limit being approached would say
|
||||||
|
// the opposite.
|
||||||
|
color = progressColor,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
} else if (running) {
|
||||||
|
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
|
||||||
|
Text(
|
||||||
|
"${gigabytes(download.done)} so far, total size unknown",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download.error?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
download.state,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (running) {
|
||||||
|
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||||
|
} else {
|
||||||
|
// A stopped download kept its partial file, so carrying on is the cheap
|
||||||
|
// answer and starting again is not the only one offered.
|
||||||
|
TextButton(onClick = onResume) { Text("Resume") }
|
||||||
|
TextButton(onClick = onRemove) { Text("Remove") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
repo.id,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
// The owner is the part that repeats; the model name at the end is what tells
|
||||||
|
// two entries apart.
|
||||||
|
overflow = TextOverflow.StartEllipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${repo.downloads} downloads · ${repo.likes} likes",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(file.path, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
gigabytes(file.bytes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Disabled rather than absent, so the row reads the same whether this one is absent,
|
||||||
|
// already here, or on its way. Offering "Download" for a file that is downloading would be
|
||||||
|
// a button that does nothing anyone can see.
|
||||||
|
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
file.have -> "Downloaded"
|
||||||
|
downloading -> "Downloading"
|
||||||
|
else -> "Download"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gigabytes(bytes: Long): String =
|
||||||
|
if (bytes >= 1_000_000_000) {
|
||||||
|
"%.2f GB".format(bytes / 1_000_000_000.0)
|
||||||
|
} else {
|
||||||
|
"%.0f MB".format(bytes / 1_000_000.0)
|
||||||
|
}
|
||||||
+146
-64
@@ -1,5 +1,7 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -10,9 +12,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,6 +26,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -37,19 +44,25 @@ import kotlinx.coroutines.withContext
|
|||||||
* which is what keeps the enrolled token from being able to introduce commands.
|
* which is what keeps the enrolled token from being able to introduce commands.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
fun MachinesScreen(
|
||||||
|
settings: ServerSettings,
|
||||||
|
reloadToken: Int,
|
||||||
|
/** Opens one provider on one machine -- its settings, and what its server is holding. */
|
||||||
|
onProvider: (String, String) -> Unit,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
var adding by remember { mutableStateOf(false) }
|
||||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
var renaming by remember { mutableStateOf<Machine?>(null) }
|
||||||
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
|
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
|
||||||
|
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
|
||||||
var busy by remember { mutableStateOf<String?>(null) }
|
var busy by remember { mutableStateOf<String?>(null) }
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
@@ -82,19 +95,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded ->
|
is LoadState.Loaded ->
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
uniqueItems(current.value, key = { it.id }) { setup ->
|
uniqueItems(current.value, key = { it.id }) { machine ->
|
||||||
SetupCard(
|
MachineCard(
|
||||||
setup = setup,
|
machine = machine,
|
||||||
onRename = { renaming = setup },
|
onRename = { renaming = machine },
|
||||||
onRediscover = {
|
onRediscover = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = "Asking ${setup.name} what it has…"
|
busy = "Asking ${machine.name} what it has…"
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
updateSetup(
|
updateMachine(
|
||||||
settings,
|
settings,
|
||||||
setup.id,
|
machine.id,
|
||||||
rediscover = true,
|
rediscover = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,7 +118,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete = { confirmingDelete = setup },
|
onDelete = { confirmingDelete = machine },
|
||||||
|
onSignIn = { provider -> signingIn = machine to provider },
|
||||||
|
onProvider = { provider -> onProvider(machine.id, provider.name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +128,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (adding) {
|
if (adding) {
|
||||||
AddSetupDialog(
|
AddMachineDialog(
|
||||||
onDismiss = { adding = false },
|
onDismiss = { adding = false },
|
||||||
onAdd = { name, ssh ->
|
onAdd = { name, ssh ->
|
||||||
adding = false
|
adding = false
|
||||||
@@ -121,7 +136,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
busy = "Asking $name what it has…"
|
busy = "Asking $name what it has…"
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
?.message
|
?.message
|
||||||
@@ -129,13 +144,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renaming?.let { setup ->
|
renaming?.let { machine ->
|
||||||
RenameDialog(
|
RenameDialog(
|
||||||
setup = setup,
|
machine = machine,
|
||||||
onDismiss = { renaming = null },
|
onDismiss = { renaming = null },
|
||||||
onRename = { name ->
|
onRename = { name ->
|
||||||
renaming = null
|
renaming = null
|
||||||
@@ -143,7 +158,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
updateSetup(settings, setup.id, name = name)
|
updateMachine(settings, machine.id, name = name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
@@ -154,10 +169,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmingDelete?.let { setup ->
|
confirmingDelete?.let { machine ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmingDelete = null },
|
onDismissRequest = { confirmingDelete = null },
|
||||||
title = { Text("Remove \"${setup.name}\"?") },
|
title = { Text("Remove \"${machine.name}\"?") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"The machine is left alone -- this only stops this app offering it. " +
|
"The machine is left alone -- this only stops this app offering it. " +
|
||||||
@@ -171,7 +186,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
withContext(Dispatchers.IO) {
|
||||||
|
deleteMachine(settings, machine.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
?.message
|
?.message
|
||||||
@@ -187,34 +204,99 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingIn?.let { (machine, provider) ->
|
||||||
|
ProviderLoginDialog(
|
||||||
|
settings = settings,
|
||||||
|
machineId = machine.id,
|
||||||
|
machineName = machine.name,
|
||||||
|
provider = provider.name,
|
||||||
|
onDismiss = { signingIn = null },
|
||||||
|
onSignedIn = {
|
||||||
|
signingIn = null
|
||||||
|
scope.launch { reload() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SetupCard(
|
private fun MachineCard(
|
||||||
setup: Setup,
|
machine: Machine,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onRediscover: () -> Unit,
|
onRediscover: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onSignIn: (Provider) -> Unit,
|
||||||
|
onProvider: (Provider) -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
Text(
|
||||||
// Not "this machine": the seeded setup is *called* that, and the card read "this
|
// Not "this machine": the seeded machine is *called* that, and the card read "this
|
||||||
// machine / this machine".
|
// machine / this machine".
|
||||||
setup.address ?: "runs where the backend does",
|
machine.address ?: "runs where the backend does",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
if (machine.providers.isEmpty()) {
|
||||||
if (setup.providers.isEmpty()) {
|
Text(
|
||||||
"Nothing found on it. Install something and rediscover."
|
"Nothing found on it. Install something and rediscover.",
|
||||||
} else {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
setup.providers.joinToString(" · ") { it.name }
|
)
|
||||||
},
|
} else {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
machine.providers.forEach { provider ->
|
||||||
)
|
// A card of its own rather than a line of text: a provider is where the
|
||||||
|
// settings that belong to *this machine* live -- how each of its models is
|
||||||
|
// loaded, the models themselves, and the server holding them -- and those had
|
||||||
|
// nowhere to be until one llama-server came to serve every session on a
|
||||||
|
// machine. Sized by its own padding rather than by whatever control happened
|
||||||
|
// to be on its row, like the tool call cards it is built after.
|
||||||
|
Card(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.clickable { onProvider(provider) }
|
||||||
|
.semantics { contentDescription = "Open ${provider.name}" },
|
||||||
|
// A border, and the machine card's own surface kept underneath it.
|
||||||
|
// The tint that was here before is one step along the surface ladder
|
||||||
|
// from the card it sits in, and two adjacent surfaces render as one flat
|
||||||
|
// block: these read as lines of text in a box rather than as things to
|
||||||
|
// open. One cue, and a visible one.
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(provider.name, style = MaterialTheme.typography.titleSmall)
|
||||||
|
// What was actually found, which is the honest second line and
|
||||||
|
// the one thing here nobody can change. No arrow: a card that
|
||||||
|
// lifts off the one behind it already reads as something to open,
|
||||||
|
// and the chevron was the only thing making these look like rows
|
||||||
|
// of a list.
|
||||||
|
provider.command?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
// A program is identified by its name, which is the tail
|
||||||
|
// of its path.
|
||||||
|
overflow = TextOverflow.StartEllipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (provider.kind == "claude_cli") {
|
||||||
|
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
TextButton(onClick = onRename) { Text("Rename") }
|
TextButton(onClick = onRename) { Text("Rename") }
|
||||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
||||||
@@ -226,7 +308,7 @@ private fun SetupCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddSetupDialog(
|
private fun AddMachineDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (String, SshDetails?) -> Unit,
|
onAdd: (String, SshDetails?) -> Unit,
|
||||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
onTest: suspend (SshDetails?) -> List<Provider>,
|
||||||
@@ -236,6 +318,7 @@ private fun AddSetupDialog(
|
|||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var identity by remember { mutableStateOf("") }
|
var identity by remember { mutableStateOf("") }
|
||||||
var attachmentsDir by remember { mutableStateOf("") }
|
var attachmentsDir by remember { mutableStateOf("") }
|
||||||
|
var modelsDir by remember { mutableStateOf("") }
|
||||||
var tested by remember { mutableStateOf<String?>(null) }
|
var tested by remember { mutableStateOf<String?>(null) }
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -250,6 +333,7 @@ private fun AddSetupDialog(
|
|||||||
port = typedPort,
|
port = typedPort,
|
||||||
identityFile = identity.trim().ifEmpty { null },
|
identityFile = identity.trim().ifEmpty { null },
|
||||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||||
|
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,33 +349,36 @@ private fun AddSetupDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
||||||
value = name,
|
Spacer(Modifier.height(8.dp))
|
||||||
onValueChange = { name = it },
|
LabelledField(
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = address,
|
|
||||||
onValueChange = { address = it },
|
|
||||||
// Just the shape. What a blank one means is said once, in the text above this
|
// Just the shape. What a blank one means is said once, in the text above this
|
||||||
// form -- repeating it here wrapped the label onto a second line.
|
// form -- repeating it here wrapped the label onto a second line.
|
||||||
label = { Text("user@host[:port]") },
|
label = "user@host[:port]",
|
||||||
singleLine = true,
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LabelledField(
|
||||||
|
label = "Key path on the backend",
|
||||||
value = identity,
|
value = identity,
|
||||||
onValueChange = { identity = it },
|
onValueChange = { identity = it },
|
||||||
label = { Text("Key path on the backend") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
)
|
||||||
// Where a file attached from the phone lands on that machine. Blank means the
|
Spacer(Modifier.height(8.dp))
|
||||||
// session's own directory, which is what most people want.
|
LabelledField(
|
||||||
OutlinedTextField(
|
// Where a file attached from the phone lands on that machine.
|
||||||
|
label = "Folder for attached files",
|
||||||
value = attachmentsDir,
|
value = attachmentsDir,
|
||||||
onValueChange = { attachmentsDir = it },
|
onValueChange = { attachmentsDir = it },
|
||||||
label = { Text("Folder for attached files (optional)") },
|
hint = "the session's own directory",
|
||||||
singleLine = true,
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LabelledField(
|
||||||
|
// Where that machine's GGUFs are, for a llama.cpp session on it.
|
||||||
|
label = "Folder for models",
|
||||||
|
value = modelsDir,
|
||||||
|
onValueChange = { modelsDir = it },
|
||||||
|
hint = "the same place this backend keeps its own downloads",
|
||||||
)
|
)
|
||||||
tested?.let {
|
tested?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -339,19 +426,14 @@ private fun AddSetupDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||||
var name by remember { mutableStateOf(setup.name) }
|
var name by remember { mutableStateOf(machine.name) }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Rename") },
|
title = { Text("Rename") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
OutlinedTextField(
|
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Sessions already running on it keep working -- they refer to the machine, " +
|
"Sessions already running on it keep working -- they refer to the machine, " +
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's root screen, in the full-width panel [SidePanels] slides over a session from the left.
|
||||||
|
*
|
||||||
|
* Not a list of its own but [MainScreen] itself, and the whole width of the screen: what a right
|
||||||
|
* swipe gets is the screen Back would have got, moved over the session instead of replacing it. The
|
||||||
|
* session stays composed underneath, with its stream open and its draft and scroll position where
|
||||||
|
* they were, so swiping the panel back off returns to it for nothing -- where Back and a tap costs
|
||||||
|
* the whole transcript over the tunnel again.
|
||||||
|
*
|
||||||
|
* Tapping the session already open is that same swipe back rather than a fresh screen: reopening it
|
||||||
|
* would hand [SessionScreen] a new summary for the conversation it is already showing.
|
||||||
|
*
|
||||||
|
* [onGone] is the one thing the list can do that this panel cannot survive -- deleting the very
|
||||||
|
* session it is drawn over. There is nothing left to swipe back into, so that closes the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MainPanel(
|
||||||
|
settings: ServerSettings,
|
||||||
|
sessionId: String,
|
||||||
|
active: Boolean,
|
||||||
|
onOpen: (SessionSummary) -> Unit,
|
||||||
|
onSpawn: () -> Unit,
|
||||||
|
onImported: (SessionSummary) -> Unit,
|
||||||
|
onSettings: () -> Unit,
|
||||||
|
onProvider: (String, String) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onGone: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Asked again each time the panel opens: who is working and who is waiting on an answer is
|
||||||
|
// exactly what changed while the session underneath was being read.
|
||||||
|
var reloadToken by remember(sessionId) { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(active) { if (active) reloadToken++ }
|
||||||
|
|
||||||
|
MainScreen(
|
||||||
|
settings = settings,
|
||||||
|
reloadToken = reloadToken,
|
||||||
|
onOpen = { if (it.id == sessionId) onClose() else onOpen(it) },
|
||||||
|
onSpawn = onSpawn,
|
||||||
|
onImported = onImported,
|
||||||
|
onSettings = onSettings,
|
||||||
|
onProvider = onProvider,
|
||||||
|
onDeleted = { if (it == sessionId) onGone() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,19 +26,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's root: one title, and four views of the backend behind it.
|
* The app's root: one title, and three views of the backend behind it.
|
||||||
*
|
*
|
||||||
* These were four screens reached by four words in a row under the title, and the row was already
|
* These were screens reached by words in a row under the title, and the row was already full. Tabs
|
||||||
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
|
* say the same thing in less space and say one more thing besides: that these are places to be
|
||||||
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
|
* rather than errands to run. Sessions, the machine's importable history and the machines
|
||||||
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
|
* themselves are all *the same backend*, looked at three ways, and none is a step down from
|
||||||
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
|
* another. Settings still is, which is why it stays a pushed screen with its own Back.
|
||||||
|
*
|
||||||
|
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
|
||||||
|
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
|
||||||
|
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
|
||||||
|
* one such set, and there is one per machine.
|
||||||
*/
|
*/
|
||||||
private enum class MainTab(val label: String) {
|
private enum class MainTab(val label: String) {
|
||||||
Sessions("Sessions"),
|
Sessions("Sessions"),
|
||||||
Import("Import"),
|
Import("Import"),
|
||||||
Models("Models"),
|
Machines("Machines"),
|
||||||
Setups("Setups"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -51,6 +55,10 @@ fun MainScreen(
|
|||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
onImported: (SessionSummary) -> Unit,
|
onImported: (SessionSummary) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
/** One machine's provider, opened from the machines tab. */
|
||||||
|
onProvider: (String, String) -> Unit,
|
||||||
|
/** A session the list has just deleted; see [SessionListScreen]. */
|
||||||
|
onDeleted: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var tab by remember { mutableStateOf(MainTab.Sessions) }
|
var tab by remember { mutableStateOf(MainTab.Sessions) }
|
||||||
var refreshToken by remember { mutableIntStateOf(0) }
|
var refreshToken by remember { mutableIntStateOf(0) }
|
||||||
@@ -140,11 +148,12 @@ fun MainScreen(
|
|||||||
reloadToken = token,
|
reloadToken = token,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
onSpawn = onSpawn,
|
onSpawn = onSpawn,
|
||||||
|
onDeleted = onDeleted,
|
||||||
)
|
)
|
||||||
MainTab.Import ->
|
MainTab.Import ->
|
||||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
||||||
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
MainTab.Machines ->
|
||||||
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator
|
|||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
import com.mikepenz.markdown.utils.resolveImageAlt
|
||||||
import com.mikepenz.markdown.utils.resolveImageLink
|
import com.mikepenz.markdown.utils.resolveImageLink
|
||||||
|
import java.net.URI
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
import org.intellij.markdown.ast.ASTNode
|
import org.intellij.markdown.ast.ASTNode
|
||||||
@@ -89,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
content.buildMarkdownAnnotatedString(node, style, settings)
|
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||||
}
|
}
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val fileLinkHandler = LocalFileLinkHandler.current
|
||||||
val onPlainTap = LocalMarkdownTap.current
|
val onPlainTap = LocalMarkdownTap.current
|
||||||
val layout = remember { Ref<TextLayoutResult>() }
|
val layout = remember { Ref<TextLayoutResult>() }
|
||||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||||
@@ -127,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
when {
|
when {
|
||||||
url != null -> {
|
url != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
uriHandler.openUri(url)
|
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
|
||||||
}
|
}
|
||||||
onPlainTap != null -> {
|
onPlainTap != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
*/
|
*/
|
||||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a markdown destination inside the current session when it names a file on that session's
|
||||||
|
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
|
||||||
|
*/
|
||||||
|
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
|
||||||
|
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
|
||||||
|
* streamed event.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
|
||||||
|
val latest = rememberUpdatedState(onFile)
|
||||||
|
return remember {
|
||||||
|
{ destination ->
|
||||||
|
val path = filePathOf(destination)
|
||||||
|
if (path == null) false
|
||||||
|
else {
|
||||||
|
latest.value(path)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path named by a local-file markdown destination.
|
||||||
|
*
|
||||||
|
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
|
||||||
|
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
|
||||||
|
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
|
||||||
|
* itself, so those coordinates are removed here.
|
||||||
|
*/
|
||||||
|
internal fun filePathOf(destination: String): String? {
|
||||||
|
val uri = runCatching { URI(destination) }.getOrNull()
|
||||||
|
val path =
|
||||||
|
when {
|
||||||
|
destination.startsWith("/") && !destination.startsWith("//") ->
|
||||||
|
uri?.path ?: destination.substringBefore('#').substringBefore('?')
|
||||||
|
uri != null &&
|
||||||
|
uri.scheme.equals("file", ignoreCase = true) &&
|
||||||
|
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
|
||||||
|
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
|
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
|
||||||
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
|
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
|
||||||
|
|||||||
@@ -21,12 +21,25 @@ const val DEFAULT_MODEL = "default"
|
|||||||
* one model rather than one model from another. Anything that does not look like that is returned
|
* one model rather than one model from another. Anything that does not look like that is returned
|
||||||
* untouched.
|
* untouched.
|
||||||
*
|
*
|
||||||
|
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
|
||||||
|
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
|
||||||
|
* models apart, and the extension goes with the directories. The model's *own* name is better still
|
||||||
|
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
|
||||||
|
* screen has the server's answer it should prefer it; this is the floor under every screen that
|
||||||
|
* does not.
|
||||||
|
*
|
||||||
* A display decision, not a correction: the full name is what the session reports.
|
* A display decision, not a correction: the full name is what the session reports.
|
||||||
*/
|
*/
|
||||||
fun modelLabel(model: String?): String {
|
fun modelLabel(model: String?): String {
|
||||||
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
||||||
|
if (name.endsWith(GGUF)) {
|
||||||
|
return name.substringAfterLast('/').removeSuffix(GGUF)
|
||||||
|
}
|
||||||
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
||||||
private val DATED_SUFFIX = Regex("""-\d{8}$""")
|
private val DATED_SUFFIX = Regex("""-\d{8}$""")
|
||||||
|
|
||||||
|
/** What every model a llama.cpp session can run is stored as. */
|
||||||
|
private const val GGUF = ".gguf"
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Models on the backend, and HuggingFace to get more from.
|
|
||||||
*
|
|
||||||
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
|
|
||||||
* downloading, are the same answers on every enrolled device, and a download started here keeps
|
|
||||||
* going when this screen closes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
|
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
|
|
||||||
var openRepo by remember { mutableStateOf<String?>(null) }
|
|
||||||
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
|
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
suspend fun reload() {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
|
|
||||||
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
|
|
||||||
// loop with a read now rather than leaving the reader watching for a second and a half.
|
|
||||||
LaunchedEffect(reloadToken) {
|
|
||||||
while (true) {
|
|
||||||
reload()
|
|
||||||
delay(1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
actionError?.let {
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = { query = it },
|
|
||||||
label = { Text("Search HuggingFace") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
TextButton(
|
|
||||||
enabled = query.isNotBlank(),
|
|
||||||
onClick = {
|
|
||||||
openRepo = null
|
|
||||||
results = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
results =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LoadState.Loaded(searchModels(settings, query))
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text("Search")
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
|
||||||
when (val current = state) {
|
|
||||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
|
||||||
is LoadState.Error ->
|
|
||||||
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
if (current.value.downloads.isNotEmpty()) {
|
|
||||||
item { SectionLabel("Downloading") }
|
|
||||||
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
|
|
||||||
->
|
|
||||||
DownloadCard(download) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
cancelDownload(settings, download.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { SectionLabel("On the backend") }
|
|
||||||
if (current.value.local.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"None yet. Search above to find one.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uniqueItems(current.value.local, key = { it.key }) { model ->
|
|
||||||
LocalModelCard(model) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteModel(settings, model.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results?.let { found ->
|
|
||||||
item { SectionLabel("HuggingFace") }
|
|
||||||
when (found) {
|
|
||||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
|
||||||
is LoadState.Error ->
|
|
||||||
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
uniqueItems(found.value, key = { it.id }) { repo ->
|
|
||||||
val open = openRepo == repo.id
|
|
||||||
RepoRow(repo, expanded = open) {
|
|
||||||
if (open) {
|
|
||||||
openRepo = null
|
|
||||||
} else {
|
|
||||||
openRepo = repo.id
|
|
||||||
repoFiles = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
repoFiles =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LoadState.Loaded(
|
|
||||||
fetchRepoFiles(settings, repo.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inside the expanded repository's own item rather than as a section
|
|
||||||
// after the list: drawn after every card, a repository's files read as
|
|
||||||
// belonging to whichever card happened to be last.
|
|
||||||
if (open) {
|
|
||||||
when (val files = repoFiles) {
|
|
||||||
null -> {}
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error ->
|
|
||||||
Text(files.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
Column {
|
|
||||||
val busy =
|
|
||||||
(state as? LoadState.Loaded)
|
|
||||||
?.value
|
|
||||||
?.downloads
|
|
||||||
.orEmpty()
|
|
||||||
.filter { it.state == "running" }
|
|
||||||
.map { it.key }
|
|
||||||
.toSet()
|
|
||||||
files.value.forEach { file ->
|
|
||||||
RepoFileRow(
|
|
||||||
file,
|
|
||||||
downloading = "${repo.id}/${file.path}" in busy,
|
|
||||||
) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
startDownload(
|
|
||||||
settings,
|
|
||||||
repo.id,
|
|
||||||
file.path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionLabel(text: String) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(text, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(download.file, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
download.repo,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// A determinate bar only when the size is known. The server sends no total when it was
|
|
||||||
// never told one, and a bar drawn from a guess is worse than one that admits it is
|
|
||||||
// counting.
|
|
||||||
if (download.total != null && download.total > 0) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { download.done.toFloat() / download.total.toFloat() },
|
|
||||||
// Blue at every value, unlike a quota bar: a download nearing its end is
|
|
||||||
// nearing success, and colouring it like a limit being approached would say the
|
|
||||||
// opposite.
|
|
||||||
color = progressColor,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
|
|
||||||
Text(
|
|
||||||
"${gigabytes(download.done)} so far, total size unknown",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
download.error?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Text(
|
|
||||||
download.state,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (download.state == "running") {
|
|
||||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(model.file, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
"${model.repo} · ${gigabytes(model.bytes)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onDelete) { Text("Delete") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
repo.id,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
// The owner is the part that repeats; the model name at the end is what tells
|
|
||||||
// two entries apart.
|
|
||||||
overflow = TextOverflow.StartEllipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${repo.downloads} downloads · ${repo.likes} likes",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(file.path, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
gigabytes(file.bytes),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Disabled rather than absent, so the row reads the same whether this one is absent,
|
|
||||||
// already here, or on its way. Offering "Download" for a file that is downloading would be
|
|
||||||
// a button that does nothing anyone can see.
|
|
||||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
file.have -> "Downloaded"
|
|
||||||
downloading -> "Downloading"
|
|
||||||
else -> "Download"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun gigabytes(bytes: Long): String =
|
|
||||||
if (bytes >= 1_000_000_000) {
|
|
||||||
"%.2f GB".format(bytes / 1_000_000_000.0)
|
|
||||||
} else {
|
|
||||||
"%.0f MB".format(bytes / 1_000_000.0)
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
||||||
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
||||||
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
||||||
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols
|
||||||
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
||||||
* the script did not subset is a glyph that silently isn't there.
|
* the script did not subset is a glyph that silently isn't there.
|
||||||
*
|
*
|
||||||
@@ -136,6 +136,34 @@ val EDIT_GLYPH = glyph(0xF03EB)
|
|||||||
*/
|
*/
|
||||||
val SAVE_GLYPH = glyph(0xF0193)
|
val SAVE_GLYPH = glyph(0xF0193)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
|
||||||
|
*
|
||||||
|
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
|
||||||
|
* row that has one: three rules look like the rows of a list, and the only thing here that draws
|
||||||
|
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
|
||||||
|
* senses cannot be confused.
|
||||||
|
*/
|
||||||
|
val DRAG_GLYPH = glyph(0xF035C)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `md-console_line` -- a shell prompt: a backgrounded command, in the panel beside the turn.
|
||||||
|
*
|
||||||
|
* The four marks here are one set, drawn by `backgroundTaskLook`: they exist because the kind of a
|
||||||
|
* background task used to be a word on a line of its own, which on a list of commands was the same
|
||||||
|
* two words down the whole panel. Each keeps its words as the description a screen reader is given.
|
||||||
|
*/
|
||||||
|
val COMMAND_GLYPH = glyph(0xF07B7)
|
||||||
|
|
||||||
|
/** `md-robot` -- a subagent: something running that is doing its own reasoning. */
|
||||||
|
val AGENT_GLYPH = glyph(0xF06A9)
|
||||||
|
|
||||||
|
/** `md-sitemap` -- a workflow: steps arranged by something other than the agent itself. */
|
||||||
|
val WORKFLOW_GLYPH = glyph(0xF04AA)
|
||||||
|
|
||||||
|
/** `md-help_circle_outline` -- a background task of a kind this build has not heard of. */
|
||||||
|
val UNKNOWN_GLYPH = glyph(0xF0625)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The size an icon draws at beside a line of text.
|
* The size an icon draws at beside a line of text.
|
||||||
*
|
*
|
||||||
@@ -165,7 +193,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
|||||||
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
||||||
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
||||||
*/
|
*/
|
||||||
private val GLYPH_BUTTON_SIZE = 48.dp
|
val GLYPH_BUTTON_SIZE = 48.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import org.json.JSONObject
|
|||||||
* gets a push from Google's servers, which would mean this backend talking to Google about
|
* gets a push from Google's servers, which would mean this backend talking to Google about
|
||||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
||||||
*
|
*
|
||||||
|
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
|
||||||
|
*
|
||||||
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
|
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
|
||||||
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
|
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
|
||||||
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
|
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
|
||||||
@@ -138,10 +140,11 @@ class NotificationService : Service() {
|
|||||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||||
// them is already saying it.
|
// them is already saying it.
|
||||||
if (isOnScreen(notification.sessionId)) return
|
if (isOnScreen(notification.sessionId)) return
|
||||||
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
|
// The app is up, so it says this itself as a banner over whatever screen they are on --
|
||||||
// both -- one thing happened, and a drawer filling up behind an app that already showed you
|
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
||||||
// each one is a drawer nobody reads.
|
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
||||||
if (handOver(notification)) return
|
// makes the row a silent one.
|
||||||
|
val banner = handOver(notification)
|
||||||
val manager = NotificationManagerCompat.from(this)
|
val manager = NotificationManagerCompat.from(this)
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
// Two different noes, and both are answers rather than faults: the runtime permission
|
||||||
// refused, and notifications switched off for the app in Android's own settings.
|
// refused, and notifications switched off for the app in Android's own settings.
|
||||||
@@ -172,6 +175,7 @@ class NotificationService : Service() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setWhen((notification.at * 1000).toLong())
|
.setWhen((notification.at * 1000).toLong())
|
||||||
.setShowWhen(true)
|
.setShowWhen(true)
|
||||||
|
.setSilent(banner)
|
||||||
.build()
|
.build()
|
||||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
manager.notify(notification.sessionId, ALERT_ID, built)
|
||||||
}
|
}
|
||||||
@@ -262,7 +266,8 @@ class NotificationService : Service() {
|
|||||||
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
||||||
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
|
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
|
||||||
* nor blocks the thread reading the stream, and the buffer is there so a handful of
|
* nor blocks the thread reading the stream, and the buffer is there so a handful of
|
||||||
* sessions finishing together all land rather than the last one winning.
|
* sessions finishing together all land rather than the last one winning. Reaching the app
|
||||||
|
* does not stop the drawer's row; it makes it a silent one.
|
||||||
*/
|
*/
|
||||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||||
|
|
||||||
@@ -272,7 +277,11 @@ class NotificationService : Service() {
|
|||||||
private fun handOver(notification: SessionNotification) =
|
private fun handOver(notification: SessionNotification) =
|
||||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
||||||
|
|
||||||
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
/**
|
||||||
|
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
|
||||||
|
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
|
||||||
|
* away. Opening the session *is* reading the notification, whichever way they got here.
|
||||||
|
*/
|
||||||
fun showing(context: Context, sessionId: String) {
|
fun showing(context: Context, sessionId: String) {
|
||||||
onScreen = sessionId
|
onScreen = sessionId
|
||||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
// Whatever was posted about it before is about to be read, so it has nothing left to
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
private const val PENDING_MESSAGES = "pending-messages"
|
||||||
|
|
||||||
|
/** A quiet user bubble below the durable transcript. */
|
||||||
|
internal data class QueuedMessage(
|
||||||
|
val id: String,
|
||||||
|
val text: String,
|
||||||
|
val attachments: List<String>,
|
||||||
|
val refusal: String? = null,
|
||||||
|
/** This phone is still waiting for any durable event that says the server accepted it. */
|
||||||
|
val local: Boolean = false,
|
||||||
|
/** The HTTP request returned successfully; the provider event is still outstanding. */
|
||||||
|
val serverAccepted: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun localPendingMessage(text: String, attachments: List<String>) =
|
||||||
|
QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true)
|
||||||
|
|
||||||
|
private fun QueuedMessage.matches(text: String, attachments: List<String>) =
|
||||||
|
this.text == text && this.attachments == attachments
|
||||||
|
|
||||||
|
/** Replaces the local bridge with the server's durable waiting message, without drawing both. */
|
||||||
|
internal fun reconcileQueuedMessage(
|
||||||
|
queued: List<QueuedMessage>,
|
||||||
|
event: SessionEvent.MessageQueued,
|
||||||
|
): List<QueuedMessage> {
|
||||||
|
if (queued.any { !it.local && it.id == event.id }) return queued
|
||||||
|
val at = queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
|
||||||
|
if (at < 0) return queued + QueuedMessage(event.id, event.text, event.attachments)
|
||||||
|
return queued.mapIndexed { index, message ->
|
||||||
|
if (index == at) QueuedMessage(event.id, event.text, event.attachments) else message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes exactly the pending bubble that became a provider-received user message. */
|
||||||
|
internal fun reconcileUserMessage(
|
||||||
|
queued: List<QueuedMessage>,
|
||||||
|
event: SessionEvent.UserMessage,
|
||||||
|
): List<QueuedMessage> {
|
||||||
|
val at =
|
||||||
|
event.id?.let { id -> queued.indexOfFirst { !it.local && it.id == id }.takeIf { it >= 0 } }
|
||||||
|
?: queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
|
||||||
|
return if (at < 0) queued else queued.filterIndexed { index, _ -> index != at }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a failed send in place and puts its actionable failure in that message's bubble. */
|
||||||
|
internal fun markPendingFailure(
|
||||||
|
queued: List<QueuedMessage>,
|
||||||
|
id: String,
|
||||||
|
failure: String,
|
||||||
|
): List<QueuedMessage> = queued.map { message ->
|
||||||
|
if (message.local && message.id == id) message.copy(refusal = failure) else message
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops persisting a send once the server owns it, while its bubble awaits the provider event. */
|
||||||
|
internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<QueuedMessage> =
|
||||||
|
queued.map { message ->
|
||||||
|
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun discardPendingMessage(
|
||||||
|
queued: List<QueuedMessage>,
|
||||||
|
id: String,
|
||||||
|
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
|
||||||
|
|
||||||
|
/** Restores sends for which this phone has not yet seen a durable server event. */
|
||||||
|
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
|
||||||
|
val encoded =
|
||||||
|
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).getString(key, null)
|
||||||
|
?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val messages = JSONArray(encoded)
|
||||||
|
List(messages.length()) { index ->
|
||||||
|
val message = messages.getJSONObject(index)
|
||||||
|
val attachments = message.optJSONArray("attachments") ?: JSONArray()
|
||||||
|
QueuedMessage(
|
||||||
|
id = message.getString("id"),
|
||||||
|
text = message.getString("text"),
|
||||||
|
attachments = List(attachments.length()) { attachments.getString(it) },
|
||||||
|
refusal = message.optString("refusal").takeIf { it.isNotEmpty() },
|
||||||
|
local = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: org.json.JSONException) {
|
||||||
|
// A corrupt local outbox is not useful on the next open either. Remove it rather than
|
||||||
|
// repeatedly pretending it decoded to an intentionally empty one.
|
||||||
|
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { remove(key) }
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores only sends the server has not confirmed; everything accepted is the server's to keep. */
|
||||||
|
internal fun savePendingMessages(context: Context, key: String, queued: List<QueuedMessage>) {
|
||||||
|
val local = queued.filter { it.local && !it.serverAccepted }
|
||||||
|
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit {
|
||||||
|
if (local.isEmpty()) {
|
||||||
|
remove(key)
|
||||||
|
} else {
|
||||||
|
putString(
|
||||||
|
key,
|
||||||
|
JSONArray(
|
||||||
|
local.map { message ->
|
||||||
|
JSONObject()
|
||||||
|
.put("id", message.id)
|
||||||
|
.put("text", message.text)
|
||||||
|
.put("attachments", JSONArray(message.attachments))
|
||||||
|
.put("refusal", message.refusal ?: "")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relays a provider CLI's headless browser login without ever owning its credentials.
|
||||||
|
*
|
||||||
|
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
|
||||||
|
* OAuth client and the only writer of its credential file.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProviderLoginDialog(
|
||||||
|
settings: ServerSettings,
|
||||||
|
machineId: String,
|
||||||
|
machineName: String,
|
||||||
|
provider: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSignedIn: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
|
||||||
|
var code by remember(machineId, provider) { mutableStateOf("") }
|
||||||
|
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
|
||||||
|
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
suspend fun follow(initial: ProviderLogin): ProviderLogin {
|
||||||
|
var current = initial
|
||||||
|
val wasSubmitting = initial.state == "submitting"
|
||||||
|
while (current.state == "starting" || current.state == "submitting") {
|
||||||
|
delay(400)
|
||||||
|
current =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
fetchProviderLogin(
|
||||||
|
settings,
|
||||||
|
machineId,
|
||||||
|
provider,
|
||||||
|
current.attempt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
login = current
|
||||||
|
}
|
||||||
|
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
|
||||||
|
current =
|
||||||
|
current.copy(
|
||||||
|
detail = "That code was not accepted. Copy the complete code and try again."
|
||||||
|
)
|
||||||
|
login = current
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(machineId, provider, retry) {
|
||||||
|
error = null
|
||||||
|
code = ""
|
||||||
|
login = null
|
||||||
|
try {
|
||||||
|
val started =
|
||||||
|
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
|
||||||
|
login = started
|
||||||
|
if (follow(started).state == "succeeded") {
|
||||||
|
onSignedIn()
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
login
|
||||||
|
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
|
||||||
|
?.let {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = ::dismiss,
|
||||||
|
title = { Text("Sign in to Claude") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Claude will sign in on $machineName. Open the authorization page, then " +
|
||||||
|
"paste the code it gives you here."
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
when (val current = login) {
|
||||||
|
null ->
|
||||||
|
if (error == null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text("Starting sign-in…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
when (current.state) {
|
||||||
|
"starting",
|
||||||
|
"submitting" ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
if (current.state == "submitting") "Checking code…"
|
||||||
|
else "Starting sign-in…"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"waitingForCode" -> {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
current.authorizationUrl?.let(uriHandler::openUri)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
error = "Couldn't open the authorization page."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = current.authorizationUrl != null,
|
||||||
|
) {
|
||||||
|
Text("Open authorization page")
|
||||||
|
}
|
||||||
|
LabelledField(
|
||||||
|
label = "Authorization code",
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it },
|
||||||
|
)
|
||||||
|
current.detail?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"succeeded" -> Text("Signed in on $machineName.")
|
||||||
|
"cancelled" -> Text("Sign-in was cancelled.")
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
current.detail ?: "Sign-in failed.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val current = login
|
||||||
|
when {
|
||||||
|
current?.state == "waitingForCode" ->
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val submitted =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
submitProviderLoginCode(
|
||||||
|
settings,
|
||||||
|
machineId,
|
||||||
|
provider,
|
||||||
|
current.attempt,
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
login = submitted
|
||||||
|
if (follow(submitted).state == "succeeded") {
|
||||||
|
onSignedIn()
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = code.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Continue")
|
||||||
|
}
|
||||||
|
error != null || current?.state == "failed" || current?.state == "cancelled" ->
|
||||||
|
TextButton(onClick = { retry++ }) { Text("Try again") }
|
||||||
|
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (login?.state != "succeeded") {
|
||||||
|
TextButton(onClick = ::dismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controls for whatever settings a provider says it takes.
|
||||||
|
*
|
||||||
|
* One composable for both screens that offer them — the spawn form and the session settings dialog
|
||||||
|
* — and for every provider, because the server declares the list (see `DriverKind::params`) rather
|
||||||
|
* than this file knowing it. A driver that grows a setting gets a control here with no change to
|
||||||
|
* the app, which is the whole point: the values that suit one machine ship as defaults, and every
|
||||||
|
* one of them stays reachable from a phone.
|
||||||
|
*
|
||||||
|
* [values] is the whole map and [onChange] hands back the whole map. A key absent from it means the
|
||||||
|
* setting is unset, which is what every [ParamSpec.unset] describes — so clearing a field and never
|
||||||
|
* touching it are deliberately the same state.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProviderParamFields(
|
||||||
|
specs: List<ParamSpec>,
|
||||||
|
values: Map<String, String>,
|
||||||
|
onChange: (Map<String, String>) -> Unit,
|
||||||
|
/**
|
||||||
|
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
|
||||||
|
* running yet and every setting is about to be read — saying it there would be a warning about
|
||||||
|
* a state the reader cannot be in.
|
||||||
|
*/
|
||||||
|
warnAboutRestart: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (specs.isEmpty()) return
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
specs.forEach { spec ->
|
||||||
|
val set = { value: String ->
|
||||||
|
onChange(
|
||||||
|
// Blank clears rather than storing an empty string: the server reads an absent
|
||||||
|
// key as "use the default", and an empty one would be a value it then failed
|
||||||
|
// to parse.
|
||||||
|
if (value.isBlank()) values - spec.key else values + (spec.key to value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (spec.kind) {
|
||||||
|
"choice" -> {
|
||||||
|
// The first option is what unset means, so selecting it clears the key — see
|
||||||
|
// `ParamKind::Choice`. Without that the picker could show a default it could
|
||||||
|
// not return to.
|
||||||
|
val default = spec.options.firstOrNull().orEmpty()
|
||||||
|
ChipGroup(
|
||||||
|
label = spec.label + restartSuffix(spec, warnAboutRestart),
|
||||||
|
options = spec.options,
|
||||||
|
selected = values[spec.key] ?: default,
|
||||||
|
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
LabelledField(
|
||||||
|
label = spec.label + restartSuffix(spec, warnAboutRestart),
|
||||||
|
value = values[spec.key].orEmpty(),
|
||||||
|
onValueChange = set,
|
||||||
|
hint = spec.unset,
|
||||||
|
// Prose is written rather than filled in, so it gets the room to be read
|
||||||
|
// back -- see `ParamKind::Prose`.
|
||||||
|
lines = if (spec.kind == "prose") 4 else 1,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
if (warnAboutRestart && specs.any { it.restart }) {
|
||||||
|
Text(
|
||||||
|
"A setting marked “on restart” is saved now and read when this session's process " +
|
||||||
|
"next starts.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a control whose value will not take effect yet.
|
||||||
|
*
|
||||||
|
* On the label rather than beside it, because the reader decides whether to change the thing before
|
||||||
|
* they touch it — a note underneath is read after the decision.
|
||||||
|
*/
|
||||||
|
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
|
||||||
|
if (warn && spec.restart) " (on restart)" else ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
|
||||||
|
* entry is made harder by, and these are nearly all numbers.
|
||||||
|
*/
|
||||||
|
private fun keyboardFor(kind: String): KeyboardType =
|
||||||
|
when (kind) {
|
||||||
|
"integer" -> KeyboardType.Number
|
||||||
|
"decimal" -> KeyboardType.Decimal
|
||||||
|
else -> KeyboardType.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long typing has to stop before edited settings are sent.
|
||||||
|
*
|
||||||
|
* Long enough that a number is one request rather than one per digit, short enough that closing the
|
||||||
|
* dialog straight after typing still saves — the save runs on the screen behind it, which outlives
|
||||||
|
* the dialog, so this delay is not a window the value can be lost in.
|
||||||
|
*/
|
||||||
|
const val PARAM_SAVE_DELAY_MS = 700L
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One provider on one machine: what it is, what its shared server is holding, and how each of its
|
||||||
|
* models is loaded.
|
||||||
|
*
|
||||||
|
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
|
||||||
|
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
|
||||||
|
* a model is loaded stopped being anything a single session could decide, because one copy of it in
|
||||||
|
* memory is what several sessions are talking to.
|
||||||
|
*
|
||||||
|
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
|
||||||
|
* closing a session leaves the model loaded on purpose, since the next one to want it would
|
||||||
|
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
|
||||||
|
* visible.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProviderScreen(
|
||||||
|
settings: ServerSettings,
|
||||||
|
machineId: String,
|
||||||
|
provider: String,
|
||||||
|
/**
|
||||||
|
* The way back, or null where this is drawn inside something that has one of its own -- the
|
||||||
|
* session settings screen's second tab. Two ways out stacked above each other is a reader
|
||||||
|
* asking which of them goes where.
|
||||||
|
*/
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
|
||||||
|
var reload by remember { mutableIntStateOf(0) }
|
||||||
|
var editing by remember { mutableStateOf<ProviderModel?>(null) }
|
||||||
|
var confirmingStop by remember { mutableStateOf(false) }
|
||||||
|
// What is being done to the server or to one of its models, in a word, and what went wrong
|
||||||
|
// when it did. Both here rather than per row: these act on the whole machine.
|
||||||
|
var busy by remember { mutableStateOf<String?>(null) }
|
||||||
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
|
||||||
|
|
||||||
|
// The machine's own models and what is being fetched onto it. Only for a provider that serves
|
||||||
|
// files off that machine's disk -- everything else names its models rather than holding them,
|
||||||
|
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
|
||||||
|
val kind = (state as? LoadState.Loaded)?.value?.kind
|
||||||
|
val machineModels =
|
||||||
|
rememberMachineModels(
|
||||||
|
settings = settings,
|
||||||
|
machineId = machineId,
|
||||||
|
enabled = kind == "llama_cpp",
|
||||||
|
// A download that became a model is a model this screen has no settings for yet, so
|
||||||
|
// the view it is drawing is now one model short of the truth.
|
||||||
|
onLocalChange = { reload++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(reload) {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
LoadState.Loaded(fetchProvider(settings, machineId, provider))
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
|
||||||
|
// changes what it is showing.
|
||||||
|
val act = { what: String, action: suspend () -> Unit ->
|
||||||
|
scope.launch {
|
||||||
|
busy = what
|
||||||
|
actionError =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
||||||
|
busy = null
|
||||||
|
reload++
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// The models search at the bottom takes the keyboard, and everything below the field it is
|
||||||
|
// typed in -- the Search button, the results -- is behind it without this.
|
||||||
|
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
|
||||||
|
onBack?.let {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(onClick = it) { Text("Back") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val current = state) {
|
||||||
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
|
is LoadState.Loaded -> {
|
||||||
|
val view = current.value
|
||||||
|
Text(view.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"on ${view.machine}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
view.command?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
actionError?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
busy?.let {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
view.server?.let { server ->
|
||||||
|
item("server") {
|
||||||
|
ServerCard(
|
||||||
|
server = server,
|
||||||
|
maxLoaded = view.maxLoaded,
|
||||||
|
enabled = busy == null,
|
||||||
|
onStop = { confirmingStop = true },
|
||||||
|
onMaxLoaded = { chosen ->
|
||||||
|
act("Saving…") {
|
||||||
|
setProviderSettings(
|
||||||
|
settings,
|
||||||
|
machineId,
|
||||||
|
provider,
|
||||||
|
chosen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
||||||
|
item("models-heading") {
|
||||||
|
Text("Models", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
"How a model is loaded belongs to the machine, not to a session: " +
|
||||||
|
"one copy of it in memory answers every session using it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machineModels.actionError?.let { failure ->
|
||||||
|
item("models-error") {
|
||||||
|
Text(failure, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Above the models: this is what is about to be one of them.
|
||||||
|
downloadCards(machineModels)
|
||||||
|
val sizes = machineModels.sizes
|
||||||
|
uniqueItems(view.models, key = { it.id }) { model ->
|
||||||
|
ModelCard(
|
||||||
|
model = model,
|
||||||
|
specs = view.modelParams,
|
||||||
|
bytes = sizes[model.id],
|
||||||
|
onDelete =
|
||||||
|
if (model.id in sizes) ({ confirmingDelete = model }) else null,
|
||||||
|
// Tapping opens the settings; a provider whose models take none has
|
||||||
|
// nothing to open, so the row is not a control.
|
||||||
|
onEdit =
|
||||||
|
if (view.modelParams.isEmpty()) null else ({ editing = model }),
|
||||||
|
onUnload =
|
||||||
|
if (model.status == "loaded" || model.status == "sleeping") {
|
||||||
|
{
|
||||||
|
act("Unloading ${model.label}…") {
|
||||||
|
unloadProviderModel(
|
||||||
|
settings,
|
||||||
|
machineId,
|
||||||
|
provider,
|
||||||
|
model.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
enabled = busy == null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind == "llama_cpp") modelSearch(machineModels)
|
||||||
|
if (view.mcpServers.isNotEmpty()) {
|
||||||
|
item("mcp") {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
view.mcpServers.joinToString(", ") +
|
||||||
|
" — configured on the backend, in its config file.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editing?.let { model ->
|
||||||
|
val view = (state as? LoadState.Loaded)?.value
|
||||||
|
ModelSettingsDialog(
|
||||||
|
model = model,
|
||||||
|
specs = view?.modelParams.orEmpty(),
|
||||||
|
onDismiss = { editing = null },
|
||||||
|
onSave = { params ->
|
||||||
|
editing = null
|
||||||
|
act("Saving ${model.label}…") {
|
||||||
|
setModelSettings(settings, machineId, provider, model.id, params)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmingDelete?.let { model ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmingDelete = null },
|
||||||
|
title = { Text("Delete ${model.label}?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
|
||||||
|
"Nothing here can get it back -- downloading it again is the whole file again. " +
|
||||||
|
"Sessions using it keep their conversations and cannot start it."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
confirmingDelete = null
|
||||||
|
machineModels.remove(model.id)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmingStop) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmingStop = false },
|
||||||
|
title = { Text("Stop this server?") },
|
||||||
|
text = {
|
||||||
|
// Said plainly rather than hidden: this is the only thing that frees the memory,
|
||||||
|
// and what it costs is that every session on this machine reloads its model.
|
||||||
|
Text(
|
||||||
|
"Every model it is holding is unloaded. Sessions using it will show as " +
|
||||||
|
"exited, and the next message to one loads its model again — which is " +
|
||||||
|
"the slow part, not the sending."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
confirmingStop = false
|
||||||
|
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Stop")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerCard(
|
||||||
|
server: ServerState,
|
||||||
|
maxLoaded: Int?,
|
||||||
|
enabled: Boolean,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
onMaxLoaded: (Int?) -> Unit,
|
||||||
|
) {
|
||||||
|
// The saved value is what this starts at and what Save is compared against, so a field left
|
||||||
|
// half-typed is visibly not saved rather than quietly either way.
|
||||||
|
val saved = maxLoaded?.toString().orEmpty()
|
||||||
|
var typed by remember(saved) { mutableStateOf(saved) }
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
if (server.running) {
|
||||||
|
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
|
||||||
|
} else {
|
||||||
|
// Not a fault: nothing is loaded because nothing has asked. Saying it in
|
||||||
|
// words rather than colouring the row, since "stopped" and "we could not
|
||||||
|
// ask" would otherwise look the same.
|
||||||
|
"Not running. A session starts it when it needs a model."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LabelledField(
|
||||||
|
label = "Models loaded at once",
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it.filter(Char::isDigit) },
|
||||||
|
hint = "one -- a second model replaces the first",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Shown whether or not it is running, and disabled when there is nothing to stop:
|
||||||
|
// a button that comes and goes makes its own absence the message.
|
||||||
|
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
enabled = enabled && typed != saved,
|
||||||
|
onClick = { onMaxLoaded(typed.toIntOrNull()) },
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typed != saved) {
|
||||||
|
Text(
|
||||||
|
"Read when this server next starts.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModelCard(
|
||||||
|
model: ProviderModel,
|
||||||
|
specs: List<ParamSpec>,
|
||||||
|
/** How big the file is on the machine, for a provider whose models are files. */
|
||||||
|
bytes: Long?,
|
||||||
|
onEdit: (() -> Unit)?,
|
||||||
|
onUnload: (() -> Unit)?,
|
||||||
|
onDelete: (() -> Unit)?,
|
||||||
|
enabled: Boolean,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
model.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
bytes?.let {
|
||||||
|
Text(
|
||||||
|
gigabytes(it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the server is doing with it, in its own word. Absent means nobody could ask --
|
||||||
|
// the server is not running -- and the line is left out rather than guessed at.
|
||||||
|
model.status?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (model.settings.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
// In the words the dialog uses, and in the order it draws them: a summary
|
||||||
|
// naming `contextSize` is a summary of a different screen than the one it
|
||||||
|
// sits under.
|
||||||
|
specs
|
||||||
|
.mapNotNull { spec ->
|
||||||
|
model.settings[spec.key]?.let { "${spec.label} $it" }
|
||||||
|
}
|
||||||
|
.joinToString(", "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onUnload != null || onDelete != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Both shown whenever this kind of model has them, disabled rather than
|
||||||
|
// absent: unloading frees memory and deleting frees disk, and a button that
|
||||||
|
// comes and goes makes its own absence the message.
|
||||||
|
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one model is loaded.
|
||||||
|
*
|
||||||
|
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
|
||||||
|
* unloads the model for everybody using it, which is not something to do once per keystroke.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ModelSettingsDialog(
|
||||||
|
model: ProviderModel,
|
||||||
|
specs: List<ParamSpec>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Map<String, String>) -> Unit,
|
||||||
|
) {
|
||||||
|
var params by remember(model.id) { mutableStateOf(model.settings) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
// Every control here is a number, so the keyboard is up for most of this dialog's life --
|
||||||
|
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
|
||||||
|
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
|
||||||
|
// lets `imePadding` shrink it instead.
|
||||||
|
properties = DialogProperties(decorFitsSystemWindows = false),
|
||||||
|
modifier = Modifier.imePadding(),
|
||||||
|
title = { Text(model.label) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
if (model.status == "loaded" || model.status == "sleeping") {
|
||||||
|
"This model is loaded. Saving takes it out of memory, and the sessions " +
|
||||||
|
"using it load it again with these settings on their next message."
|
||||||
|
} else {
|
||||||
|
"Read when this model is next loaded."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ProviderParamFields(
|
||||||
|
specs = specs,
|
||||||
|
values = params,
|
||||||
|
onChange = { params = it },
|
||||||
|
// Every one of these is read at load time, and the sentence above already
|
||||||
|
// says when that is -- marking each control "on restart" would repeat it six
|
||||||
|
// times.
|
||||||
|
warnAboutRestart = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -18,6 +20,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
|
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
|
||||||
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
|
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
|
||||||
*
|
*
|
||||||
|
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
|
||||||
|
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
|
||||||
|
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
|
||||||
|
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
|
||||||
|
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
|
||||||
|
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
|
||||||
|
* false`, which is the half of this a caller has to remember.
|
||||||
|
*
|
||||||
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,6 +39,10 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
|
|||||||
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
||||||
.clip(MaterialTheme.shapes.extraSmall)
|
.clip(MaterialTheme.shapes.extraSmall)
|
||||||
.background(rawSurface)
|
.background(rawSurface)
|
||||||
|
// Clipped and filled before this, so the tint is the viewport and does not scroll away
|
||||||
|
// from under the text; padded after it, so the inset travels with the content and the
|
||||||
|
// last column does not end flush against the edge.
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging a row of a [androidx.compose.foundation.lazy.LazyColumn] into a different place in it.
|
||||||
|
*
|
||||||
|
* Generic rather than the session list's own, because "hold this and move it" is one gesture
|
||||||
|
* wherever it appears and the arithmetic below is the whole of it. The list itself is left alone:
|
||||||
|
* this reports a move and the caller decides what a move means -- it is the caller that holds the
|
||||||
|
* rows and the caller that tells a server about the new order.
|
||||||
|
*
|
||||||
|
* The drag is on a [ReorderHandle] rather than on the row, which is what keeps it out of the way of
|
||||||
|
* the scroll. A whole row that can be dragged sideways-ish is a row that sometimes eats a fling,
|
||||||
|
* and a list is scrolled far more often than it is rearranged.
|
||||||
|
*/
|
||||||
|
class Reorder
|
||||||
|
internal constructor(
|
||||||
|
private val listState: LazyListState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val haptics: HapticFeedback,
|
||||||
|
/** What the [EDGE] band is in pixels here; a band in raw pixels is one screen's answer. */
|
||||||
|
private val density: Density,
|
||||||
|
/**
|
||||||
|
* The caller's own lists are what move; these are [State] so that the gesture, which outlives a
|
||||||
|
* recomposition, is never holding the first composition's copy of them.
|
||||||
|
*/
|
||||||
|
private val onMove: State<(from: Int, to: Int) -> Unit>,
|
||||||
|
private val onSettled: State<() -> Unit>,
|
||||||
|
) {
|
||||||
|
/** The key of the row in hand, or null when nothing is being dragged. */
|
||||||
|
var held by mutableStateOf<Any?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Where the list had laid the row out when it was taken hold of, in viewport pixels. */
|
||||||
|
private var grabbedAt = 0
|
||||||
|
|
||||||
|
/** How far the finger has moved since, which is what the row is drawn following. */
|
||||||
|
private var dragged by mutableFloatStateOf(0f)
|
||||||
|
|
||||||
|
/** How far the list has scrolled under it since -- see [follow]. */
|
||||||
|
private var scrolled = 0f
|
||||||
|
|
||||||
|
/** The index the row has been moved to so far, which is what the next move counts from. */
|
||||||
|
private var at = 0
|
||||||
|
|
||||||
|
/** Where it started, so that a handle merely pressed is not reported as a rearrangement. */
|
||||||
|
private var from = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the travel below the moves so far have accounted for.
|
||||||
|
*
|
||||||
|
* The travel is what decides a crossing, rather than where the row is drawn *now*: a lazy list
|
||||||
|
* animates an item into its new place, so for a few frames after a move `offset` still reports
|
||||||
|
* roughly the old one. Deciding from that offset re-decided the same crossing on every frame
|
||||||
|
* until the animation caught up, and a drag of two rows arrived six rows down.
|
||||||
|
*/
|
||||||
|
private var settled = 0f
|
||||||
|
|
||||||
|
private fun info(key: Any): LazyListItemInfo? =
|
||||||
|
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
private fun itemAt(index: Int): LazyListItemInfo? =
|
||||||
|
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far from where the list laid it out this row should be drawn -- zero for every row but
|
||||||
|
* the one in hand.
|
||||||
|
*
|
||||||
|
* Measured against where the row is laid out *now* rather than accumulated, which is what makes
|
||||||
|
* it self-correcting: a move, or a scroll under the finger, puts the row somewhere new, and the
|
||||||
|
* same subtraction cancels that out so the row stays under the finger instead of jumping by its
|
||||||
|
* own height.
|
||||||
|
*/
|
||||||
|
fun offsetOf(key: Any): Float {
|
||||||
|
if (key != held) return 0f
|
||||||
|
val now = info(key) ?: return 0f
|
||||||
|
return grabbedAt + dragged - now.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun grab(key: Any) {
|
||||||
|
val from = info(key) ?: return
|
||||||
|
held = key
|
||||||
|
grabbedAt = from.offset
|
||||||
|
at = from.index
|
||||||
|
this.from = from.index
|
||||||
|
dragged = 0f
|
||||||
|
scrolled = 0f
|
||||||
|
settled = 0f
|
||||||
|
// The platform's "you have picked this up", the same feedback a long press gives, because
|
||||||
|
// the gesture it confirms is the same kind of commitment.
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun drag(by: Float) {
|
||||||
|
if (held == null) return
|
||||||
|
dragged += by
|
||||||
|
cross()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trades places with as many neighbours as the travel so far has earned.
|
||||||
|
*
|
||||||
|
* Half a neighbour's height each way, so the row changes place when it covers most of the one
|
||||||
|
* it is passing -- and a full height of hysteresis before it can come back, since the move has
|
||||||
|
* already paid that half in the other direction. A loop rather than one step: a fast drag, or a
|
||||||
|
* list scrolling under a parked finger, crosses several rows between two events.
|
||||||
|
*/
|
||||||
|
private fun cross() {
|
||||||
|
while (true) {
|
||||||
|
val slack = dragged + scrolled - settled
|
||||||
|
val next = itemAt(if (slack > 0) at + 1 else at - 1) ?: return
|
||||||
|
if (abs(slack) < next.size / 2f) return
|
||||||
|
// Where the list is looking, taken before the move and put back after it. A lazy list
|
||||||
|
// keeps its place by the *key* of the item at the top, so moving that item takes the
|
||||||
|
// viewport with it -- drag the top row down two places and the list scrolls two rows
|
||||||
|
// to follow it, which reads as the row never having moved. The correction is by index,
|
||||||
|
// which is the thing that did not change.
|
||||||
|
val anchor = listState.firstVisibleItemIndex
|
||||||
|
val within = listState.firstVisibleItemScrollOffset
|
||||||
|
onMove.value(at, next.index)
|
||||||
|
// Requested rather than scrolled to: this has to take effect in the *same* measurement
|
||||||
|
// as the move, and a scroll launched beside it lands before the list has taken the new
|
||||||
|
// order and is then undone by it.
|
||||||
|
listState.requestScrollToItem(anchor, within)
|
||||||
|
settled += if (slack > 0) next.size.toFloat() else -next.size.toFloat()
|
||||||
|
at = next.index
|
||||||
|
// Loud on purpose: the row is under a finger that is covering it, so the tick is how
|
||||||
|
// the reader knows a place was taken rather than that they are still between two.
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun release() {
|
||||||
|
// Only where the row actually went somewhere: a handle pressed and let go has rearranged
|
||||||
|
// nothing, and reporting one would have the server rewrite the order it already has.
|
||||||
|
val moved = held != null && at != from
|
||||||
|
held = null
|
||||||
|
dragged = 0f
|
||||||
|
scrolled = 0f
|
||||||
|
settled = 0f
|
||||||
|
if (moved) onSettled.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the list while the row in hand is held against one end of it, so a row can be moved
|
||||||
|
* further than one screenful. A frame loop rather than a response to the drag, because a finger
|
||||||
|
* parked at the bottom edge sends no more events and is exactly the case this exists for.
|
||||||
|
*/
|
||||||
|
internal fun follow() {
|
||||||
|
val key = held ?: return
|
||||||
|
scope.launch {
|
||||||
|
while (held == key) {
|
||||||
|
withFrameNanos {}
|
||||||
|
val moving = info(key) ?: continue
|
||||||
|
val viewport = listState.layoutInfo.viewportEndOffset
|
||||||
|
val edge = with(density) { EDGE.toPx() }
|
||||||
|
val top = grabbedAt + dragged
|
||||||
|
val bottom = top + moving.size
|
||||||
|
val step =
|
||||||
|
when {
|
||||||
|
top < edge -> -(edge - top).coerceAtMost(edge)
|
||||||
|
bottom > viewport - edge -> (bottom - (viewport - edge)).coerceAtMost(edge)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (step == 0f) continue
|
||||||
|
// Counted as travel of its own: the finger has not moved, but the rows have moved
|
||||||
|
// under it, which is the same thing to everything above. Nothing is added to the
|
||||||
|
// drag, because where the row is *drawn* is measured against the list's own
|
||||||
|
// offsets and those have already moved.
|
||||||
|
scrolled += listState.scrollBy(step * SPEED)
|
||||||
|
cross()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** How close to an end of the list a held row has to be before the list follows it. */
|
||||||
|
val EDGE = 36.dp
|
||||||
|
|
||||||
|
/** A fraction of the overshoot per frame, so the scroll eases in rather than lurching. */
|
||||||
|
const val SPEED = 0.12f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberReorder(
|
||||||
|
listState: LazyListState,
|
||||||
|
/** Two indices into the lazy list, which is the caller's own order to rearrange. */
|
||||||
|
onMove: (from: Int, to: Int) -> Unit,
|
||||||
|
/** The drag is over: the order on screen is the one to keep. */
|
||||||
|
onSettled: () -> Unit,
|
||||||
|
): Reorder {
|
||||||
|
val move = rememberUpdatedState(onMove)
|
||||||
|
val settled = rememberUpdatedState(onSettled)
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return remember(listState) { Reorder(listState, scope, haptics, density, move, settled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handle a row is dragged by: the burger, at about the size of a heading.
|
||||||
|
*
|
||||||
|
* Bigger than an icon beside a line of text -- this is what a row is taken hold of by, and at
|
||||||
|
* [GLYPH_SIZE] it read as decoration on the end of the row. Not as big as the row either: a mark
|
||||||
|
* scaled to the card's whole inner height came out heavier than anything else on screen, since
|
||||||
|
* these rules thicken with the glyph.
|
||||||
|
*
|
||||||
|
* The touch square around it is [GLYPH_BUTTON_SIZE], the same as every other icon control here, so
|
||||||
|
* the mark and the area that answers to a finger are two different sizes -- which is why the caller
|
||||||
|
* subtracts [HANDLE_MARGIN] from the gap it wants: what has to line up with the text on the other
|
||||||
|
* side is the mark, not the box around it.
|
||||||
|
*
|
||||||
|
* [key] is the row's own key in the list, which is how a gesture that started here finds the row it
|
||||||
|
* belongs to -- an index would be stale the moment the first move landed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReorderHandle(state: Reorder, key: Any, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.size(GLYPH_BUTTON_SIZE)
|
||||||
|
// Nothing here draws a word, and a handle is the kind of control somebody using a
|
||||||
|
// screen reader has no other way to find.
|
||||||
|
.semantics { contentDescription = "Drag to reorder" }
|
||||||
|
.pointerInput(key) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
state.grab(key)
|
||||||
|
state.follow()
|
||||||
|
},
|
||||||
|
onDrag = { _, amount -> state.drag(amount.y) },
|
||||||
|
onDragEnd = { state.release() },
|
||||||
|
onDragCancel = { state.release() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Glyph(DRAG_GLYPH, colour = MaterialTheme.colorScheme.onSurfaceVariant, size = HANDLE_MARK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How big the mark itself is: a heading's size, which is what the font is asked for in `sp`. */
|
||||||
|
private val HANDLE_MARK = 24.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the touch square lies outside the mark on each side.
|
||||||
|
*
|
||||||
|
* A caller that wants the *mark* a given distance from something takes this off that distance --
|
||||||
|
* see the rule about aligning the mark rather than the box it is centred in.
|
||||||
|
*/
|
||||||
|
val HANDLE_MARGIN = (GLYPH_BUTTON_SIZE - HANDLE_MARK.value.dp) / 2
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line under a finished reply: what it cost to produce, and when it was sent.
|
||||||
|
*
|
||||||
|
* Small and set back, in the tone the session's own subtitle takes: it is about the message rather
|
||||||
|
* than part of it, and at the reply's own size it would read as the last thing the model said.
|
||||||
|
*
|
||||||
|
* Right-aligned because it closes the message rather than opening one -- a reader scanning down the
|
||||||
|
* left edge is reading what was said, and this is where that ends.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReplyFooter(
|
||||||
|
ts: Double,
|
||||||
|
tokensPerSecond: Double?,
|
||||||
|
prefillMs: Long?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the footer says, or null when there is nothing to say: "read 9.5s · 50.3 tok/s · 3:00 PM".
|
||||||
|
*
|
||||||
|
* Split out so the wording is testable without a screen, and [zone] is a parameter for the same
|
||||||
|
* reason [limitSummary] takes one: a test has to say the same thing wherever it runs.
|
||||||
|
*
|
||||||
|
* **The time is last, and so sits against the right edge whatever else is on the line.** The
|
||||||
|
* measurements in front of it are the provider's, so a session on another provider has fewer of
|
||||||
|
* them or none -- and a reader who has learned where the clock is should not have to find it again
|
||||||
|
* because the model changed. The costs grow leftwards into the space instead.
|
||||||
|
*
|
||||||
|
* Those measurements are drawn only where the provider made them. Most do not -- a coding CLI
|
||||||
|
* reports what a turn cost and never how long the model spent on it -- and the time this app
|
||||||
|
* watched a reply arrive over is a different quantity: it counts the network, the pauses between
|
||||||
|
* tokens and whatever else the machine was doing. So the line is the clock alone rather than a
|
||||||
|
* plausible figure beside it.
|
||||||
|
*/
|
||||||
|
fun replyFooterText(
|
||||||
|
ts: Double,
|
||||||
|
tokensPerSecond: Double?,
|
||||||
|
prefillMs: Long?,
|
||||||
|
zone: ZoneId,
|
||||||
|
): String? {
|
||||||
|
val at =
|
||||||
|
if (ts <= 0.0) null
|
||||||
|
else
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(Instant.ofEpochMilli((ts * 1000).toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// A tenth up to three digits, where the difference between 18 and 18.4 tok/s is something a
|
||||||
|
// reader comparing two models can use; past that the tenth is noise on a figure that moves by
|
||||||
|
// more than that between turns.
|
||||||
|
val rate =
|
||||||
|
tokensPerSecond
|
||||||
|
?.takeIf { it > 0.0 }
|
||||||
|
?.let {
|
||||||
|
if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
|
||||||
|
else String.format(Locale.getDefault(), "%.1f tok/s", it)
|
||||||
|
}
|
||||||
|
// Named "read" rather than given a unit alone, because a second figure in seconds beside a
|
||||||
|
// rate is unreadable otherwise -- and it is the same word the status row uses while it is
|
||||||
|
// happening, so the wait and the figure for it are one vocabulary.
|
||||||
|
val read = prefillMs?.takeIf { it > 0 }?.let { "read ${formatMillis(it)}" }
|
||||||
|
return listOfNotNull(read, rate, at).joinToString(" · ").ifEmpty { null }
|
||||||
|
}
|
||||||
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A session wanting attention, said over the app rather than through Android's drawer.
|
* A session wanting attention, said over the app as well as in Android's drawer.
|
||||||
*
|
*
|
||||||
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
|
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
||||||
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
||||||
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
||||||
* while these are on screen the stream is delivered here instead, which is arranged by the
|
* behind it that is still there when they go looking and goes by itself when they open the session.
|
||||||
* collection below and nothing else.
|
* Whether the app is open at all is this collection and nothing else.
|
||||||
*
|
*
|
||||||
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
||||||
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
||||||
|
|||||||
@@ -5,24 +5,32 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.FilterQuality
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
@@ -30,12 +38,20 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -140,12 +156,23 @@ fun SessionImageViewer(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||||
|
val view = LocalView.current
|
||||||
|
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
|
||||||
|
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties =
|
||||||
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||||
) {
|
) {
|
||||||
|
ViewerSystemBars(hiddenBars)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
Modifier.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClose,
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
when (val image = bitmap) {
|
when (val image = bitmap) {
|
||||||
@@ -165,7 +192,46 @@ fun SessionImageViewer(
|
|||||||
// beside it are.
|
// beside it are.
|
||||||
CircularProgressIndicator(color = Color.White)
|
CircularProgressIndicator(color = Color.White)
|
||||||
}
|
}
|
||||||
else -> ZoomableImage(image)
|
else -> {
|
||||||
|
var viewport by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
var nativeSizeRequest by remember { mutableIntStateOf(0) }
|
||||||
|
ZoomableImage(
|
||||||
|
image,
|
||||||
|
nativeSizeRequest = nativeSizeRequest,
|
||||||
|
onViewportChanged = {
|
||||||
|
viewport = it
|
||||||
|
ViewCompat.getRootWindowInsets(view)?.let { insets ->
|
||||||
|
barInsets =
|
||||||
|
ViewerBarInsets(
|
||||||
|
status =
|
||||||
|
insets
|
||||||
|
.getInsetsIgnoringVisibility(
|
||||||
|
WindowInsetsCompat.Type.statusBars()
|
||||||
|
)
|
||||||
|
.top,
|
||||||
|
navigation =
|
||||||
|
insets
|
||||||
|
.getInsetsIgnoringVisibility(
|
||||||
|
WindowInsetsCompat.Type.navigationBars()
|
||||||
|
)
|
||||||
|
.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBarsChanged = { hiddenBars = it },
|
||||||
|
barInsets = barInsets,
|
||||||
|
viewport = viewport,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { nativeSizeRequest++ },
|
||||||
|
modifier =
|
||||||
|
Modifier.align(Alignment.BottomEnd)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text("100%")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,34 +291,72 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
|
|||||||
* The image on its own, as large as it fits, with pinch to zoom.
|
* The image on its own, as large as it fits, with pinch to zoom.
|
||||||
*
|
*
|
||||||
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
||||||
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
|
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
|
||||||
* visible.
|
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
|
||||||
|
* screen pixel and recenters it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ZoomableImage(image: ImageBitmap) {
|
private fun ZoomableImage(
|
||||||
|
image: ImageBitmap,
|
||||||
|
nativeSizeRequest: Int,
|
||||||
|
onViewportChanged: (IntSize) -> Unit,
|
||||||
|
onBarsChanged: (ViewerBars) -> Unit,
|
||||||
|
barInsets: ViewerBarInsets,
|
||||||
|
viewport: IntSize,
|
||||||
|
) {
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
|
||||||
|
LaunchedEffect(nativeSizeRequest, nativeScale) {
|
||||||
|
if (nativeSizeRequest > 0) {
|
||||||
|
scale = nativeScale
|
||||||
|
offsetX = 0f
|
||||||
|
offsetY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val bars =
|
||||||
|
viewerBars(
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
viewport.width,
|
||||||
|
viewport.height,
|
||||||
|
scale,
|
||||||
|
Offset(offsetX, offsetY),
|
||||||
|
barInsets,
|
||||||
|
)
|
||||||
|
SideEffect { onBarsChanged(bars) }
|
||||||
Image(
|
Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
contentDescription = "Attached image",
|
contentDescription = "Attached image",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Inside,
|
||||||
// Zoomed in, the reader is looking at pixels on purpose.
|
// Zoomed in, the reader is looking at pixels on purpose.
|
||||||
filterQuality = FilterQuality.None,
|
filterQuality = FilterQuality.None,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
.onSizeChanged(onViewportChanged)
|
||||||
detectTransformGestures { _, pan, zoom, _ ->
|
.pointerInput(nativeScale) {
|
||||||
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||||
// already the whole of it; a ceiling so it cannot be lost off-screen.
|
val oldScale = scale
|
||||||
scale = (scale * zoom).coerceIn(1f, 8f)
|
val maximumScale = maxOf(8f, nativeScale)
|
||||||
if (scale > 1f) {
|
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
|
||||||
offsetX += pan.x
|
if (newScale > 1f) {
|
||||||
offsetY += pan.y
|
val offset =
|
||||||
|
zoomOffset(
|
||||||
|
Offset(offsetX, offsetY),
|
||||||
|
centroid,
|
||||||
|
pan,
|
||||||
|
oldScale,
|
||||||
|
newScale,
|
||||||
|
Offset(size.width / 2f, size.height / 2f),
|
||||||
|
)
|
||||||
|
offsetX = offset.x
|
||||||
|
offsetY = offset.y
|
||||||
} else {
|
} else {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
offsetY = 0f
|
offsetY = 0f
|
||||||
}
|
}
|
||||||
|
scale = newScale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
@@ -263,3 +367,104 @@ private fun ZoomableImage(image: ImageBitmap) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewerSystemBars(hidden: ViewerBars) {
|
||||||
|
val view = LocalView.current
|
||||||
|
val window = (view.parent as? DialogWindowProvider)?.window
|
||||||
|
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||||
|
SideEffect {
|
||||||
|
controller?.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
if (hidden.status) {
|
||||||
|
controller?.hide(WindowInsetsCompat.Type.statusBars())
|
||||||
|
} else {
|
||||||
|
controller?.show(WindowInsetsCompat.Type.statusBars())
|
||||||
|
}
|
||||||
|
if (hidden.navigation) {
|
||||||
|
controller?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||||
|
} else {
|
||||||
|
controller?.show(WindowInsetsCompat.Type.navigationBars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(view) {
|
||||||
|
onDispose {
|
||||||
|
controller?.show(
|
||||||
|
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
|
||||||
|
|
||||||
|
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
|
||||||
|
|
||||||
|
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
|
||||||
|
internal fun viewerBars(
|
||||||
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
|
viewportWidth: Int,
|
||||||
|
viewportHeight: Int,
|
||||||
|
scale: Float,
|
||||||
|
offset: Offset,
|
||||||
|
insets: ViewerBarInsets,
|
||||||
|
): ViewerBars {
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
|
||||||
|
return ViewerBars()
|
||||||
|
}
|
||||||
|
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
||||||
|
val width = imageWidth * fittedScale * scale
|
||||||
|
val height = imageHeight * fittedScale * scale
|
||||||
|
val left = viewportWidth / 2f + offset.x - width / 2f
|
||||||
|
val right = left + width
|
||||||
|
val top = viewportHeight / 2f + offset.y - height / 2f
|
||||||
|
val bottom = top + height
|
||||||
|
val crossesScreen = right > 0f && left < viewportWidth
|
||||||
|
return ViewerBars(
|
||||||
|
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
|
||||||
|
navigation =
|
||||||
|
crossesScreen &&
|
||||||
|
insets.navigation > 0 &&
|
||||||
|
bottom > viewportHeight - insets.navigation &&
|
||||||
|
top < viewportHeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
|
||||||
|
internal fun nativeScale(
|
||||||
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
|
viewportWidth: Int,
|
||||||
|
viewportHeight: Int,
|
||||||
|
): Float {
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
|
||||||
|
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The downscale-only factor used by [ContentScale.Inside]. */
|
||||||
|
private fun insideScale(
|
||||||
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
|
viewportWidth: Int,
|
||||||
|
viewportHeight: Int,
|
||||||
|
): Float =
|
||||||
|
minOf(
|
||||||
|
1f,
|
||||||
|
viewportWidth.toFloat() / imageWidth,
|
||||||
|
viewportHeight.toFloat() / imageHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
|
||||||
|
internal fun zoomOffset(
|
||||||
|
offset: Offset,
|
||||||
|
centroid: Offset,
|
||||||
|
pan: Offset,
|
||||||
|
oldScale: Float,
|
||||||
|
newScale: Float,
|
||||||
|
viewportCenter: Offset,
|
||||||
|
): Offset {
|
||||||
|
val scaleChange = newScale / oldScale
|
||||||
|
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,11 +14,15 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -29,14 +35,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
|
* The sessions tab: every session, in the order the reader has put them in.
|
||||||
|
*
|
||||||
|
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
|
||||||
|
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
|
||||||
|
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
|
||||||
|
* both meant a list that rearranged itself under whoever was reading it, and the status word and
|
||||||
|
* its colour already say which session wants something without the row having to move to say it.
|
||||||
|
*
|
||||||
|
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
|
||||||
|
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
|
||||||
|
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
|
||||||
|
* "which rows am I acting on" and "where does this one go" are two questions.
|
||||||
*
|
*
|
||||||
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
|
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
|
||||||
* What stays here is the button that adds a session, because that acts on this list and nothing
|
* What stays here is the button that adds a session, because that acts on this list and nothing
|
||||||
@@ -48,10 +70,23 @@ fun SessionListScreen(
|
|||||||
reloadToken: Int,
|
reloadToken: Int,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
|
/** A session this list has just deleted, for whoever is showing it elsewhere. */
|
||||||
|
onDeleted: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
|
||||||
|
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
|
||||||
|
// tab: a selection mode with nothing in it has no controls and no way out but Back.
|
||||||
|
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
|
// The sessions a delete has been confirmed for, or none. A list rather than one session,
|
||||||
|
// because a selection is what the bar below acts on.
|
||||||
|
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
|
||||||
|
|
||||||
|
// Whether an answer is outstanding, which is a different question from whether there is
|
||||||
|
// anything to draw: see [refresh].
|
||||||
|
var reloading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||||
@@ -60,6 +95,10 @@ fun SessionListScreen(
|
|||||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
|
|
||||||
|
// Why the order on screen is not the order that was saved, when saving one failed. The list is
|
||||||
|
// what failed, so it is reported over the list rather than on any row.
|
||||||
|
var orderError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
||||||
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
||||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
@@ -70,31 +109,142 @@ fun SessionListScreen(
|
|||||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
listState = LoadState.Loading
|
// The rows stay while the answer is on its way, with the bar below saying one is: this
|
||||||
|
// list is asked again every time the panel over a session is opened, and blanking it each
|
||||||
|
// time hands the reader an empty screen to report on something that was never in doubt.
|
||||||
|
// A first load has nothing to keep, and says so with the spinner instead.
|
||||||
|
if (listState !is LoadState.Loaded) listState = LoadState.Loading
|
||||||
|
reloading = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState =
|
listState =
|
||||||
try {
|
try {
|
||||||
val loaded =
|
val loaded =
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||||
deleteErrors = emptyMap()
|
deleteErrors = emptyMap()
|
||||||
|
val alive = loaded.value.map { it.id }.toSet()
|
||||||
|
// A selection is of sessions, so one deleted somewhere else leaves it. Only
|
||||||
|
// that one: the other rows the reader picked out are still there.
|
||||||
|
selected = selected.intersect(alive)
|
||||||
// The path out for a cached transcript whose session was deleted somewhere
|
// The path out for a cached transcript whose session was deleted somewhere
|
||||||
// else. This list is the only place that ever learns the full set. On the
|
// else. This list is the only place that ever learns the full set. On the
|
||||||
// answer rather than in `finally`: a list that failed to arrive says nothing
|
// answer rather than in `finally`: a list that failed to arrive says nothing
|
||||||
// about which sessions exist.
|
// about which sessions exist.
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
|
||||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
|
||||||
}
|
|
||||||
loaded
|
loaded
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
|
reloading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes every session in [targets], one after another.
|
||||||
|
*
|
||||||
|
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
|
||||||
|
* and each one ends a process. Each row says what is happening to it from the moment the work
|
||||||
|
* is handed over, which is also when the selection goes -- a bar still naming sessions being
|
||||||
|
* deleted is a set nobody can act on.
|
||||||
|
*/
|
||||||
|
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
|
||||||
|
selected = emptySet()
|
||||||
|
// Marked here rather than after the request returns: a row has to say something is
|
||||||
|
// happening to it from the moment it is asked for.
|
||||||
|
deleting = deleting + targets.map { it.id }
|
||||||
|
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
|
||||||
|
scope.launch {
|
||||||
|
for (session in targets) {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||||
|
// After it succeeded, not before: a refused delete leaves the session
|
||||||
|
// exactly as it was, and its transcript with it.
|
||||||
|
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||||
|
}
|
||||||
|
// Only this row, and only what changed. Refetching the list instead put every
|
||||||
|
// other session back through loading and handed the reader an empty screen, to
|
||||||
|
// report on something never in doubt.
|
||||||
|
val loaded = listState
|
||||||
|
if (loaded is LoadState.Loaded) {
|
||||||
|
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
|
||||||
|
}
|
||||||
|
onDeleted(session.id)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Kept, because it is still there: the server refused, so the session it
|
||||||
|
// refused about is exactly as it was.
|
||||||
|
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
||||||
|
} finally {
|
||||||
|
deleting = deleting - session.id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) { refresh() }
|
LaunchedEffect(reloadToken) { refresh() }
|
||||||
|
|
||||||
|
val rows = rememberLazyListState()
|
||||||
|
val reorder =
|
||||||
|
rememberReorder(
|
||||||
|
listState = rows,
|
||||||
|
onMove = { from, to ->
|
||||||
|
// Moved here and now, because the row is under a finger: waiting for the server to
|
||||||
|
// agree would drag the handle away from the card it is on. What the server thinks
|
||||||
|
// is asked for when the finger comes up, and a refusal puts the list back.
|
||||||
|
val loaded = listState
|
||||||
|
if (loaded is LoadState.Loaded) {
|
||||||
|
val moved = loaded.value.toMutableList()
|
||||||
|
moved.add(to, moved.removeAt(from))
|
||||||
|
listState = LoadState.Loaded(moved)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled = {
|
||||||
|
val loaded = listState
|
||||||
|
if (loaded is LoadState.Loaded) {
|
||||||
|
val order = loaded.value.map { it.id }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
|
||||||
|
orderError = null
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
orderError = e.message ?: "The new order couldn't be saved"
|
||||||
|
// The screen must not go on showing an arrangement nothing kept, so
|
||||||
|
// the server's own order comes back -- which is also the only way to
|
||||||
|
// see what it does think.
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Back leaves selection mode rather than the tab, which is the level it is one step above.
|
||||||
|
// Nested inside MainScreen's own handler, so it wins while there is a selection.
|
||||||
|
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||||
|
|
||||||
|
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
|
||||||
|
// can still be scrolled to while it is up.
|
||||||
|
var barHeight by remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
|
||||||
|
// reserved for a bar that is not up.
|
||||||
|
val covered = if (selected.isEmpty()) 0.dp else barHeight
|
||||||
|
|
||||||
|
// The spawn button floats over the list, so the list ends above it -- measured, for the
|
||||||
|
// reason the bar is. Without this the last row sat under the button, which was survivable
|
||||||
|
// while every part of a row did the same thing and is not now that corner is a handle.
|
||||||
|
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
orderError?.let { message ->
|
||||||
|
// The server's own words, unprefixed, the way every other failure is shown.
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
when (val state = listState) {
|
when (val state = listState) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||||
@@ -113,20 +263,32 @@ fun SessionListScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
LazyColumn(
|
||||||
val ordered =
|
state = rows,
|
||||||
state.value.sortedWith(
|
contentPadding =
|
||||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
|
||||||
.thenByDescending { it.lastActivity }
|
) {
|
||||||
)
|
uniqueItems(state.value, key = { it.id }) { session ->
|
||||||
LazyColumn {
|
|
||||||
uniqueItems(ordered, key = { it.id }) { session ->
|
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
error = deleteErrors[session.id],
|
error = deleteErrors[session.id],
|
||||||
deleting = session.id in deleting,
|
deleting = session.id in deleting,
|
||||||
onOpen = { onOpen(session) },
|
picked = session.id in selected,
|
||||||
onLongPress = { confirmingDelete = session },
|
// The handle is a selection-mode control, so it is absent rather
|
||||||
|
// than disabled outside one: this is not a capability being
|
||||||
|
// withheld, it is a mode the list is not in.
|
||||||
|
reorder = reorder.takeIf { selected.isNotEmpty() },
|
||||||
|
onClick = {
|
||||||
|
// In selection mode a tap is a selection, so the reader is
|
||||||
|
// never one mis-tap away from opening a session they were only
|
||||||
|
// picking rows for.
|
||||||
|
if (selected.isEmpty()) onOpen(session)
|
||||||
|
else
|
||||||
|
selected =
|
||||||
|
if (session.id in selected) selected - session.id
|
||||||
|
else selected + session.id
|
||||||
|
},
|
||||||
|
onLongPress = { selected = selected + session.id },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -135,70 +297,114 @@ fun SessionListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Over the list rather than above it: a bar that appears in the flow moves every row down
|
||||||
|
// by its own height at the moment the reader is looking at them.
|
||||||
|
if (reloading) {
|
||||||
|
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beside nothing in particular, because a selection is not one row: the options that act on
|
||||||
|
// it belong to the screen, and the bottom is where a thumb already is.
|
||||||
|
if (selected.isNotEmpty()) {
|
||||||
|
val picked =
|
||||||
|
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
||||||
|
SessionSelectionBar(
|
||||||
|
count = picked.size,
|
||||||
|
modifier =
|
||||||
|
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
||||||
|
barHeight = with(density) { it.height.toDp() }
|
||||||
|
},
|
||||||
|
onDelete = { confirmingDelete = picked },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the bar when there is one, by what that bar measured: the button stays rather than
|
||||||
|
// coming and going, since an absent control cannot say whether there was nothing to do.
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = onSpawn,
|
onClick = onSpawn,
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
modifier =
|
||||||
|
Modifier.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
|
||||||
|
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
|
||||||
) {
|
) {
|
||||||
Text("+", style = MaterialTheme.typography.headlineMedium)
|
Text("+", style = MaterialTheme.typography.headlineMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmingDelete?.let { session ->
|
val targets = confirmingDelete
|
||||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
if (targets.isNotEmpty()) {
|
||||||
// next. Off to begin with: see [deleteSession].
|
// Reset per selection, so a toggle turned on for one set of conversations is not still
|
||||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
// on for the next. Off to begin with: see [deleteSession].
|
||||||
|
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
|
||||||
|
// Whichever of these keep a transcript of their own decide what the sentences below say,
|
||||||
|
// and whether the switch is offered at all. Old servers reported only the capability, when
|
||||||
|
// Claude Code was its sole owner.
|
||||||
|
val owned = targets.filter { it.keepsOwnTranscript }
|
||||||
|
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmingDelete = null },
|
onDismissRequest = { confirmingDelete = emptyList() },
|
||||||
title = { Text("Delete \"${session.title}\"?") },
|
title = {
|
||||||
|
Text(
|
||||||
|
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
|
||||||
|
else "Delete ${targets.size} sessions?"
|
||||||
|
)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
// Two different acts behind one button, so it says which one this is. What
|
// Two different acts behind one button, so it says which one this is. What
|
||||||
// separates them is whether the *driver* keeps its own record of the conversation
|
// separates them is whether the *driver* keeps its own record of the
|
||||||
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
|
// conversation
|
||||||
|
// -- the coding CLIs do, whether this app spawned the session or imported it;
|
||||||
// echo and llama.cpp do not.
|
// echo and llama.cpp do not.
|
||||||
//
|
//
|
||||||
// This used to branch on `imported`, above a comment asserting that "a session
|
// This used to branch on `imported`, above a comment asserting that "a session
|
||||||
// started here has no copy anywhere". That was false for every claude-cli session
|
// started here has no copy anywhere". That was false for every coding-CLI session
|
||||||
// this app spawned, and getting it wrong in that direction is the expensive one:
|
// this app spawned, and getting it wrong in that direction is the expensive one:
|
||||||
// "this can't be undone", said of something that can, spends the credibility the
|
// "this can't be undone", said of something that can, spends the credibility that
|
||||||
// sentence needs.
|
// sentence needs.
|
||||||
//
|
//
|
||||||
// Neither branch promises a restore. The recoverable one says what is known -- the
|
// Neither branch promises a restore. The recoverable one says what is known,
|
||||||
// driver keeps its own record -- rather than that the file is still there, and it
|
// that the driver keeps its own record, rather than that the file is still there,
|
||||||
// names what goes either way, because this app's transcript holds images, peer
|
// and it names what goes either way, because this app's transcript holds images,
|
||||||
// messages and commands the CLI's own record never had.
|
// peer messages and commands the CLI's own record never had.
|
||||||
|
//
|
||||||
|
// A selection takes the sentence that covers all of it: "some of these" is what
|
||||||
|
// makes the mixed case true without either half of it being read as a promise
|
||||||
|
// about every row.
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
!session.keepsOwnTranscript ->
|
owned.isEmpty() ->
|
||||||
"Kills the process and deletes the conversation. Nothing else " +
|
"Kills the process and deletes the conversation. Nothing else " +
|
||||||
"keeps a copy, so this can't be undone."
|
"keeps a copy, so this can't be undone."
|
||||||
// The sentence below is the one the toggle makes false, which is why it
|
// The sentence below is the one the toggle makes false, which is
|
||||||
// is written twice rather than appended to: leaving "should still be
|
// why it is written twice rather than appended to: "should still be
|
||||||
// there to import again" on screen beside a switch that removes it is
|
// there to import again", left on screen beside a switch that removes
|
||||||
// the reassurance being read at the moment it stops being true.
|
// it, is the reassurance being read as it stops being true.
|
||||||
alsoDeleteForeign ->
|
alsoDeleteForeign ->
|
||||||
"Kills the process and deletes both copies of the conversation: " +
|
"Kills the process and deletes both copies of the conversation: " +
|
||||||
"this app's, and Claude Code's own transcript on the " +
|
"this app's, and $transcriptOwner's own transcript on the " +
|
||||||
"machine. Nothing keeps another, so this can't be undone."
|
"machine. Nothing keeps another, so this can't be undone."
|
||||||
else ->
|
else ->
|
||||||
"Stops the process and deletes this app's copy of the " +
|
"Stops the process and deletes this app's copy of the " +
|
||||||
"conversation, including any images, peer messages and " +
|
"conversation, including any images, peer messages and " +
|
||||||
"commands recorded only here. Claude Code keeps its own " +
|
"commands recorded only here. $transcriptOwner keeps its own " +
|
||||||
"transcript on the machine, so the conversation itself " +
|
"transcript on the machine" +
|
||||||
"should still be there to import again."
|
(if (owned.size < targets.size) " for some of these" else "") +
|
||||||
|
", so the conversation itself should still be there to " +
|
||||||
|
"import again."
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Only where there is a second copy to decide about. Absent rather than
|
// Only where there is a second copy to decide about. Absent rather than
|
||||||
// disabled, because this is not a capability being withheld: for echo and
|
// disabled, because this is not a capability being withheld: for echo and
|
||||||
// llama.cpp there is no other transcript, and a switch offering to delete one
|
// llama.cpp there is no other transcript, and a switch offering to delete one
|
||||||
// would be asking about something that does not exist.
|
// would be asking about something that does not exist.
|
||||||
if (session.keepsOwnTranscript) {
|
if (owned.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
// Its own row rather than beside the paragraph: a switch is taller than a
|
// Its own row rather than beside the paragraph: a switch is taller
|
||||||
// line of text and re-centres whatever shares a row with it.
|
// than a line of text and re-centres whatever shares a row with it.
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"Delete Claude Code's transcript too",
|
"Delete $transcriptOwner's transcript too",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -214,38 +420,8 @@ fun SessionListScreen(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
confirmingDelete = null
|
confirmingDelete = emptyList()
|
||||||
// Marked here rather than after the request returns: the row has to say
|
deleteChosen(targets, alsoDeleteForeign)
|
||||||
// something is happening to it from the moment it is asked for.
|
|
||||||
deleting = deleting + session.id
|
|
||||||
deleteErrors = deleteErrors - session.id
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
|
||||||
// After it succeeded, not before: a refused delete leaves the
|
|
||||||
// session exactly as it was, and its transcript with it.
|
|
||||||
transcriptCache.session(session.id).purge()
|
|
||||||
}
|
|
||||||
// Only this row, and only what changed. Refetching the list instead
|
|
||||||
// put every other session back through loading and handed the
|
|
||||||
// reader an empty screen, to report on something never in doubt.
|
|
||||||
val loaded = listState
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
listState =
|
|
||||||
LoadState.Loaded(
|
|
||||||
loaded.value.filterNot { it.id == session.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Kept, because it is still there: the server refused, so the
|
|
||||||
// session it refused about is exactly as it was.
|
|
||||||
deleteErrors =
|
|
||||||
deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
|
||||||
} finally {
|
|
||||||
deleting = deleting - session.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Coloured by consequence: this takes something away, and does so wherever it
|
// Coloured by consequence: this takes something away, and does so wherever it
|
||||||
@@ -254,12 +430,54 @@ fun SessionListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ring of space inside a session's card, which is also what its handle leaves around itself.
|
||||||
|
*/
|
||||||
|
private val CARD_PADDING = 16.dp
|
||||||
|
|
||||||
|
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
|
||||||
|
private val BUTTON_RING = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can be done to the sessions that are selected.
|
||||||
|
*
|
||||||
|
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
|
||||||
|
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SessionSelectionBar(
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$count selected",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDelete) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionCard(
|
private fun SessionCard(
|
||||||
@@ -274,62 +492,118 @@ private fun SessionCard(
|
|||||||
* about a request that has not been answered yet.
|
* about a request that has not been answered yet.
|
||||||
*/
|
*/
|
||||||
deleting: Boolean,
|
deleting: Boolean,
|
||||||
onOpen: () -> Unit,
|
/** Whether this row is one of the selection the bottom bar acts on. */
|
||||||
|
picked: Boolean,
|
||||||
|
/** The drag this row can be moved by, or null where the list is not in selection mode. */
|
||||||
|
reorder: Reorder?,
|
||||||
|
onClick: () -> Unit,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val held = reorder?.held == session.id
|
||||||
BusyItem(label = if (deleting) "deleting" else null) {
|
BusyItem(label = if (deleting) "deleting" else null) {
|
||||||
Card(
|
Card(
|
||||||
// Off while the delete is in flight: a card that still opens a session it is deleting
|
colors =
|
||||||
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
|
if (picked)
|
||||||
// which leaves gestures alone so the list still scrolls.
|
CardDefaults.cardColors(
|
||||||
Modifier.fillMaxWidth()
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
.combinedClickable(
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
enabled = !deleting,
|
)
|
||||||
onClick = onOpen,
|
else CardDefaults.cardColors(),
|
||||||
onLongClick = onLongPress,
|
// Lifted while it is in hand, which is the one cue that says this row is being carried
|
||||||
)
|
// rather than sitting where it belongs.
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
// Drawn where the finger has taken it, above the rows it is passing over. Both
|
||||||
|
// in the layer rather than in the layout, so nothing around it moves and the
|
||||||
|
// list does not remeasure per frame of a drag.
|
||||||
|
.zIndex(if (held) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Row(
|
Column(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
// Everything but the handle, which is what makes the two gestures separate
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// rather than competing: a press that lands on the handle never reaches this,
|
||||||
|
// so holding it cannot select the row it is about to move. The card had the
|
||||||
|
// click while the handle was the only thing inside it that did not want one,
|
||||||
|
// and a hold on the handle then both selected the row and ate the drag.
|
||||||
|
//
|
||||||
|
// Off while the delete is in flight: a card that still opens a session it is
|
||||||
|
// deleting is a race the reader can start by tapping. Here rather than in
|
||||||
|
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
||||||
|
Modifier.weight(1f)
|
||||||
|
.combinedClickable(
|
||||||
|
enabled = !deleting,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongPress,
|
||||||
|
)
|
||||||
|
.padding(CARD_PADDING)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
session.title,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.weight(1f),
|
) {
|
||||||
)
|
Text(
|
||||||
StatusText(session.status)
|
session.title,
|
||||||
}
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Spacer(Modifier.height(4.dp))
|
modifier = Modifier.weight(1f),
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
)
|
||||||
Text(
|
StatusText(session.status)
|
||||||
// Machine, then what runs on it, then what it is set to: the same order and
|
if (session.backgroundTasks > 0) {
|
||||||
// separator as the session screen's header and the usage dialog, so one
|
Text(
|
||||||
// pair of facts is not written three ways.
|
backgroundTaskLabel(session.backgroundTasks),
|
||||||
listOfNotNull(
|
style = MaterialTheme.typography.labelLarge,
|
||||||
session.setupName,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
session.provider,
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
session.model?.let { modelLabel(it) },
|
|
||||||
)
|
)
|
||||||
.joinToString(" · "),
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Spacer(Modifier.height(4.dp))
|
||||||
modifier = Modifier.weight(1f),
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
)
|
Text(
|
||||||
Text(
|
// Machine, then what runs on it, then what it is set to: the same order
|
||||||
relativeTime(session.lastActivity),
|
// and separator as the session screen's header and the usage dialog, so
|
||||||
style = MaterialTheme.typography.bodySmall,
|
// one pair of facts is not written three ways.
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
listOfNotNull(
|
||||||
)
|
session.machineName,
|
||||||
|
session.provider,
|
||||||
|
session.model?.let { modelLabel(it) },
|
||||||
|
)
|
||||||
|
.joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
relativeTime(session.lastActivity),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The server's own words, unprefixed, the way every other failure is shown.
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
error?.let {
|
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held
|
||||||
Spacer(Modifier.height(8.dp))
|
// open for it outside selection mode: the row is then the row it always was.
|
||||||
// The server's own words, unprefixed, the way every other failure is shown.
|
if (reorder != null) {
|
||||||
Text(
|
ReorderHandle(
|
||||||
it,
|
reorder,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
session.id,
|
||||||
color = MaterialTheme.colorScheme.error,
|
// Dimmed with the rest of the row while something is happening to it, since
|
||||||
|
// a row on its way out is not one to rearrange -- see [BusyItem], whose
|
||||||
|
// appearance this matches rather than repeating its dimming rule.
|
||||||
|
// The mark lines up with the text on the other side of the card,
|
||||||
|
// which means taking the square it is centred in off the gap: see
|
||||||
|
// [HANDLE_MARGIN].
|
||||||
|
Modifier.alpha(if (deleting) 0.4f else 1f)
|
||||||
|
.padding(end = CARD_PADDING - HANDLE_MARGIN),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,18 +613,8 @@ private fun SessionCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusText(status: String) {
|
fun StatusText(status: String) {
|
||||||
val (label, color) =
|
val label = sessionStatusWord(status)
|
||||||
when (status) {
|
val color = sessionStatusColour(status)
|
||||||
"awaitingInput" -> "your turn" to awaitingColor
|
|
||||||
"running" -> "running" to runningColor
|
|
||||||
"compacting" -> "compacting" to commandColor
|
|
||||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
// Said in words, because it differs in kind from the others rather than in degree: the
|
|
||||||
// session is not idle and has not exited, nobody has been able to find out which. A
|
|
||||||
// muted colour alone would read as one of the quiet states.
|
|
||||||
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (sessionWorking(status)) {
|
if (sessionWorking(status)) {
|
||||||
// The same colour as the word beside it: the two are one signal, and a spinner in the
|
// The same colour as the word beside it: the two are one signal, and a spinner in the
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,331 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can be changed about one session, as opposed to about this app.
|
|
||||||
*
|
|
||||||
* Over the session rather than a step down from it: everything here is about the conversation
|
|
||||||
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
||||||
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
||||||
* controls and hid the thing they act on.
|
|
||||||
*
|
|
||||||
* The model and the permission mode are deliberately still on the session's own bar, because those
|
|
||||||
* are changed *while* reading a turn -- "not this model, try that one".
|
|
||||||
*
|
|
||||||
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
|
||||||
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
|
||||||
* and Reload do, because what those two take away is not visible from here.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionSettingsDialog(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
/**
|
|
||||||
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
|
||||||
*/
|
|
||||||
title: String,
|
|
||||||
onRenamed: (String) -> Unit,
|
|
||||||
/**
|
|
||||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
|
||||||
* the Reload row below, which is what would discard it.
|
|
||||||
*/
|
|
||||||
cachedBytes: Long?,
|
|
||||||
onReload: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
/**
|
|
||||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
|
||||||
* measures is that screen's own state.
|
|
||||||
*/
|
|
||||||
onCopyRenderReport: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
|
||||||
var saving by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
|
||||||
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
|
||||||
// position that may have been changed since. Until the answer arrives the switch is disabled
|
|
||||||
// and a spinner sits beside it, which is what not knowing looks like.
|
|
||||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
||||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Where the session works. Null until the server has been asked, for the same reason the switch
|
|
||||||
// above is. An empty answer is a session that was never given a directory, which is not the
|
|
||||||
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
|
||||||
// settled.
|
|
||||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
||||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
||||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var movingCwd by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
|
||||||
try {
|
|
||||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
||||||
notify = fresh.notify
|
|
||||||
cwd = fresh.cwd.orEmpty()
|
|
||||||
typedCwd = fresh.cwd.orEmpty()
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
|
||||||
// instead of offering a position nothing confirmed.
|
|
||||||
notifyError = e.message
|
|
||||||
notify = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the session, which ends the process that is in the old directory.
|
|
||||||
*
|
|
||||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
||||||
* process, and a stopped session is a state this app already has a word and a button for.
|
|
||||||
*/
|
|
||||||
fun moveCwd() {
|
|
||||||
val chosen = typedCwd.trim()
|
|
||||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
||||||
movingCwd = true
|
|
||||||
cwdError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
||||||
cwd = chosen
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Where it happened: this field is the only thing on screen that knows a move was
|
|
||||||
// asked for, and the reason is usually the path itself.
|
|
||||||
cwdError = e.message
|
|
||||||
} finally {
|
|
||||||
movingCwd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
|
||||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
|
||||||
// and one that stays moved after a refusal lies.
|
|
||||||
fun setNotify(wanted: Boolean) {
|
|
||||||
val was = notify
|
|
||||||
notify = wanted
|
|
||||||
notifyError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
notify = was
|
|
||||||
notifyError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
|
||||||
// request whose success would look exactly like the failure of having typed nothing.
|
|
||||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
if (!changed || saving) return
|
|
||||||
val chosen = name.trim()
|
|
||||||
saving = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
||||||
onRenamed(chosen)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Reported here, where it happened, because this dialog is the only place that
|
|
||||||
// knows a rename was attempted.
|
|
||||||
error = e.message
|
|
||||||
saving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Session settings") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !saving,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
// The keyboard's own action does what the button does: a one-field form where
|
|
||||||
// the return key does nothing is a form people press return at anyway.
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Notifications", modifier = Modifier.weight(1f))
|
|
||||||
if (notify == null && notifyError == null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = notify == true,
|
|
||||||
onCheckedChange = { setNotify(it) },
|
|
||||||
enabled = notify != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Beside the switch that failed, not with the rename's error: they are two requests
|
|
||||||
// and a reader has to be able to tell which one the server refused.
|
|
||||||
notifyError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = typedCwd,
|
|
||||||
onValueChange = { typedCwd = it },
|
|
||||||
label = { Text("Working directory") },
|
|
||||||
// What the field cannot say by being empty: a session that was never given
|
|
||||||
// one starts wherever its launcher does, and this names that rather than
|
|
||||||
// showing a path nobody chose.
|
|
||||||
placeholder = { Text("wherever the session was started") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = cwd != null && !movingCwd,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = { moveCwd() },
|
|
||||||
enabled =
|
|
||||||
cwd != null &&
|
|
||||||
!movingCwd &&
|
|
||||||
typedCwd.trim().isNotEmpty() &&
|
|
||||||
typedCwd.trim() != cwd,
|
|
||||||
) {
|
|
||||||
Text(if (movingCwd) "Moving..." else "Move")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
||||||
// directory is settled when the process is spawned, so it is ended and the next
|
|
||||||
// thing said to the session starts it in the new place.
|
|
||||||
Text(
|
|
||||||
"Moving stops the session's process. It starts again in the new directory " +
|
|
||||||
"with the next message, or with Start.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
cwdError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Transcript", modifier = Modifier.weight(1f))
|
|
||||||
// The size is what the button discards, and the unknown state is drawn rather
|
|
||||||
// than guessed: a spinner while the directory is being measured, and words when
|
|
||||||
// there is nothing there, because "nothing cached" and "0 B" read as different
|
|
||||||
// claims.
|
|
||||||
when {
|
|
||||||
cachedBytes == null ->
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
else ->
|
|
||||||
Text(
|
|
||||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
// Enabled whether or not anything is cached: "what I see disagrees with the
|
|
||||||
// machine" is a state an empty cache can be in too, and a control that comes
|
|
||||||
// and goes makes its own presence the signal.
|
|
||||||
TextButton(onClick = onReload) { Text("Reload") }
|
|
||||||
}
|
|
||||||
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
|
||||||
// costs is not visible, and neither is the case it exists for.
|
|
||||||
Text(
|
|
||||||
"Reload throws away this phone's copy and fetches the transcript from the " +
|
|
||||||
"server again. Use it when what is shown here disagrees with the file " +
|
|
||||||
"on the machine.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
error?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// About this session, which is what everything in here is -- and it was on the
|
|
||||||
// header until 2026-09-03, where the folder button now is. It copies rather than
|
|
||||||
// opening anything, so it says so and then says it happened: a row that looks like
|
|
||||||
// a control and gives no sign of having run is one people press twice.
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Render timings", modifier = Modifier.weight(1f))
|
|
||||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
|
||||||
// makes its own presence the signal, and its absence cannot say why.
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
|
||||||
Text(if (saving) "Saving..." else "Save")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can be changed about one session, and about the provider serving it.
|
||||||
|
*
|
||||||
|
* A screen rather than a dialog, again, and for the reason the dialog was chosen in the first place
|
||||||
|
* turned around: it has outgrown one. A Material dialog constrains its own height and scrolls
|
||||||
|
* inside itself, so a form of a dozen settings is read through a letterbox that also covers the
|
||||||
|
* conversation it is about -- and there is nowhere in it to put a second tab. Drawn over the
|
||||||
|
* session rather than as a `Screen` of its own, so the session under it stays composed and its
|
||||||
|
* stream keeps flowing; the back gesture closes it.
|
||||||
|
*
|
||||||
|
* **Two tabs, and the second is not a copy.** It is [ProviderScreen] -- the same composable the
|
||||||
|
* machines tab opens, for this session's machine and provider. A session's settings and its
|
||||||
|
* provider's are different things with different owners (one rides on a request, one decides how a
|
||||||
|
* model is loaded for everybody), and this is the second way in rather than a second version of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* The model and the permission mode are on the session's own bar as well, because those are changed
|
||||||
|
* *while* reading a turn -- "not this model, try that one". They are here too because that bar is
|
||||||
|
* one row shared with three actions: a long model name leaves the other picker a few pixels wide,
|
||||||
|
* and this is where somebody goes looking for a setting anyway.
|
||||||
|
*
|
||||||
|
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
||||||
|
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
||||||
|
* and Reload do, because what those two take away is not visible from here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SessionSettingsScreen(
|
||||||
|
settings: ServerSettings,
|
||||||
|
sessionId: String,
|
||||||
|
/** Which machine and provider the second tab is about. */
|
||||||
|
machineId: String,
|
||||||
|
provider: String,
|
||||||
|
/**
|
||||||
|
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
||||||
|
*/
|
||||||
|
title: String,
|
||||||
|
onRenamed: (String) -> Unit,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Owned by the screen behind this rather than held here, like [title]: this dialog is what
|
||||||
|
* changes it, and a level kept only for as long as the dialog is open is the old one again the
|
||||||
|
* next time it is opened.
|
||||||
|
*
|
||||||
|
* Not fetched, because unlike the notification switch there is nothing else that changes it:
|
||||||
|
* the level is this app's to set and the server does not resolve it into something else.
|
||||||
|
*/
|
||||||
|
effort: String?,
|
||||||
|
onEffortChanged: (String?) -> Unit,
|
||||||
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||||
|
takesEffort: Boolean,
|
||||||
|
/**
|
||||||
|
* The settings that are one of a list -- the model and the permission mode.
|
||||||
|
*
|
||||||
|
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
|
||||||
|
* the provider offers. Whichever of them this one has no answer for is not in the list, and
|
||||||
|
* draws no row.
|
||||||
|
*/
|
||||||
|
choices: List<SessionChoice>,
|
||||||
|
/**
|
||||||
|
* The settings this session's provider takes, and what they are set to.
|
||||||
|
*
|
||||||
|
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
|
||||||
|
* provider with none, which draws no section at all.
|
||||||
|
*/
|
||||||
|
paramSpecs: List<ParamSpec>,
|
||||||
|
params: Map<String, String>,
|
||||||
|
onParamsChanged: (Map<String, String>) -> Unit,
|
||||||
|
/**
|
||||||
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||||
|
* the Reload row below, which is what would discard it.
|
||||||
|
*/
|
||||||
|
cachedBytes: Long?,
|
||||||
|
onReload: () -> Unit,
|
||||||
|
/**
|
||||||
|
* Opens the transcript file itself in the explorer. Null from a server that does not say where
|
||||||
|
* it is, which draws no button rather than one that cannot work.
|
||||||
|
*/
|
||||||
|
onViewRaw: (() -> Unit)?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
/**
|
||||||
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||||
|
* measures is that screen's own state.
|
||||||
|
*/
|
||||||
|
onCopyRenderReport: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
|
var effortError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
||||||
|
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
||||||
|
// position that may have been changed since. Until the answer arrives the switch is disabled
|
||||||
|
// and a spinner sits beside it, which is what not knowing looks like.
|
||||||
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||||
|
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||||
|
// The same three-state shape the notification switch has, for the same reason: until the
|
||||||
|
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
|
||||||
|
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||||
|
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
|
||||||
|
// When the server next intends to ask whether the limit has lifted, or null when nothing is
|
||||||
|
// waiting. Read once with everything else: it moves on the server's schedule, not this
|
||||||
|
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
|
||||||
|
// told.
|
||||||
|
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
|
||||||
|
var resumeError by remember { mutableStateOf<String?>(null) }
|
||||||
|
// Where the session works. Null until the server has been asked, for the same reason the switch
|
||||||
|
// above is. An empty answer is a session that was never given a directory, which is not the
|
||||||
|
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
||||||
|
// settled.
|
||||||
|
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||||
|
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
||||||
|
var cwdError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var movingCwd by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(sessionId) {
|
||||||
|
try {
|
||||||
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||||
|
notify = fresh.notify
|
||||||
|
autoResume = fresh.autoResume
|
||||||
|
resumeMessage = fresh.autoResumeMessage
|
||||||
|
resumeAt = fresh.resumeAt
|
||||||
|
cwd = fresh.cwd.orEmpty()
|
||||||
|
typedCwd = fresh.cwd.orEmpty()
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
||||||
|
// instead of offering a position nothing confirmed.
|
||||||
|
notifyError = e.message
|
||||||
|
notify = null
|
||||||
|
resumeError = e.message
|
||||||
|
autoResume = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the session, which ends the process that is in the old directory.
|
||||||
|
*
|
||||||
|
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
||||||
|
* process, and a stopped session is a state this app already has a word and a button for.
|
||||||
|
*/
|
||||||
|
fun moveCwd() {
|
||||||
|
val chosen = typedCwd.trim()
|
||||||
|
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||||
|
movingCwd = true
|
||||||
|
cwdError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
||||||
|
cwd = chosen
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Where it happened: this field is the only thing on screen that knows a move was
|
||||||
|
// asked for, and the reason is usually the path itself.
|
||||||
|
cwdError = e.message
|
||||||
|
} finally {
|
||||||
|
movingCwd = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||||
|
*
|
||||||
|
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||||
|
* control that stays where it was put after a refusal is stating something untrue.
|
||||||
|
*/
|
||||||
|
fun setEffort(chosen: String?) {
|
||||||
|
val was = effort
|
||||||
|
onEffortChanged(chosen)
|
||||||
|
effortError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
onEffortChanged(was)
|
||||||
|
effortError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
||||||
|
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
||||||
|
// and one that stays moved after a refusal lies.
|
||||||
|
fun setNotify(wanted: Boolean) {
|
||||||
|
val was = notify
|
||||||
|
notify = wanted
|
||||||
|
notifyError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
notify = was
|
||||||
|
notifyError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns auto-resume on or off, or changes what it would say.
|
||||||
|
*
|
||||||
|
* One request for both, because the server takes one: switching it on and typing the message
|
||||||
|
* are two halves of the same decision, and sending them separately would leave a moment where
|
||||||
|
* the session is armed with the old words.
|
||||||
|
*
|
||||||
|
* Put back if refused, like the notification switch. Turning it off also clears what was
|
||||||
|
* scheduled -- said here rather than only on the server, or the row would go on naming a time
|
||||||
|
* that no longer exists.
|
||||||
|
*/
|
||||||
|
fun setAutoResume(on: Boolean, message: String) {
|
||||||
|
val wasOn = autoResume
|
||||||
|
val wasMessage = resumeMessage
|
||||||
|
val wasAt = resumeAt
|
||||||
|
autoResume = on
|
||||||
|
resumeMessage = message
|
||||||
|
if (!on) resumeAt = null
|
||||||
|
resumeError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
setSessionAutoResume(settings, sessionId, on, message)
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
autoResume = wasOn
|
||||||
|
resumeMessage = wasMessage
|
||||||
|
resumeAt = wasAt
|
||||||
|
resumeError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
||||||
|
// request whose success would look exactly like the failure of having typed nothing.
|
||||||
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
if (!changed || saving) return
|
||||||
|
val chosen = name.trim()
|
||||||
|
saving = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
||||||
|
onRenamed(chosen)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Reported here, where it happened, because this dialog is the only place that
|
||||||
|
// knows a rename was attempted.
|
||||||
|
error = e.message
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The platform's own way back out of a layer: without it, back falls through to whatever is
|
||||||
|
// under this and closes the session -- which reads as a crash to somebody who meant to return
|
||||||
|
// to what they were reading.
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
var tab by remember(sessionId) { mutableIntStateOf(0) }
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
// The keyboard covers the lower half of a form of fields, and this is a screen rather
|
||||||
|
// than a dialog now -- nothing else is going to move it out of the way.
|
||||||
|
Column(Modifier.fillMaxSize().imePadding()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
GlyphButton(BACK_GLYPH, "Back", onDismiss)
|
||||||
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||||
|
Text(
|
||||||
|
"Settings",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Disabled rather than absent while there is nothing to save: a button that comes
|
||||||
|
// and goes makes its own presence the signal, and its absence cannot say why.
|
||||||
|
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
||||||
|
Text(if (saving) "Saving..." else "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The same two-tab shape the main screen uses for its three, so a reader who has
|
||||||
|
// learned one has learned the other.
|
||||||
|
TabRow(selectedTabIndex = tab) {
|
||||||
|
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Session") })
|
||||||
|
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(provider) })
|
||||||
|
}
|
||||||
|
if (tab == 1) {
|
||||||
|
// The machines tab's own screen, with its back control left off: this one has a
|
||||||
|
// header of its own, and two ways out stacked above each other is a reader asking
|
||||||
|
// which of them goes where.
|
||||||
|
ProviderScreen(
|
||||||
|
settings = settings,
|
||||||
|
machineId = machineId,
|
||||||
|
provider = provider,
|
||||||
|
onBack = null,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 12.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
LabelledField(
|
||||||
|
label = "Name",
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
enabled = !saving,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
// The keyboard's own action does what the button does: a one-field form where
|
||||||
|
// the return key does nothing is a form people press return at anyway.
|
||||||
|
keyboardActions = KeyboardActions(onDone = { save() }),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Notifications", modifier = Modifier.weight(1f))
|
||||||
|
if (notify == null && notifyError == null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = notify == true,
|
||||||
|
onCheckedChange = { setNotify(it) },
|
||||||
|
enabled = notify != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Beside the switch that failed, not with the rename's error: they are two requests
|
||||||
|
// and a reader has to be able to tell which one the server refused.
|
||||||
|
notifyError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
|
||||||
|
if (autoResume == null && resumeError == null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoResume == true,
|
||||||
|
onCheckedChange = { setAutoResume(it, resumeMessage) },
|
||||||
|
enabled = autoResume != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Disabled rather than hidden while the switch is off: a field that comes and goes
|
||||||
|
// makes its own presence the signal, and a visible one teaches what the switch will
|
||||||
|
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
|
||||||
|
// sentence is one request instead of one per letter.
|
||||||
|
LabelledField(
|
||||||
|
label = "Message to send",
|
||||||
|
value = resumeMessage,
|
||||||
|
onValueChange = { resumeMessage = it },
|
||||||
|
// What an empty field means, said above it: the server's own word rather than
|
||||||
|
// a session poked with nothing to read.
|
||||||
|
hint = DEFAULT_RESUME_MESSAGE,
|
||||||
|
enabled = autoResume == true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
||||||
|
)
|
||||||
|
// What it does and what it costs, in the order it happens. The last sentence is the
|
||||||
|
// one that matters: the time below is when the server will *ask*, not a promise
|
||||||
|
// about when the session speaks.
|
||||||
|
Text(
|
||||||
|
"When this session stops because the account is out of quota, the server " +
|
||||||
|
"checks the limit and sends this message once it has lifted. It checks " +
|
||||||
|
"again if the limit is still on.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
// Only where something is actually waiting. Absent is not a state worth a row: a
|
||||||
|
// session that has not hit a limit has nothing scheduled, which the reader can see
|
||||||
|
// from the switch.
|
||||||
|
resumeAt?.let { at ->
|
||||||
|
Text(
|
||||||
|
"Waiting now -- next check ${formatCheckTime(at)}.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resumeError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The button sits at the bottom of the row rather than centred on it: the field
|
||||||
|
// beside it is a label above a box, and a control centred against the pair lands
|
||||||
|
// beside the label rather than beside the thing it acts on.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
LabelledField(
|
||||||
|
label = "Working directory",
|
||||||
|
value = typedCwd,
|
||||||
|
onValueChange = { typedCwd = it },
|
||||||
|
// What the field cannot say by being empty: a session that was never given
|
||||||
|
// one starts wherever its launcher does, and this names that rather than
|
||||||
|
// showing a path nobody chose.
|
||||||
|
hint = "wherever the session was started",
|
||||||
|
enabled = cwd != null && !movingCwd,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { moveCwd() },
|
||||||
|
enabled =
|
||||||
|
cwd != null &&
|
||||||
|
!movingCwd &&
|
||||||
|
typedCwd.trim().isNotEmpty() &&
|
||||||
|
typedCwd.trim() != cwd,
|
||||||
|
) {
|
||||||
|
Text(if (movingCwd) "Moving..." else "Move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The whole of what pressing Move does, where it is about to be pressed. A
|
||||||
|
// directory is settled when the process is spawned, so it is ended and the next
|
||||||
|
// thing said to the session starts it in the new place.
|
||||||
|
Text(
|
||||||
|
"Moving stops the session's process. It starts again in the new directory " +
|
||||||
|
"with the next message, or with Start.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
cwdError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
choices.forEach { choice ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(choice.label, modifier = Modifier.weight(1f))
|
||||||
|
PickerButton(
|
||||||
|
current = choice.current,
|
||||||
|
options = choice.options,
|
||||||
|
onPick = choice.onPick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||||
|
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||||
|
// -- the row would be teaching something false about it.
|
||||||
|
if (takesEffort) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Thinking", modifier = Modifier.weight(1f))
|
||||||
|
PickerButton(
|
||||||
|
current = effort ?: DEFAULT_EFFORT,
|
||||||
|
// The level the CLI picks for itself is in the list as well as in the
|
||||||
|
// button, so leaving a level is not a one-way trip -- the same
|
||||||
|
// correction the model picker carries.
|
||||||
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||||
|
onPick = { chosen ->
|
||||||
|
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// What it costs, said where it is about to be pressed, like Move above: the
|
||||||
|
// CLI reads the level when it launches and has no control request for
|
||||||
|
// changing one.
|
||||||
|
Text(
|
||||||
|
"Changing this stops the session's process. It starts again with the " +
|
||||||
|
"next message, or with Start.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
effortError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paramSpecs.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Model settings",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Edited here and saved by the screen behind this, which is what makes
|
||||||
|
// typing in a text field affordable: the save is debounced, and a dialog
|
||||||
|
// dismissed mid-edit would take an unsaved value with it.
|
||||||
|
ProviderParamFields(
|
||||||
|
specs = paramSpecs,
|
||||||
|
values = params,
|
||||||
|
onChange = onParamsChanged,
|
||||||
|
warnAboutRestart = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Transcript", modifier = Modifier.weight(1f))
|
||||||
|
// The size is what the button discards, and the unknown state is drawn rather
|
||||||
|
// than guessed: a spinner while the directory is being measured, and words when
|
||||||
|
// there is nothing there, because "nothing cached" and "0 B" read as different
|
||||||
|
// claims.
|
||||||
|
when {
|
||||||
|
cachedBytes == null ->
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both on a line of their own under what they act on, rather than crowded against
|
||||||
|
// the size on the line above: two buttons and a measurement do not fit the width
|
||||||
|
// of a phone, and the one that would lose is the number.
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// The record as it is on disk, for the question the drawn conversation cannot
|
||||||
|
// answer -- which is most of what anybody opens this dialog to debug.
|
||||||
|
onViewRaw?.let { TextButton(onClick = it) { Text("View raw") } }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
// Enabled whether or not anything is cached: "what I see disagrees with the
|
||||||
|
// machine" is a state an empty cache can be in too, and a control that comes
|
||||||
|
// and goes makes its own presence the signal.
|
||||||
|
TextButton(onClick = onReload) { Text("Reload") }
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// About this session, which is what everything in here is -- and it was on the
|
||||||
|
// header until 2026-09-03, where the folder button now is. It copies rather than
|
||||||
|
// opening anything, so it says so and then says it happened: a row that looks like
|
||||||
|
// a control and gives no sign of having run is one people press twice.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Render timings", modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session setting that is a choice from a list, as this dialog draws it.
|
||||||
|
*
|
||||||
|
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
|
||||||
|
* or neither, and they are otherwise the same control.
|
||||||
|
*/
|
||||||
|
data class SessionChoice(
|
||||||
|
val label: String,
|
||||||
|
val current: String,
|
||||||
|
val options: List<String>,
|
||||||
|
val onPick: (String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the server will next look, as a local time.
|
||||||
|
*
|
||||||
|
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
|
||||||
|
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
|
||||||
|
* is looking at it.
|
||||||
|
*/
|
||||||
|
private fun formatCheckTime(epochSeconds: Double): String =
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// A time that cannot be read is not a time to show: the sentence above still says a check
|
||||||
|
// is coming, which is the part the reader can act on.
|
||||||
|
"soon"
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a session's status is called on screen, and what colour it is drawn in.
|
||||||
|
*
|
||||||
|
* One pair of functions rather than a branch on each screen that shows a status. There were two,
|
||||||
|
* and the second silently fell short the moment the server grew a state: `waiting` arrived and the
|
||||||
|
* session list learned the word and the colour while the session screen's status row printed the
|
||||||
|
* wire's own word in the muted grey every quiet state uses. That comment already said the words
|
||||||
|
* were "the session list's own"; this is what makes that true rather than a promise.
|
||||||
|
*
|
||||||
|
* A subagent's own three states are deliberately not here -- see `subagentStatusLabel`, which
|
||||||
|
* collapses everything it does not recognise rather than passing it through, because a subagent has
|
||||||
|
* fewer states than a session and reporting one it cannot have is worse than reporting none.
|
||||||
|
*/
|
||||||
|
fun sessionStatusWord(status: String, subagent: Boolean = false): String =
|
||||||
|
when (status) {
|
||||||
|
"idle" -> "idle"
|
||||||
|
"running" -> "running"
|
||||||
|
"compacting" -> "compacting"
|
||||||
|
// Not "running": a model coming off disk is not a model answering, and the difference is
|
||||||
|
// minutes. Said in its own word so a first message that waits is explained rather than
|
||||||
|
// looking like a session that has stopped responding. See `SessionStatus::Loading`.
|
||||||
|
//
|
||||||
|
// "model" rather than "loading" alone, because there are two waits before an answer and
|
||||||
|
// the reader is entitled to know which one they are in: this one happens once, and
|
||||||
|
// "reading prompt" below happens on every turn.
|
||||||
|
"loading" -> "loading model"
|
||||||
|
// The model has the prompt and has not started answering. Its own word for the same
|
||||||
|
// reason: a long conversation spends real time here, and reported as "running" it looked
|
||||||
|
// like a model thinking. See `SessionStatus::Reading`.
|
||||||
|
"reading" -> "reading prompt"
|
||||||
|
// Its own word, because the state it is easily mistaken for means the opposite: "idle"
|
||||||
|
// invites the reader to type something, and a waiting session is going to carry on without
|
||||||
|
// them. See `SessionStatus::Waiting`.
|
||||||
|
"waiting" -> "waiting"
|
||||||
|
"awaitingInput" -> "your turn"
|
||||||
|
// A subagent's process was always its parent's, so it had none of its own to merely stop.
|
||||||
|
"exited" -> if (subagent) "finished" else "exited"
|
||||||
|
// Said in words, because it differs in kind from the others rather than in degree: the
|
||||||
|
// session is not idle and has not exited, nobody has been able to find out which. A muted
|
||||||
|
// colour alone would read as one of the quiet states.
|
||||||
|
"unknown" -> "can't tell"
|
||||||
|
// A state this build has never heard of, said as itself. The nearest word we do know would
|
||||||
|
// read as a fact somebody established.
|
||||||
|
else -> status
|
||||||
|
}
|
||||||
|
|
||||||
|
fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
|
||||||
|
* to do something or want something, and every quiet one shares the muted colour.
|
||||||
|
*
|
||||||
|
* Stated beside whatever draws it rather than inherited -- a colour that carries meaning has to
|
||||||
|
* carry its own contrast, since the surface under it will not change to rescue it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun sessionStatusColour(status: String): Color =
|
||||||
|
when (status) {
|
||||||
|
"awaitingInput" -> awaitingColor
|
||||||
|
"running" -> runningColor
|
||||||
|
"compacting" -> commandColor
|
||||||
|
// The same accent as the other states that are busy on their own account, because that is
|
||||||
|
// what this is: something is happening and nothing is wanted from the reader.
|
||||||
|
"loading",
|
||||||
|
"reading" -> commandColor
|
||||||
|
"waiting" -> waitingColor
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -70,13 +72,22 @@ class UsageFeed(
|
|||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||||
val refresh: () -> Unit,
|
val refresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
/**
|
||||||
fun forSetup(setup: String): SessionUsage =
|
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
||||||
when (val state = snapshots) {
|
*
|
||||||
|
* A session rather than a machine, because a machine is not what is metered: one machine runs
|
||||||
|
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
|
||||||
|
*/
|
||||||
|
fun forSession(session: SessionSummary): SessionUsage {
|
||||||
|
// Settled without asking anybody: a session nothing meters has nothing to check, and
|
||||||
|
// "checking" is what the fetch's own states would say about it for as long as one is out.
|
||||||
|
val provider = session.usageProvider ?: return SessionUsage.NotMetered
|
||||||
|
return when (val state = snapshots) {
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,8 +127,8 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
|||||||
*
|
*
|
||||||
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
||||||
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
||||||
* Taken over however many windows came back rather than the three Claude sends today -- the backend
|
* Taken over however many windows this session's provider returned rather than the three Claude
|
||||||
* passes windows it does not recognise straight through.
|
* sends today -- the backend passes windows it does not recognise straight through.
|
||||||
*
|
*
|
||||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
||||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
||||||
@@ -133,7 +144,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The five-hour window for the machine this session runs on, under the session's own header.
|
* The shortest usage window for the pool this session uses, under the session's own header.
|
||||||
*
|
*
|
||||||
* Here rather than only in the usage dialog because it is the number that decides whether to keep
|
* Here rather than only in the usage dialog because it is the number that decides whether to keep
|
||||||
* going, and it was a screen away from the place that decision gets made. It reports on this
|
* going, and it was a screen away from the place that decision gets made. It reports on this
|
||||||
@@ -150,17 +161,17 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||||
// would sit at a stale figure for hours.
|
// would sit at a stale figure for hours.
|
||||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
val now = rememberUsageNow()
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
now = OffsetDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||||
// problem about a setup somebody chose, on every screen, forever.
|
// a problem about a machine somebody chose, on every screen, forever.
|
||||||
if (usage is SessionUsage.NotMetered) {
|
//
|
||||||
|
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
||||||
|
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||||
|
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
|
||||||
|
// second and was then taken away. A row that has to be withdrawn is worse than one that
|
||||||
|
// arrives late.
|
||||||
|
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,24 +182,18 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
||||||
// answer from "this much is used", and only words carry a difference in kind.
|
// answer from "this much is used", and only words carry a difference in kind.
|
||||||
when (val state = usage) {
|
when (val state = usage) {
|
||||||
SessionUsage.NotMetered -> Unit
|
// Both handled above, before the row exists at all.
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
SessionUsage.NotMetered,
|
||||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
SessionUsage.Waiting -> Unit
|
||||||
|
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = shortestUsageWindow(state.windows)
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
UsageNote("Usage unknown -- no window duration was reported")
|
||||||
} else {
|
} else {
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.weight(1f))
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
||||||
// The same step at the same percentages as the dialog's bars: this is the
|
|
||||||
// same measurement, and a reader who learned the colour there has to be
|
|
||||||
// able to read it here without checking which screen they are on.
|
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
fiveHourLabel(window, now),
|
usageWindowLabel(window, now),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
@@ -199,6 +204,56 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberUsageNow(): OffsetDateTime {
|
||||||
|
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(REFRESH_MS)
|
||||||
|
now = OffsetDateTime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quota fill with a white tick showing how far the current time window has progressed. */
|
||||||
|
@Composable
|
||||||
|
internal fun UsageProgressIndicator(
|
||||||
|
window: UsageWindow,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val elapsed = usageWindowElapsedFraction(window, now)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||||
|
// The same step at the same percentages everywhere: this is the same measurement, and a
|
||||||
|
// reader who learned the colour on one surface should not have to relearn it on another.
|
||||||
|
color = quotaColor(window.percent),
|
||||||
|
modifier =
|
||||||
|
modifier.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
elapsed?.let { fraction ->
|
||||||
|
drawLine(
|
||||||
|
color = Color.White,
|
||||||
|
start = Offset(size.width * fraction, 0f),
|
||||||
|
end = Offset(size.width * fraction, size.height),
|
||||||
|
strokeWidth = 2.dp.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
|
||||||
|
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
|
||||||
|
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
|
||||||
|
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
|
||||||
|
val remainingMinutes =
|
||||||
|
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
|
||||||
|
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageNote(text: String) {
|
private fun UsageNote(text: String) {
|
||||||
@@ -210,42 +265,103 @@ private fun UsageNote(text: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
|
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how
|
||||||
|
* long the whole window is.
|
||||||
*
|
*
|
||||||
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
||||||
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
||||||
*
|
*
|
||||||
|
* The window's *length* is what the provider's own name for it used to carry ("5-hour window"), and
|
||||||
|
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
|
||||||
|
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
|
||||||
|
* reported no duration there is simply nothing after the span -- the name it gave is not a
|
||||||
|
* measurement of one, so nothing is inferred from it.
|
||||||
|
*
|
||||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
||||||
* that is not running gets the percentage and nothing else.
|
* that is not running gets the percentage and nothing else.
|
||||||
*/
|
*/
|
||||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||||
val percent = "${window.percent.toInt()}%"
|
val percent = "${window.percent.toInt()}%"
|
||||||
|
val outOf =
|
||||||
|
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
|
||||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
return when (val end = windowEnd(window.resetsAt, now)) {
|
||||||
// Between blocks the five-hour window has no reset time, and saying so is a fact about
|
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
|
||||||
// nothing: there is no window to run out. The percentage is the whole answer.
|
// there is no window to run out. The percentage is the whole answer.
|
||||||
WindowEnd.NotRunning -> percent
|
WindowEnd.NotRunning -> percent
|
||||||
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
is WindowEnd.Ends ->
|
||||||
// Under a minute, including past the end: the number would round to "0m left", which
|
// Under a minute, including past the end: the number would round to "0m left", which
|
||||||
// reads as a measurement rather than as the window having run out.
|
// reads as a measurement rather than as the window having run out.
|
||||||
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
||||||
else "$percent · ${formatSpan(end.until)} left"
|
else "$percent · ${formatSpan(end.until)} left$outOf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One machine's snapshot, out of every machine's.
|
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
|
||||||
|
*
|
||||||
|
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
||||||
|
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||||
|
* service on one machine.
|
||||||
*
|
*
|
||||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||||
* machine having no quota rather than the question going unanswered.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
fun usageFor(
|
||||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
snapshots: List<UsageSnapshot>,
|
||||||
// nothing metered on it. That is a different answer from having asked and failed.
|
machine: String,
|
||||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
provider: String,
|
||||||
|
model: String?,
|
||||||
|
): SessionUsage {
|
||||||
|
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||||
|
// to ask about. That is a different answer from having asked and failed.
|
||||||
|
val pools = usageSnapshotsFor(snapshots, machine, provider)
|
||||||
|
if (pools.isEmpty()) return SessionUsage.NotMetered
|
||||||
|
val mine =
|
||||||
|
usagePoolFor(pools, model)
|
||||||
|
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
||||||
if (mine.state != "ok") {
|
if (mine.state != "ok") {
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
val why =
|
||||||
|
mine.detail
|
||||||
|
?: when (mine.state) {
|
||||||
|
"notLoggedIn" -> "no Claude account is signed in on this machine"
|
||||||
|
"authenticating" -> "Claude sign-in is in progress"
|
||||||
|
"loginRequired" -> "Claude sign-in is required"
|
||||||
|
else -> mine.state
|
||||||
|
}
|
||||||
|
return SessionUsage.Unavailable(why)
|
||||||
}
|
}
|
||||||
return SessionUsage.Known(mine.windows)
|
return SessionUsage.Known(mine.windows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every billing pool reported for one provider on one machine. */
|
||||||
|
internal fun usageSnapshotsFor(
|
||||||
|
snapshots: List<UsageSnapshot>,
|
||||||
|
machine: String,
|
||||||
|
provider: String?,
|
||||||
|
): List<UsageSnapshot> =
|
||||||
|
if (provider == null) emptyList()
|
||||||
|
else snapshots.filter { it.machine == machine && it.provider == provider }
|
||||||
|
|
||||||
|
/** The pool an explicit model names, or the provider's generic pool for every other model. */
|
||||||
|
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
|
||||||
|
if (pools.size == 1) return pools.first()
|
||||||
|
val normalizedModel = model?.normalizedPoolName()
|
||||||
|
val named = normalizedModel?.let { wanted ->
|
||||||
|
pools.firstOrNull { pool ->
|
||||||
|
val name = pool.limitName?.normalizedPoolName()
|
||||||
|
name == wanted || (wanted.contains("luna") && name == "gptreserve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return named ?: pools.firstOrNull { it.limitId == "codex" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortest cycle the selected pool actually reported. */
|
||||||
|
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
|
||||||
|
windows
|
||||||
|
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
|
||||||
|
.minByOrNull { it.first }
|
||||||
|
?.second
|
||||||
|
|
||||||
|
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
|
||||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -125,28 +124,14 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
LabelledField(label = "Host", value = host, onValueChange = { host = it })
|
||||||
value = host,
|
|
||||||
onValueChange = { host = it },
|
|
||||||
label = { Text("Host") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
LabelledField(label = "Port", value = port, onValueChange = { port = it })
|
||||||
value = port,
|
|
||||||
onValueChange = { port = it },
|
|
||||||
label = { Text("Port") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
LabelledField(
|
||||||
|
label = if (existing != null) "Token (unchanged if left blank)" else "Token",
|
||||||
value = token,
|
value = token,
|
||||||
onValueChange = { token = it },
|
onValueChange = { token = it },
|
||||||
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val OPEN_THRESHOLD = 0.35f
|
||||||
|
private val FLING_THRESHOLD = 400.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the screen a panel takes by default, leaving a sliver of what it is over.
|
||||||
|
*
|
||||||
|
* A panel given the whole width instead is standing in for the screen rather than sitting over it,
|
||||||
|
* and then the sliver would be a strip of a screen the reader has just left behind.
|
||||||
|
*/
|
||||||
|
const val PANEL_FRACTION = 0.88f
|
||||||
|
|
||||||
|
/** How dark the scrim over [SidePanels]' content goes with a panel fully open. */
|
||||||
|
private const val SCRIM_ALPHA = 0.32f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which side of the content a panel comes in from: where it sits, and which way it slides out.
|
||||||
|
*
|
||||||
|
* [sign] is also the direction of the reveal this side owns, so the drag arithmetic is written once
|
||||||
|
* rather than once per side with the minus signs moved around.
|
||||||
|
*/
|
||||||
|
enum class PanelSide(val alignment: Alignment, val sign: Float) {
|
||||||
|
Left(Alignment.CenterStart, -1f),
|
||||||
|
Right(Alignment.CenterEnd, 1f),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps [content] composed while a panel belonging to it moves over from the left or the right.
|
||||||
|
*
|
||||||
|
* One gesture drives both sides rather than one handler each, because two `draggable`s over the
|
||||||
|
* same content cannot share a horizontal drag: the inner one claims it whichever way the finger
|
||||||
|
* went, and the outer never sees a thing. So the position is a single signed reveal -- negative is
|
||||||
|
* the left panel showing, positive the right -- which also makes it impossible to have both open.
|
||||||
|
*
|
||||||
|
* A side left null has no panel and no gesture toward it -- the reveal cannot travel that way at
|
||||||
|
* all -- so one composable serves a screen with one panel and a screen with two.
|
||||||
|
*
|
||||||
|
* The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its
|
||||||
|
* drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing
|
||||||
|
* that content, or starting over any ordinary part of the session, gives the gesture back to the
|
||||||
|
* panel; Android's own right-edge Back gesture remains untouched.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SidePanels(
|
||||||
|
left: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
|
||||||
|
leftFraction: Float = PANEL_FRACTION,
|
||||||
|
right: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
|
||||||
|
rightFraction: Float = PANEL_FRACTION,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Which panel the gesture settled on, null for neither. The *settled* side rather than the
|
||||||
|
// current position, so a panel's contents know they are being looked at while the animation
|
||||||
|
// is still running.
|
||||||
|
var opened by remember { mutableStateOf<PanelSide?>(null) }
|
||||||
|
var dragging by remember { mutableStateOf(false) }
|
||||||
|
var draggedReveal by remember { mutableFloatStateOf(0f) }
|
||||||
|
val animatedReveal = remember { Animatable(0f) }
|
||||||
|
// Read from a draw or layout lambda, never from the composable body: where the panel has got
|
||||||
|
// to changes every frame of a drag, and a body that reads it recomposes this whole subtree --
|
||||||
|
// the session included -- once per frame. The booleans below are what composition is allowed
|
||||||
|
// to know, and each of them changes twice per gesture. (Same rule as the keyboard inset in
|
||||||
|
// SessionScreen, and found the same way.)
|
||||||
|
fun revealNow() = if (dragging) draggedReveal else animatedReveal.value
|
||||||
|
val leftShown by remember { derivedStateOf { revealNow() < 0f } }
|
||||||
|
val rightShown by remember { derivedStateOf { revealNow() > 0f } }
|
||||||
|
val engaged = leftShown || rightShown
|
||||||
|
val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() }
|
||||||
|
|
||||||
|
suspend fun startDrag() {
|
||||||
|
animatedReveal.stop()
|
||||||
|
draggedReveal = animatedReveal.value
|
||||||
|
dragging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which panel the reveal belongs to, [bias] breaking the tie at rest -- a drag away from
|
||||||
|
// nothing is toward whichever panel that direction opens.
|
||||||
|
fun sideOf(bias: Float): PanelSide? =
|
||||||
|
when {
|
||||||
|
draggedReveal < 0f -> PanelSide.Left
|
||||||
|
draggedReveal > 0f -> PanelSide.Right
|
||||||
|
bias > 0f -> PanelSide.Left
|
||||||
|
bias < 0f -> PanelSide.Right
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun finishDrag(velocity: Float) {
|
||||||
|
val side = sideOf(0f)
|
||||||
|
// How fast the finger is moving toward that side's open position: the left panel opens
|
||||||
|
// rightwards and the right panel leftwards, so the sign of a velocity only means something
|
||||||
|
// once it is read against the side. A fling decides on its own; anything slower is decided
|
||||||
|
// by how far in the panel already is.
|
||||||
|
val toward = side?.let { -it.sign * velocity } ?: 0f
|
||||||
|
val opens =
|
||||||
|
if (toward.absoluteValue > flingThreshold) toward > 0f
|
||||||
|
else draggedReveal.absoluteValue >= OPEN_THRESHOLD
|
||||||
|
val target = side.takeIf { opens }
|
||||||
|
opened = target
|
||||||
|
animatedReveal.snapTo(draggedReveal)
|
||||||
|
dragging = false
|
||||||
|
animatedReveal.animateTo(target?.sign ?: 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
opened = null
|
||||||
|
scope.launch { animatedReveal.animateTo(0f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = opened != null) { close() }
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val dragState = rememberDraggableState { delta ->
|
||||||
|
// Against the width of the panel this drag is moving, since the reveal is a fraction
|
||||||
|
// of it and the two sides need not be the same width.
|
||||||
|
val width =
|
||||||
|
sideOf(delta)?.let {
|
||||||
|
constraints.maxWidth * if (it == PanelSide.Left) leftFraction else rightFraction
|
||||||
|
} ?: return@rememberDraggableState
|
||||||
|
draggedReveal =
|
||||||
|
(draggedReveal - delta / width.coerceAtLeast(1f)).coerceIn(
|
||||||
|
if (left == null) 0f else -1f,
|
||||||
|
if (right == null) 0f else 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val drag =
|
||||||
|
Modifier.draggable(
|
||||||
|
state = dragState,
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
onDragStarted = { startDrag() },
|
||||||
|
onDragStopped = { velocity -> finishDrag(velocity) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.then(drag)
|
||||||
|
.then(if (engaged) Modifier.clearAndSetSemantics {} else Modifier)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engaged) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.graphicsLayer { alpha = revealNow().absoluteValue * SCRIM_ALPHA }
|
||||||
|
.background(MaterialTheme.colorScheme.scrim)
|
||||||
|
.semantics { contentDescription = "Dismiss panel" }
|
||||||
|
.clickable { close() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both panels stay composed while they are off screen, so opening one costs no
|
||||||
|
// composition -- but an off-screen panel is cleared from the semantics tree, since nothing
|
||||||
|
// a reader cannot see should be reachable by swiping through the screen.
|
||||||
|
left?.let { panel ->
|
||||||
|
SlidingPanel(
|
||||||
|
side = PanelSide.Left,
|
||||||
|
width = maxWidth * leftFraction,
|
||||||
|
raised = leftFraction < 1f,
|
||||||
|
shown = { (-revealNow()).coerceAtLeast(0f) },
|
||||||
|
visible = leftShown,
|
||||||
|
drag = drag,
|
||||||
|
) {
|
||||||
|
panel(opened == PanelSide.Left, ::close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
right?.let { panel ->
|
||||||
|
SlidingPanel(
|
||||||
|
side = PanelSide.Right,
|
||||||
|
width = maxWidth * rightFraction,
|
||||||
|
raised = rightFraction < 1f,
|
||||||
|
shown = { revealNow().coerceAtLeast(0f) },
|
||||||
|
visible = rightShown,
|
||||||
|
drag = drag,
|
||||||
|
) {
|
||||||
|
panel(opened == PanelSide.Right, ::close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One panel at [shown] of the way in, sliding out to its own [side].
|
||||||
|
*
|
||||||
|
* [raised] is for a panel with some of the screen still beside it, which takes a tonal step to say
|
||||||
|
* it is above what it has not covered. A panel covering the whole width has nothing to be above,
|
||||||
|
* and a step there is a screen that is simply the wrong colour.
|
||||||
|
*
|
||||||
|
* [visible] says the same thing as `shown() > 0f` and is the form composition may read; see
|
||||||
|
* [SidePanels].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.SlidingPanel(
|
||||||
|
side: PanelSide,
|
||||||
|
width: Dp,
|
||||||
|
raised: Boolean,
|
||||||
|
shown: () -> Float,
|
||||||
|
visible: Boolean,
|
||||||
|
drag: Modifier,
|
||||||
|
contents: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = if (raised) 3.dp else 0.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier =
|
||||||
|
Modifier.align(side.alignment)
|
||||||
|
.width(width)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.graphicsLayer { translationX = side.sign * size.width * (1f - shown()) }
|
||||||
|
.then(if (visible) Modifier else Modifier.clearAndSetSemantics {})
|
||||||
|
.then(drag),
|
||||||
|
) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -48,44 +47,52 @@ fun SpawnScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
||||||
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
||||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||||
|
|
||||||
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
|
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
|
||||||
// provider is stored by name and resolved against the current setup rather than held as an
|
// the
|
||||||
|
// provider is stored by name and resolved against the current machine rather than held as an
|
||||||
// object that could outlive the list it came from.
|
// object that could outlive the list it came from.
|
||||||
var setupName by remember { mutableStateOf<String?>(null) }
|
var machineName by remember { mutableStateOf<String?>(null) }
|
||||||
var providerName by remember { mutableStateOf<String?>(null) }
|
var providerName by remember { mutableStateOf<String?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var model by remember { mutableStateOf("") }
|
var model by remember { mutableStateOf("") }
|
||||||
|
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
|
||||||
|
var providerModelsLoading by remember { mutableStateOf(false) }
|
||||||
|
var providerModelsError by remember { mutableStateOf<String?>(null) }
|
||||||
var cwd by remember { mutableStateOf("") }
|
var cwd by remember { mutableStateOf("") }
|
||||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
// Set only after the selected provider reports its own default. An empty value is not sent.
|
||||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
var permissionMode by remember { mutableStateOf("") }
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
||||||
|
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
|
||||||
|
// arrives is one you can spawn at without having chosen it.
|
||||||
|
var effort by remember { mutableStateOf<String?>(null) }
|
||||||
|
var defaultsAsked by remember { mutableStateOf(false) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||||
// form worth keeping, and that one leaves nothing to fill in.
|
// form worth keeping, and that one leaves nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||||
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
|
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an
|
||||||
// Claude session needs none, so failing to list them must not stop the screen rendering.
|
// absent key means the server's own default, which is what every field's placeholder says.
|
||||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
|
||||||
var contextSize by remember { mutableStateOf("") }
|
|
||||||
var temperature by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
|
||||||
|
// default must leave a screen you can still spawn from, so the picker stays on "default"
|
||||||
|
// and says so rather than the whole form refusing to draw.
|
||||||
|
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
||||||
|
.onSuccess { effort = it }
|
||||||
|
defaultsAsked = true
|
||||||
options =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||||
val first = fetched.firstOrNull()
|
val first = fetched.firstOrNull()
|
||||||
setupName = first?.name
|
machineName = first?.name
|
||||||
providerName = first?.providers?.firstOrNull()?.name
|
providerName = first?.providers?.firstOrNull()?.name
|
||||||
LoadState.Loaded(fetched)
|
LoadState.Loaded(fetched)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
@@ -102,7 +109,7 @@ fun SpawnScreen(
|
|||||||
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
||||||
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
||||||
// under an error message.
|
// under an error message.
|
||||||
val setups =
|
val machines =
|
||||||
when (val state = options) {
|
when (val state = options) {
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
@@ -114,51 +121,88 @@ fun SpawnScreen(
|
|||||||
}
|
}
|
||||||
is LoadState.Loaded -> state.value
|
is LoadState.Loaded -> state.value
|
||||||
}
|
}
|
||||||
val setup = setups.firstOrNull { it.name == setupName }
|
val machine = machines.firstOrNull { it.name == machineName }
|
||||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
||||||
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
||||||
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
// the extra fields on the kind rather than the provider name keeps a second installation
|
||||||
// from needing anything here.
|
// from needing anything here.
|
||||||
val isClaude = current?.kind == "claude_cli"
|
val isClaude = current?.kind == "claude_cli"
|
||||||
|
val isCodex = current?.kind == "codex_cli"
|
||||||
|
val isCodingCli = isClaude || isCodex
|
||||||
val isLlama = current?.kind == "llama_cpp"
|
val isLlama = current?.kind == "llama_cpp"
|
||||||
|
// Echo is the only kind with nothing to choose between.
|
||||||
|
val offersModels = isCodingCli || isLlama
|
||||||
|
// Where a session's tools act, which is the only thing a working directory decides.
|
||||||
|
val takesCwd = isCodingCli || isLlama
|
||||||
|
|
||||||
|
// Whichever machine and provider are chosen now, asked again when either changes. The
|
||||||
|
// previous answer is dropped first rather than left on screen: a model name from another
|
||||||
|
// machine looks exactly like one from this one.
|
||||||
|
LaunchedEffect(machine?.id, current?.name) {
|
||||||
|
model = ""
|
||||||
|
// A key from the previous provider would be a setting this one does not have, drawn
|
||||||
|
// by no control and sent at the spawn anyway.
|
||||||
|
params = emptyMap()
|
||||||
|
providerModels = emptyList()
|
||||||
|
providerModelsError = null
|
||||||
|
permissionMode = current?.defaultPermissionMode.orEmpty()
|
||||||
|
// Every kind that offers models at all, not only the coding CLIs: a llama provider
|
||||||
|
// answers with the GGUFs on the machine it runs on, through the same call. One
|
||||||
|
// question with one answer is what keeps the picker free of a branch on the kind.
|
||||||
|
if (machine == null || current == null || !offersModels) {
|
||||||
|
providerModelsLoading = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
providerModelsLoading = true
|
||||||
|
try {
|
||||||
|
providerModels =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
fetchProviderModels(settings, machine.id, current.name)
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
providerModelsError = e.message
|
||||||
|
} finally {
|
||||||
|
providerModelsLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The machine first, because it decides what can be run at all.
|
// The machine first, because it decides what can be run at all.
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Setup",
|
label = "Machine",
|
||||||
options = setups.map { it.name },
|
options = machines.map { it.name },
|
||||||
selected = setupName,
|
selected = machineName,
|
||||||
onSelect = { name ->
|
onSelect = { name ->
|
||||||
setupName = name
|
machineName = name
|
||||||
// The provider list changes with the machine, so a name carried over from the
|
// The provider list changes with the machine, so a name carried over from the
|
||||||
// previous one would be a selection that isn't in the picker. Take that machine's
|
// previous one would be a selection that isn't in the picker. Take that machine's
|
||||||
// first.
|
// first.
|
||||||
providerName =
|
providerName =
|
||||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
setup?.address?.let {
|
machine?.address?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
// The address belongs to the setup above it, not to the provider label below; without
|
// The address belongs to the machine above it, not to the provider label below; without
|
||||||
// this they read as one block.
|
// this they read as one block.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only what this machine actually has. A setup with none says so rather than showing an
|
// Only what this machine actually has. A machine with none says so rather than showing an
|
||||||
// empty row that reads as a failure.
|
// empty row that reads as a failure.
|
||||||
if (setup != null && setup.providers.isEmpty()) {
|
if (machine != null && machine.providers.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"\"${setup.name}\" has no providers configured.",
|
"\"${machine.name}\" has no providers configured.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Provider",
|
label = "Provider",
|
||||||
options = setup?.providers?.map { it.name }.orEmpty(),
|
options = machine?.providers?.map { it.name }.orEmpty(),
|
||||||
selected = providerName,
|
selected = providerName,
|
||||||
onSelect = { providerName = it },
|
onSelect = { providerName = it },
|
||||||
)
|
)
|
||||||
@@ -166,91 +210,115 @@ fun SpawnScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
LabelledField(label = "Title", value = title, onValueChange = { title = it })
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
label = { Text("Title") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLlama) {
|
if (offersModels) {
|
||||||
// A llama session names one of the models this backend has downloaded, so the choice is
|
when {
|
||||||
// that list rather than free text -- a name that is not on disk is a session that
|
providerModelsLoading ->
|
||||||
// cannot start.
|
Text(
|
||||||
if (models.isEmpty()) {
|
"Loading model choices…",
|
||||||
Text(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
"No models downloaded yet. Get one from the Models screen first.",
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
providerModelsError != null ->
|
||||||
)
|
Text(
|
||||||
} else {
|
"Model choices unavailable: $providerModelsError",
|
||||||
ChipGroup(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
label = "Model",
|
color = MaterialTheme.colorScheme.error,
|
||||||
// The file, not the whole key: the repository is the same for every
|
)
|
||||||
// quantisation of a model, so the file name is what tells two of them apart.
|
// A llama session cannot start without one, so this says what to do about it
|
||||||
options = models.map { it.file },
|
// rather than only that there is nothing -- the models it needs are on the
|
||||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
// machine that will serve them, which is not always this backend.
|
||||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
providerModels.isEmpty() && isLlama ->
|
||||||
)
|
Text(
|
||||||
|
"No models on ${machine.name}. The Models screen downloads " +
|
||||||
|
"to the backend; another machine needs the file put there itself.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
providerModels.isEmpty() ->
|
||||||
|
Text(
|
||||||
|
"This machine reported no selectable models.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
ChipGroup(
|
||||||
|
label = "Model",
|
||||||
|
// The label, and the id is what is sent: for a llama model those differ,
|
||||||
|
// since it is chosen by path and named by what is inside the file.
|
||||||
|
options = providerModels.map { it.label },
|
||||||
|
selected = providerModels.firstOrNull { it.id == model }?.label,
|
||||||
|
onSelect = { chosen ->
|
||||||
|
val id = providerModels.first { it.label == chosen }.id
|
||||||
|
// A llama session has to have one, so choosing the same chip twice
|
||||||
|
// must not clear it -- there is nothing to fall back to.
|
||||||
|
model = if (model == id && !isLlama) "" else id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
if (isCodingCli) {
|
||||||
value = contextSize,
|
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will
|
||||||
onValueChange = { contextSize = it },
|
// take a name it did not list.
|
||||||
label = { Text("Context size (blank = the model's default)") },
|
LabelledField(
|
||||||
singleLine = true,
|
label = "Model",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = model,
|
||||||
)
|
onValueChange = { model = it },
|
||||||
Spacer(Modifier.height(16.dp))
|
hint = "the CLI's default",
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = temperature,
|
|
||||||
onValueChange = { temperature = it },
|
|
||||||
label = { Text("Temperature (blank = llama.cpp's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isClaude) {
|
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
|
||||||
if (current.models.isNotEmpty()) {
|
// read by the process this form is about to start.
|
||||||
Spacer(Modifier.height(16.dp))
|
ProviderParamFields(
|
||||||
ChipGroup(
|
specs = current?.params.orEmpty(),
|
||||||
label = "Model",
|
values = params,
|
||||||
options = current.models,
|
onChange = { params = it },
|
||||||
selected = model.ifEmpty { null },
|
warnAboutRestart = false,
|
||||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model,
|
|
||||||
onValueChange = { model = it },
|
|
||||||
label = { Text("Model (blank = the CLI's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
// Every session whose tools act on files needs one, which is both kinds that have
|
||||||
|
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
|
||||||
|
if (takesCwd) {
|
||||||
|
LabelledField(
|
||||||
|
label = "Working directory",
|
||||||
value = cwd,
|
value = cwd,
|
||||||
onValueChange = { cwd = it },
|
onValueChange = { cwd = it },
|
||||||
label = { Text("Working directory") },
|
hint = "wherever the session's process starts",
|
||||||
placeholder = { Text("/home/…") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offered wherever the provider has modes, rather than where this screen believes it
|
||||||
|
// does: the server is what knows, and llama.cpp grew them without this line changing.
|
||||||
|
if (current != null && current.permissionModes.isNotEmpty()) {
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Permissions",
|
label = "Permissions",
|
||||||
options = PERMISSION_MODES,
|
options = current.permissionModes,
|
||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
onSelect = { permissionMode = it },
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCodingCli) {
|
||||||
|
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
||||||
|
// is stored as the default, which is the whole way that default is set. A picker that
|
||||||
|
// quietly changed a global would be the same control with the fact left out.
|
||||||
|
ChipGroup(
|
||||||
|
label = "Thinking (kept as the default for new sessions)",
|
||||||
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||||
|
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
|
||||||
|
// Disabled-looking until the server has answered, for the reason above.
|
||||||
|
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
|
||||||
|
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -268,33 +336,29 @@ fun SpawnScreen(
|
|||||||
try {
|
try {
|
||||||
val spawned =
|
val spawned =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
// Stored before the spawn and not after it: choosing a level is
|
||||||
|
// an intent about new sessions in general, so a spawn that then
|
||||||
|
// fails must not also lose the choice. Non-fatal for the same
|
||||||
|
// reason the fetch above is -- the session is what was asked for.
|
||||||
|
if (isCodingCli) {
|
||||||
|
runCatching { setDefaultEffort(settings, effort) }
|
||||||
|
}
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
// The id, not the label: labels are editable and the server
|
// The id, not the label: labels are editable and the server
|
||||||
// resolves by id. Non-null here, since `chosen` came from
|
// resolves by id. Non-null here, since `chosen` came from
|
||||||
// `setup`'s own provider list.
|
// `machine`'s own provider list.
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
provider = chosen.name,
|
provider = chosen.name,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
model =
|
model = model.trim().takeIf { offersModels },
|
||||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { takesCwd },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
effort = effort.takeIf { isCodingCli },
|
||||||
// Sent only when set, so blank means "whatever llama.cpp does
|
// Already only the keys somebody set: a field left blank
|
||||||
// by default" rather than a zero.
|
// removes its key rather than sending an empty value, so
|
||||||
params =
|
// "blank" reaches the server as "your default".
|
||||||
buildMap {
|
params = params,
|
||||||
if (isLlama) {
|
|
||||||
contextSize
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("contextSize", it) }
|
|
||||||
temperature
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("temperature", it) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onSpawned(spawned)
|
onSpawned(spawned)
|
||||||
@@ -304,7 +368,8 @@ fun SpawnScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !busy && current != null && !(isLlama && modelKey == null),
|
// A llama session names the file to load, so there is nothing to spawn without one.
|
||||||
|
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
|
||||||
) {
|
) {
|
||||||
Text(if (busy) "Spawning..." else "Spawn")
|
Text(if (busy) "Spawning..." else "Spawn")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a session has running beside the turn you are reading: its background tasks, then its
|
||||||
|
* subagents, in the panel [SidePanels] slides over it from the right.
|
||||||
|
*
|
||||||
|
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
|
||||||
|
* composition, since the panel is composed for every session whether or not anybody opens it.
|
||||||
|
*
|
||||||
|
* [onOpenCall] takes the reader to where a background task was started, in the transcript under
|
||||||
|
* this panel -- so the panel is closed with it, which is the caller's to do.
|
||||||
|
*
|
||||||
|
* [backgroundTasks] is the live count from the session's own event stream, and is what the
|
||||||
|
* background list is refetched against: a card for work that has since finished is a stale
|
||||||
|
* measurement drawn as a current one, which is the one thing a list of what is running now must not
|
||||||
|
* do.
|
||||||
|
*
|
||||||
|
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
|
||||||
|
* background section pushes the subagents down without either being able to run off the panel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SubagentPanel(
|
||||||
|
settings: ServerSettings,
|
||||||
|
summary: SessionSummary,
|
||||||
|
active: Boolean,
|
||||||
|
backgroundTasks: Int,
|
||||||
|
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||||
|
onOpenCall: (CallSite) -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
|
var rows by
|
||||||
|
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
|
||||||
|
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
||||||
|
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
||||||
|
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
|
||||||
|
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
|
||||||
|
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
|
||||||
|
var background by
|
||||||
|
remember(summary.id) {
|
||||||
|
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
|
||||||
|
}
|
||||||
|
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(active, refreshToken) {
|
||||||
|
if (!active) return@LaunchedEffect
|
||||||
|
rows = LoadState.Loading
|
||||||
|
rows =
|
||||||
|
try {
|
||||||
|
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
|
||||||
|
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
|
||||||
|
LoadState.Loaded(fetched)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
|
||||||
|
// over the list at every start and end would blink precisely when something happened.
|
||||||
|
LaunchedEffect(active, backgroundTasks, refreshToken) {
|
||||||
|
if (!active || backgroundTasks == 0) return@LaunchedEffect
|
||||||
|
background =
|
||||||
|
try {
|
||||||
|
LoadState.Loaded(
|
||||||
|
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
|
||||||
|
)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||||
|
|
||||||
|
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
backgroundTaskSection(
|
||||||
|
count = backgroundTasks,
|
||||||
|
tasks = background,
|
||||||
|
expanded = backgroundExpanded,
|
||||||
|
onToggle = { backgroundExpanded = !backgroundExpanded },
|
||||||
|
onRetry = { refreshToken++ },
|
||||||
|
onOpenCall = onOpenCall,
|
||||||
|
)
|
||||||
|
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
|
||||||
|
when (val state = rows) {
|
||||||
|
is LoadState.Loading ->
|
||||||
|
item(key = "subagents-loading") {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||||
|
}
|
||||||
|
is LoadState.Error ->
|
||||||
|
item(key = "subagents-error") {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
if (ordered.isNullOrEmpty()) {
|
||||||
|
item(key = "subagents-empty") {
|
||||||
|
Text(
|
||||||
|
"No subagents in this session.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uniqueItems(ordered, key = { it.id }) { subagent ->
|
||||||
|
SubagentCard(
|
||||||
|
subagent = subagent,
|
||||||
|
selected = subagent.id in selected,
|
||||||
|
selecting = selected.isNotEmpty(),
|
||||||
|
deleting = subagent.id in deleting,
|
||||||
|
onClick = { onOpenSubagent(subagent) },
|
||||||
|
onSelect = {
|
||||||
|
selected =
|
||||||
|
if (subagent.id in selected) selected - subagent.id
|
||||||
|
else selected + subagent.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isNotEmpty()) {
|
||||||
|
val picked = ordered.orEmpty().filter { it.id in selected }
|
||||||
|
SubagentSelectionBar(
|
||||||
|
picked = picked,
|
||||||
|
onDelete = { confirming = picked },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deleteError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirming?.let { picked ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirming = null },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
if (picked.size == 1) "Delete this subagent?"
|
||||||
|
else "Delete ${picked.size} subagents?"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
|
||||||
|
"A subagent's transcript is the only record of what it did: the session " +
|
||||||
|
"that started it kept just the Task call. Nothing else has a copy, so " +
|
||||||
|
"this can't be undone. The session itself is untouched."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
confirming = null
|
||||||
|
selected = emptySet()
|
||||||
|
val ids = picked.map { it.id }
|
||||||
|
val gone = ids.toSet()
|
||||||
|
deleting += gone
|
||||||
|
deleteError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
deleteSubagents(settings, summary.id, ids)
|
||||||
|
gone.forEach {
|
||||||
|
transcriptCache
|
||||||
|
.session(TranscriptAddress(summary.id, it))
|
||||||
|
.purge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val loaded = rows
|
||||||
|
if (loaded is LoadState.Loaded) {
|
||||||
|
rows =
|
||||||
|
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
deleteError = e.message ?: "Delete failed"
|
||||||
|
} finally {
|
||||||
|
deleting -= gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
|
||||||
|
rows.sortedWith(
|
||||||
|
compareByDescending<SubagentSummary> { it.status == "running" }
|
||||||
|
.thenByDescending { it.lastActivity }
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SubagentCard(
|
||||||
|
subagent: SubagentSummary,
|
||||||
|
selected: Boolean,
|
||||||
|
selecting: Boolean,
|
||||||
|
deleting: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
BusyItem(label = if (deleting) "deleting" else null) {
|
||||||
|
OutlinedCard(
|
||||||
|
colors =
|
||||||
|
if (selected)
|
||||||
|
CardDefaults.outlinedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
else CardDefaults.outlinedCardColors(),
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
enabled = !deleting,
|
||||||
|
onClick = { if (selecting) onSelect() else onClick() },
|
||||||
|
onLongClick = onSelect,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
subagentStatusLabel(subagent.status),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
relativeTime(subagent.lastActivity),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubagentSelectionBar(
|
||||||
|
picked: List<SubagentSummary>,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val running = picked.count { it.status == "running" }
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (running == 0) "${picked.size} selected" else "$running still running",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDelete, enabled = running == 0) {
|
||||||
|
Text(
|
||||||
|
"Delete",
|
||||||
|
color =
|
||||||
|
if (running == 0) MaterialTheme.colorScheme.error
|
||||||
|
else LocalContentColor.current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subagentStatusLabel(status: String) =
|
||||||
|
when (status) {
|
||||||
|
"running" -> "running"
|
||||||
|
"exited" -> "finished"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
@@ -134,6 +134,20 @@ val clearedColor: Color
|
|||||||
val awaitingColor: Color
|
val awaitingColor: Color
|
||||||
@Composable get() = Mocha.Peach
|
@Composable get() = Mocha.Peach
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
|
||||||
|
* started is still going, and it will speak again with nobody having typed anything.
|
||||||
|
*
|
||||||
|
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
|
||||||
|
* reader has something to do, and this one means they specifically do not. Not [runningColor]
|
||||||
|
* either: nothing is being written, and a green "running" on a session that will say nothing for
|
||||||
|
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
|
||||||
|
* but not replying to you either -- and a different blue because that one is the session acting on
|
||||||
|
* itself rather than getting on with what was asked.
|
||||||
|
*/
|
||||||
|
val waitingColor: Color
|
||||||
|
@Composable get() = Mocha.Sky
|
||||||
|
|
||||||
/** Approaching a limit -- still fine, worth seeing. */
|
/** Approaching a limit -- still fine, worth seeing. */
|
||||||
val warningColor: Color
|
val warningColor: Color
|
||||||
@Composable get() = Mocha.Yellow
|
@Composable get() = Mocha.Yellow
|
||||||
@@ -199,6 +213,8 @@ val rawSurface: Color
|
|||||||
*/
|
*/
|
||||||
fun catppuccinSyntax(): SyntaxPalette =
|
fun catppuccinSyntax(): SyntaxPalette =
|
||||||
SyntaxPalette(
|
SyntaxPalette(
|
||||||
|
addition = Mocha.Green,
|
||||||
|
deletion = Mocha.Red,
|
||||||
keyword = Mocha.Mauve,
|
keyword = Mocha.Mauve,
|
||||||
string = Mocha.Green,
|
string = Mocha.Green,
|
||||||
literal = Mocha.Peach,
|
literal = Mocha.Peach,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model's working, shut until somebody asks for it.
|
||||||
|
*
|
||||||
|
* Shut by default, like a tool call and a memory note and for the same reason: it is not what the
|
||||||
|
* session said, and left open it puts the reasoning between the question and the answer -- which on
|
||||||
|
* a small model is most of the conversation.
|
||||||
|
*
|
||||||
|
* The heading is the whole of what the reader gets for free, so it carries the one thing worth
|
||||||
|
* knowing without opening anything: whether this is still going, and if not how long it took. A
|
||||||
|
* spinner while it runs, because that is the same fact a running command reports and it is drawn
|
||||||
|
* the same way here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ThinkingCard(
|
||||||
|
item: TranscriptItem.ThinkingRow,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(thinkingHeadline(item), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
if (item.open) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
// Plain text rather than markdown: this is a model talking to itself, so its
|
||||||
|
// half-finished lists and stray backticks are not markup it meant to write, and
|
||||||
|
// rendering them as such makes the working look like an answer.
|
||||||
|
Text(
|
||||||
|
item.text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Thinking", "Thought for 12.4s", or "Thought".
|
||||||
|
*
|
||||||
|
* The third is the one worth keeping: a block whose turn ended before the model said anything --
|
||||||
|
* interrupted, stopped, a process that exited -- was thought about for a length of time nobody
|
||||||
|
* measured. Naming a span there would be this screen inventing one, and the reader has no way to
|
||||||
|
* tell an invented one from the rest.
|
||||||
|
*/
|
||||||
|
fun thinkingHeadline(item: TranscriptItem.ThinkingRow): String =
|
||||||
|
when {
|
||||||
|
item.open -> "Thinking"
|
||||||
|
item.ms != null -> "Thought for ${formatMillis(item.ms)}"
|
||||||
|
else -> "Thought"
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -51,24 +48,31 @@ data class ToolInput(
|
|||||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||||
mapOf(
|
mapOf(
|
||||||
"Bash" to ("command" to Language.SHELL),
|
"Bash" to ("command" to Language.SHELL),
|
||||||
|
"Shell" to ("command" to Language.SHELL),
|
||||||
|
"Patch" to ("diff" to Language.DIFF),
|
||||||
"Read" to ("file_path" to null),
|
"Read" to ("file_path" to null),
|
||||||
"Write" to ("file_path" to null),
|
"Write" to ("file_path" to null),
|
||||||
"Edit" to ("file_path" to null),
|
"Edit" to ("file_path" to null),
|
||||||
"Glob" to ("pattern" to null),
|
"Glob" to ("pattern" to null),
|
||||||
"Grep" to ("pattern" to null),
|
"Grep" to ("pattern" to null),
|
||||||
"WebFetch" to ("url" to null),
|
"WebFetch" to ("url" to null),
|
||||||
|
"WebSearch" to ("query" to null),
|
||||||
|
// Persisted transcripts keep the provider vocabulary they were written with.
|
||||||
|
"web_search" to ("query" to null),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Fields that are the tool's own prose about itself rather than input to it. */
|
/** Fields that are the tool's own prose about itself rather than input to it. */
|
||||||
private val DESCRIPTIONS = listOf("description", "prompt")
|
private val DESCRIPTIONS = listOf("description", "prompt")
|
||||||
|
|
||||||
fun parseToolInput(tool: String, input: String): ToolInput {
|
fun parseToolInput(tool: String, input: String): ToolInput {
|
||||||
|
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
|
||||||
val json =
|
val json =
|
||||||
try {
|
try {
|
||||||
JSONObject(input)
|
JSONObject(input)
|
||||||
} catch (_: org.json.JSONException) {
|
} catch (_: org.json.JSONException) {
|
||||||
// Not an object: older transcripts and some tools send a bare string. It is still the
|
// Not an object: older transcripts and some tools send a bare string. It is still the
|
||||||
// input, so it is still shown.
|
// input, so it is still shown. JSON null is the one exception: it means the call had
|
||||||
|
// no input, and drawing the word makes an absent value look like an instruction.
|
||||||
return ToolInput(
|
return ToolInput(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -78,15 +82,20 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
||||||
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
|
val subject =
|
||||||
|
subjectKey
|
||||||
|
?.let { json.text(it) }
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
|
||||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
||||||
json.optString(it).takeIf { v -> v.isNotBlank() }
|
json.text(it)?.takeIf { value -> value.isNotBlank() }
|
||||||
}
|
}
|
||||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||||
val rest =
|
val rest =
|
||||||
json
|
json
|
||||||
.keys()
|
.keys()
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
.filterNot(json::isNull)
|
||||||
.filter { it != subjectKey || subject == null }
|
.filter { it != subjectKey || subject == null }
|
||||||
.filter { it !in DESCRIPTIONS || description == null }
|
.filter { it !in DESCRIPTIONS || description == null }
|
||||||
.filter { it != "timeout" || timeout == null }
|
.filter { it != "timeout" || timeout == null }
|
||||||
@@ -96,6 +105,31 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
return ToolInput(subject, language, description, timeout, rest)
|
return ToolInput(subject, language, description, timeout, rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.text(key: String): String? =
|
||||||
|
if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes Codex's rendered Bash argv from old transcript rows.
|
||||||
|
*
|
||||||
|
* New events arrive normalized by the server, but persisted transcripts keep the input originally
|
||||||
|
* written to them. Only the outer pair are presentation quoting: quotes inside the command belong
|
||||||
|
* to the command and must not be parsed as an early end delimiter.
|
||||||
|
*/
|
||||||
|
internal fun renderedBashScript(command: String): String? {
|
||||||
|
val prefix =
|
||||||
|
listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull {
|
||||||
|
command.startsWith(it)
|
||||||
|
} ?: return null
|
||||||
|
val quoted = command.removePrefix(prefix)
|
||||||
|
return quoted
|
||||||
|
.takeIf {
|
||||||
|
it.length >= 2 &&
|
||||||
|
((it.startsWith('\'') && it.endsWith('\'')) ||
|
||||||
|
(it.startsWith('"') && it.endsWith('"')))
|
||||||
|
}
|
||||||
|
?.substring(1, quoted.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tool call's input: its subject highlighted, then whatever else it carried.
|
* A tool call's input: its subject highlighted, then whatever else it carried.
|
||||||
*
|
*
|
||||||
@@ -113,7 +147,8 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|||||||
RawBlock(modifier) {
|
RawBlock(modifier) {
|
||||||
parsed.subject?.let { subject ->
|
parsed.subject?.let { subject ->
|
||||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
||||||
// one being read closely.
|
// one being read closely. The sideways scroll that makes that readable is the block's,
|
||||||
|
// shared with the lines below -- see [RawBlock].
|
||||||
Text(
|
Text(
|
||||||
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
||||||
// the cache exists for a fence with two hundred lines in it.
|
// the cache exists for a fence with two hundred lines in it.
|
||||||
@@ -121,7 +156,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
parsed.rest.forEach {
|
parsed.rest.forEach {
|
||||||
@@ -130,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
softWrap = false,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.layout.onPlaced
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -61,7 +64,9 @@ sealed class TranscriptRow {
|
|||||||
*
|
*
|
||||||
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
||||||
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
||||||
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
|
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where
|
||||||
|
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
|
||||||
|
* each piece its key.
|
||||||
*/
|
*/
|
||||||
abstract val key: Any
|
abstract val key: Any
|
||||||
|
|
||||||
@@ -75,23 +80,15 @@ sealed class TranscriptRow {
|
|||||||
*/
|
*/
|
||||||
abstract val startSeq: Long
|
abstract val startSeq: Long
|
||||||
|
|
||||||
data class Single(val item: TranscriptItem) : TranscriptRow() {
|
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
||||||
override val key: Any
|
TranscriptRow() {
|
||||||
get() = item.key
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = item.seq
|
get() = item.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
||||||
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
|
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
||||||
/** The run's own name, which every call in it already carries. */
|
TranscriptRow() {
|
||||||
val id: String
|
|
||||||
get() = calls.first().runId
|
|
||||||
|
|
||||||
override val key: Any
|
|
||||||
get() = id
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = calls.first().seq
|
get() = calls.first().seq
|
||||||
}
|
}
|
||||||
@@ -102,33 +99,75 @@ sealed class TranscriptRow {
|
|||||||
*
|
*
|
||||||
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
|
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
|
||||||
* and the run this exists for is the burst of five greps nobody wants to scroll past.
|
* and the run this exists for is the burst of five greps nobody wants to scroll past.
|
||||||
|
*
|
||||||
|
* The last call is left alone too, and so is one still running wherever in its run it sits. What
|
||||||
|
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
|
||||||
|
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
|
||||||
|
* being overtaken: anything arriving behind it, a reply included, makes it history.
|
||||||
|
*
|
||||||
|
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
|
||||||
|
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
|
||||||
|
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
|
||||||
|
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
|
||||||
|
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
|
||||||
|
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
|
||||||
|
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
|
||||||
|
* inside a group once is what settles it.
|
||||||
*/
|
*/
|
||||||
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
|
fun groupToolRuns(
|
||||||
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
items: List<TranscriptItem>,
|
||||||
|
heldOut: Set<String> = emptySet(),
|
||||||
|
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
|
||||||
|
|
||||||
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> {
|
||||||
val rows = mutableListOf<TranscriptRow>()
|
val rows = mutableListOf<TranscriptRow>()
|
||||||
var run = mutableListOf<TranscriptItem.ToolRun>()
|
var run = mutableListOf<TranscriptItem.ToolRun>()
|
||||||
|
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
|
||||||
|
// rather than resetting at each piece.
|
||||||
|
var runId: String? = null
|
||||||
|
val claimedKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
fun flush() {
|
fun flush() {
|
||||||
when (run.size) {
|
val first = run.firstOrNull() ?: return
|
||||||
0 -> {}
|
// The first piece keeps the run's name, which survives a page landing in front of it
|
||||||
1 -> rows += TranscriptRow.Single(run.first())
|
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
|
||||||
else -> rows += TranscriptRow.Tools(run.toList())
|
// final guard because a duplicate LazyColumn key takes down the whole screen.
|
||||||
|
var key = first.runId
|
||||||
|
if (!claimedKeys.add(key)) {
|
||||||
|
key = "${first.runId}/${first.id}"
|
||||||
|
var suffix = 2
|
||||||
|
while (!claimedKeys.add(key)) {
|
||||||
|
key = "${first.runId}/${first.id}/${suffix++}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
rows +=
|
||||||
|
if (run.size == 1) TranscriptRow.Single(first, key)
|
||||||
|
else TranscriptRow.Tools(run.toList(), key)
|
||||||
run = mutableListOf()
|
run = mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach { item ->
|
items.forEachIndexed { index, item ->
|
||||||
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
|
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
|
||||||
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
||||||
// arriving next to an existing run, or a page of history arriving in front of one, both
|
// arriving next to an existing run, or a page of history arriving in front of one, both
|
||||||
// change which call is *first*.
|
// change which call is *first*.
|
||||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
val call = item as? TranscriptItem.ToolRun
|
||||||
run += item
|
if (call == null || call.runId != runId) {
|
||||||
} else {
|
|
||||||
flush()
|
flush()
|
||||||
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
|
runId = call?.runId
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
call == null -> rows += TranscriptRow.Single(item)
|
||||||
|
// Standing outside the run is the call's place in the list as it is now, not something
|
||||||
|
// recorded on the call: the same finished call is a row of its own while it is the last
|
||||||
|
// thing that happened, or open and never yet grouped, and part of its group once a
|
||||||
|
// reply lands behind it.
|
||||||
|
call.done && call.id !in heldOut && index != items.lastIndex -> run += call
|
||||||
|
else -> {
|
||||||
|
flush()
|
||||||
|
run += call
|
||||||
|
flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
@@ -160,7 +199,15 @@ fun ToolGroup(
|
|||||||
*/
|
*/
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
isToolExpanded: (String) -> Boolean,
|
isToolExpanded: (String) -> Boolean,
|
||||||
onToolToggle: (String) -> Unit,
|
/**
|
||||||
|
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
|
||||||
|
* edge its card begins, and how tall that card is now.
|
||||||
|
*
|
||||||
|
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
|
||||||
|
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
|
||||||
|
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
|
||||||
|
*/
|
||||||
|
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit,
|
image: @Composable (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -175,8 +222,10 @@ fun ToolGroup(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val placed = remember { Placed() }
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
|
.onPlaced { placed.top = it.positionInRoot().y }
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
) {
|
) {
|
||||||
@@ -196,13 +245,19 @@ fun ToolGroup(
|
|||||||
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
||||||
) {
|
) {
|
||||||
group.calls.forEachIndexed { index, call ->
|
group.calls.forEachIndexed { index, call ->
|
||||||
|
val card = remember(call.id) { Placed() }
|
||||||
ToolCard(
|
ToolCard(
|
||||||
tool = call,
|
tool = call,
|
||||||
expanded = isToolExpanded(call.id),
|
expanded = isToolExpanded(call.id),
|
||||||
onToggle = { onToolToggle(call.id) },
|
onToggle = {
|
||||||
|
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
|
||||||
|
},
|
||||||
onAnswer = onAnswer,
|
onAnswer = onAnswer,
|
||||||
image = image,
|
image = image,
|
||||||
shape = connectedShape(index, group.calls.size),
|
shape = connectedShape(index, group.calls.size),
|
||||||
|
modifier =
|
||||||
|
Modifier.onPlaced { card.top = it.positionInRoot().y }
|
||||||
|
.onSizeChanged { card.height = it.height },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +267,18 @@ fun ToolGroup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where something was last placed, in the window's coordinates, and how tall it was.
|
||||||
|
*
|
||||||
|
* Deliberately not snapshot state: it is written from the layout phase, and a write there that
|
||||||
|
* composition reads would schedule another recomposition of every group on screen, every frame.
|
||||||
|
* Nothing reads it except the gesture that follows.
|
||||||
|
*/
|
||||||
|
private class Placed {
|
||||||
|
var top = 0f
|
||||||
|
var height = 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The height of a group's heading, and so of the bar at its foot.
|
* The height of a group's heading, and so of the bar at its foot.
|
||||||
*
|
*
|
||||||
@@ -293,14 +360,17 @@ fun ToolCard(
|
|||||||
image: @Composable (String) -> Unit = {},
|
image: @Composable (String) -> Unit = {},
|
||||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||||
shape: Shape = CardDefaults.shape,
|
shape: Shape = CardDefaults.shape,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
||||||
|
val name = toolDisplayName(tool.tool)
|
||||||
|
val output = toolDisplayOutput(tool.tool, tool.output)
|
||||||
val deciding = tool.asks.any { it.answers.isEmpty() }
|
val deciding = tool.asks.any { it.answers.isEmpty() }
|
||||||
val open = expanded || deciding
|
val open = expanded || deciding
|
||||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
||||||
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
|
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||||
if (open) {
|
if (open) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
parsed.timeout?.let {
|
parsed.timeout?.let {
|
||||||
@@ -355,24 +425,26 @@ fun ToolCard(
|
|||||||
if (tool.tool != ASK_USER_QUESTION) {
|
if (tool.tool != ASK_USER_QUESTION) {
|
||||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||||
}
|
}
|
||||||
if (tool.output.isNotEmpty()) {
|
if (output.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||||
// What the tool printed, on the surface everything verbatim gets and in the
|
// What the tool printed, on the surface everything verbatim gets and in the
|
||||||
// face it was written for: this is column-aligned far more often than it is
|
// face it was written for: this is column-aligned far more often than it is
|
||||||
// prose, and a proportional font silently destroys the alignment that carried
|
// prose, and a proportional font silently destroys the alignment that carried
|
||||||
// the meaning.
|
// the meaning. Unwrapped for the same reason, and scrolled sideways by the
|
||||||
|
// block around it -- see [RawBlock].
|
||||||
//
|
//
|
||||||
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
||||||
// often the whole of what a diff or a test run is saying. Remembered against
|
// often the whole of what a diff or a test run is saying. Remembered against
|
||||||
// the text, so a card that is open through a scroll parses once.
|
// the text, so a card that is open through a scroll parses once.
|
||||||
val palette = remember { ansiPalette() }
|
val palette = remember { ansiPalette() }
|
||||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
val styled = remember(output, palette) { ansiStyled(output, palette) }
|
||||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||||
Text(
|
Text(
|
||||||
styled,
|
styled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
|
softWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +463,22 @@ fun ToolCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val collaborationToolNames =
|
||||||
|
mapOf(
|
||||||
|
"Task" to "Spawn agent",
|
||||||
|
"TaskOutput" to "Wait for agents",
|
||||||
|
"SendMessage" to "Message agent",
|
||||||
|
"CloseAgent" to "Close agent",
|
||||||
|
"InterruptAgent" to "Interrupt agent",
|
||||||
|
"ListAgents" to "List agents",
|
||||||
|
"ResumeAgent" to "Resume agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool
|
||||||
|
|
||||||
|
internal fun toolDisplayOutput(tool: String, output: String): String =
|
||||||
|
if (tool in collaborationToolNames && output == "completed") "" else output
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The permission ask on the call it is about.
|
* The permission ask on the call it is about.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one transcript lives: a session's own, or one of its subagents'.
|
||||||
|
*
|
||||||
|
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||||
|
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
||||||
|
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||||
|
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||||
|
*/
|
||||||
|
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||||
|
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
|
||||||
|
val urlPath: String
|
||||||
|
get() =
|
||||||
|
if (subagentId == null) "sessions/$sessionId"
|
||||||
|
else "sessions/$sessionId/subagents/$subagentId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this transcript's cache lives on the phone, relative to the cache root.
|
||||||
|
*
|
||||||
|
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
|
||||||
|
* session's cache directory takes its subagents' with it -- the same one-way door the server's
|
||||||
|
* own storage describes.
|
||||||
|
*/
|
||||||
|
val cachePath: String
|
||||||
|
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
|
||||||
|
}
|
||||||
@@ -35,8 +35,15 @@ class TranscriptCache(
|
|||||||
private val root: File,
|
private val root: File,
|
||||||
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||||
) {
|
) {
|
||||||
/** The cache for one session, whether or not anything has been stored for it yet. */
|
/**
|
||||||
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
|
* The cache for one transcript, whether or not anything has been stored for it yet.
|
||||||
|
*
|
||||||
|
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
|
||||||
|
* deleting the session (below) takes its subagents' caches with it -- there is no separate
|
||||||
|
* purge for one.
|
||||||
|
*/
|
||||||
|
fun session(address: TranscriptAddress): SessionCache =
|
||||||
|
SessionCache(File(root, address.cachePath), warn)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
||||||
|
|||||||
@@ -63,6 +63,45 @@ sealed class TranscriptItem {
|
|||||||
* inside that reply would step the list under them.
|
* inside that reply would step the list under them.
|
||||||
*/
|
*/
|
||||||
val settled: Boolean = false,
|
val settled: Boolean = false,
|
||||||
|
/** A final value that supersedes provisional deltas behind a page boundary. */
|
||||||
|
val replacesPrefix: Boolean = false,
|
||||||
|
/**
|
||||||
|
* When the reply was sent, in epoch seconds: the time on its newest delta, which is the
|
||||||
|
* moment it finished rather than the moment it started.
|
||||||
|
*
|
||||||
|
* The transcript's own timestamp rather than a clock read here, so every device draws the
|
||||||
|
* same time under the same reply and a replayed page agrees with the live stream.
|
||||||
|
*/
|
||||||
|
val ts: Double = 0.0,
|
||||||
|
/**
|
||||||
|
* How fast it was generated, where the provider measured it; null everywhere else.
|
||||||
|
*
|
||||||
|
* Folded on from the turn's usage event rather than carried by the text, because it is not
|
||||||
|
* known until the reply is over.
|
||||||
|
*/
|
||||||
|
val tokensPerSecond: Double? = null,
|
||||||
|
/** How long the provider spent reading the prompt, where it measured that. */
|
||||||
|
val prefillMs: Long? = null,
|
||||||
|
) : TranscriptItem()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model's working before -- or between -- the things it said.
|
||||||
|
*
|
||||||
|
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
|
||||||
|
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
|
||||||
|
* file it as one of them. Shut by default, like every other card that is not what was said.
|
||||||
|
*
|
||||||
|
* Three states, because two of them are not the same absence. [open] is a block still being
|
||||||
|
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
|
||||||
|
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
|
||||||
|
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
|
||||||
|
* naming a wrong one.
|
||||||
|
*/
|
||||||
|
data class ThinkingRow(
|
||||||
|
override val seq: Long,
|
||||||
|
val text: String,
|
||||||
|
val ms: Long? = null,
|
||||||
|
val open: Boolean = true,
|
||||||
) : TranscriptItem()
|
) : TranscriptItem()
|
||||||
|
|
||||||
data class ToolRun(
|
data class ToolRun(
|
||||||
@@ -143,6 +182,29 @@ sealed class TranscriptItem {
|
|||||||
get() = arrived
|
get() = arrived
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one turn ended and the next began with nothing said in between.
|
||||||
|
*
|
||||||
|
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
|
||||||
|
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
|
||||||
|
* the ordinary gap between them they read as one answer with a paragraph break through the
|
||||||
|
* middle of it. What the reader needs is to see that these are two; what started the turn is
|
||||||
|
* somebody else's transcript's business, and a row per background task is a screenful of
|
||||||
|
* dividers about work nobody was asking after.
|
||||||
|
*
|
||||||
|
* Made by the fold rather than sent by the server, because it is not something that happened:
|
||||||
|
* it is the boundary between two things that did. See [foldEvent].
|
||||||
|
*/
|
||||||
|
data class TurnBreak(override val seq: Long) : TranscriptItem() {
|
||||||
|
/**
|
||||||
|
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
|
||||||
|
* delta is the event this was made at, and a keyed list refuses two items with one key by
|
||||||
|
* taking the app down.
|
||||||
|
*/
|
||||||
|
override val key: Any
|
||||||
|
get() = "break$seq"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
|
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
|
||||||
* than only shown while it waits, because it explains what follows: a conversation that
|
* than only shown while it waits, because it explains what follows: a conversation that
|
||||||
@@ -175,6 +237,18 @@ sealed class TranscriptItem {
|
|||||||
val preTokens: Long?,
|
val preTokens: Long?,
|
||||||
val postTokens: Long?,
|
val postTokens: Long?,
|
||||||
) : TranscriptItem()
|
) : TranscriptItem()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account ran out of quota, so the turn stopped here.
|
||||||
|
*
|
||||||
|
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
|
||||||
|
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
|
||||||
|
* line.
|
||||||
|
*
|
||||||
|
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
|
||||||
|
* row has words for rather than a time it invents.
|
||||||
|
*/
|
||||||
|
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,7 +293,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
|||||||
* with the seam wherever the reader happened to have paged.
|
* with the seam wherever the reader happened to have paged.
|
||||||
*/
|
*/
|
||||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||||
val (older, newer) = healSplitMessage(earlier, later)
|
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
|
||||||
val startedEarlier =
|
val startedEarlier =
|
||||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||||
val endedLater =
|
val endedLater =
|
||||||
@@ -249,9 +323,10 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
|
|||||||
/**
|
/**
|
||||||
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
|
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
|
||||||
*
|
*
|
||||||
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
|
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one
|
||||||
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
|
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
|
||||||
* answer as two with a paragraph break through the middle of a sentence.
|
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a
|
||||||
|
* sentence. Two settled replies meeting there are two turns and stay two.
|
||||||
*
|
*
|
||||||
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
|
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
|
||||||
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
|
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
|
||||||
@@ -266,6 +341,36 @@ private fun healSplitMessage(
|
|||||||
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
||||||
return earlier to later
|
return earlier to later
|
||||||
}
|
}
|
||||||
|
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
|
||||||
|
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
|
||||||
|
// here would put back exactly the run-together paragraph it stops.
|
||||||
|
// The rule between them is put in here too, since the fold that would have made it never saw
|
||||||
|
// these two side by side.
|
||||||
|
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
|
||||||
|
if (tail.replacesPrefix) return earlier.dropLast(1) to later
|
||||||
|
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejoins a thinking block the page boundary cut, the same way [healSplitMessage] rejoins a reply.
|
||||||
|
*
|
||||||
|
* A block streams a fragment at a time exactly as a reply does, so a boundary lands inside one as
|
||||||
|
* readily. The older half then holds an open block whose [SessionEvent.ThinkingDone] is on the
|
||||||
|
* newer page -- so it spun for the rest of the conversation, saying the machine was working on a
|
||||||
|
* thought it finished minutes ago, and the same working was drawn as two blocks.
|
||||||
|
*
|
||||||
|
* Only where the older half is still open: a closed one has its own ending and the two are two
|
||||||
|
* blocks that happen to meet here. The newer half keeps its identity, for the reason [adoptRun]
|
||||||
|
* gives -- it is the part already on screen.
|
||||||
|
*/
|
||||||
|
private fun healSplitThinking(
|
||||||
|
pages: Pair<List<TranscriptItem>, List<TranscriptItem>>
|
||||||
|
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
|
||||||
|
val (earlier, later) = pages
|
||||||
|
val head = earlier.lastOrNull()
|
||||||
|
val tail = later.firstOrNull()
|
||||||
|
if (head !is TranscriptItem.ThinkingRow || tail !is TranscriptItem.ThinkingRow) return pages
|
||||||
|
if (!head.open) return pages
|
||||||
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,24 +457,75 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
// first of them: a row whose identity changed with every delta would be a new row on
|
// first of them: a row whose identity changed with every delta would be a new row on
|
||||||
// every frame, and the list would jump for the whole of a streamed answer.
|
// every frame, and the list would jump for the whole of a streamed answer.
|
||||||
val last = items.lastOrNull()
|
val last = items.lastOrNull()
|
||||||
if (last is TranscriptItem.AssistantMsg) {
|
// Only into a reply that is still arriving. A settled one is a turn that ended, and
|
||||||
// A message growing again is not finished, whatever a status said in between.
|
// text after it belongs to the next turn -- a separate message, drawn as its own row.
|
||||||
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
|
// Growing it instead ran two answers together with not even a space between them,
|
||||||
|
// which is what happens whenever a turn starts with nothing recorded in front of it:
|
||||||
|
// a subagent reporting back, or a peer message the CLI only owns up to at the end.
|
||||||
|
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
||||||
|
items.dropLast(1) + last.copy(text = last.text + event.delta, ts = entry.ts)
|
||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
// A rule between the two, and only where they actually meet: anything that draws a
|
||||||
|
// row of its own -- a message, a command, a peer note -- is already the boundary.
|
||||||
|
val between =
|
||||||
|
if (last is TranscriptItem.AssistantMsg)
|
||||||
|
listOf(TranscriptItem.TurnBreak(entry.seq))
|
||||||
|
else emptyList()
|
||||||
|
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta, ts = entry.ts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is SessionEvent.AssistantTextFinal -> {
|
||||||
|
val last = items.lastOrNull()
|
||||||
|
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
||||||
|
items.dropLast(1) +
|
||||||
|
last.copy(text = event.text, replacesPrefix = true, ts = entry.ts)
|
||||||
|
} else {
|
||||||
|
val between =
|
||||||
|
if (last is TranscriptItem.AssistantMsg)
|
||||||
|
listOf(TranscriptItem.TurnBreak(entry.seq))
|
||||||
|
else emptyList()
|
||||||
|
items +
|
||||||
|
between +
|
||||||
|
TranscriptItem.AssistantMsg(
|
||||||
|
entry.seq,
|
||||||
|
event.text,
|
||||||
|
replacesPrefix = true,
|
||||||
|
ts = entry.ts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SessionEvent.Thinking -> {
|
||||||
|
// Deltas grow the open block, keeping the seq of the first of them, for the same
|
||||||
|
// reason a reply's do: a row whose identity changed per delta is a new row per frame.
|
||||||
|
val last = items.lastOrNull()
|
||||||
|
if (last is TranscriptItem.ThinkingRow && last.open) {
|
||||||
|
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
||||||
|
} else {
|
||||||
|
items + TranscriptItem.ThinkingRow(entry.seq, event.delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The newest block still open, rather than whatever row happens to be last.
|
||||||
|
is SessionEvent.ThinkingDone ->
|
||||||
|
closeThinking(items) { it.copy(ms = event.ms, open = false) }
|
||||||
is SessionEvent.ToolStart ->
|
is SessionEvent.ToolStart ->
|
||||||
items +
|
// A call id names one call for its whole lifetime. Codex can repeat the start while
|
||||||
TranscriptItem.ToolRun(
|
// recovering an in-flight item; appending that replay made two rows with one key, and
|
||||||
entry.seq,
|
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay
|
||||||
event.id,
|
// also repairs transcripts which already contain it when they are folded on reopen.
|
||||||
runIdFor(items, event.id, event.tool),
|
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
||||||
event.tool,
|
items
|
||||||
event.input,
|
} else {
|
||||||
"",
|
items +
|
||||||
done = false,
|
TranscriptItem.ToolRun(
|
||||||
)
|
entry.seq,
|
||||||
|
event.id,
|
||||||
|
runIdFor(items, event.id, event.tool),
|
||||||
|
event.tool,
|
||||||
|
event.input,
|
||||||
|
"",
|
||||||
|
done = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
||||||
is SessionEvent.ToolEnd ->
|
is SessionEvent.ToolEnd ->
|
||||||
// Created when its start is not here, rather than dropped. A fold that only ever
|
// Created when its start is not here, rather than dropped. A fold that only ever
|
||||||
@@ -444,7 +600,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
// nothing it belongs above.
|
// nothing it belongs above.
|
||||||
is SessionEvent.MessageDropped -> items
|
is SessionEvent.MessageDropped -> items
|
||||||
is SessionEvent.Settings -> items
|
is SessionEvent.Settings -> items
|
||||||
|
// Neither carries a row: both are about what the session can do rather than about anything
|
||||||
|
// said in it, and the composer is where they are drawn.
|
||||||
|
is SessionEvent.Images -> items
|
||||||
|
is SessionEvent.BackgroundTasks -> items
|
||||||
is SessionEvent.Status -> settleReply(items, event.state)
|
is SessionEvent.Status -> settleReply(items, event.state)
|
||||||
|
is SessionEvent.AuthenticationRequired ->
|
||||||
|
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
is SessionEvent.Image ->
|
is SessionEvent.Image ->
|
||||||
// Under the call that produced it when there is one, and a row of its own when there is
|
// Under the call that produced it when there is one, and a row of its own when there is
|
||||||
@@ -458,12 +620,32 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
||||||
}
|
}
|
||||||
|
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
|
||||||
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
||||||
is SessionEvent.Compacted ->
|
is SessionEvent.Compacted ->
|
||||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||||
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
// Said rather than skipped: a line the server could not read is a hole in the conversation,
|
||||||
is SessionEvent.UsageDelta -> items
|
// and one that draws nothing is a hole nothing on screen ever mentions.
|
||||||
|
is SessionEvent.Unreadable ->
|
||||||
|
items + TranscriptItem.Note(entry.seq, "[unreadable: ${event.kind}]")
|
||||||
|
// No row: see [SessionEvent.RetiredTaskNote].
|
||||||
|
is SessionEvent.RetiredTaskNote -> items
|
||||||
|
// No row of its own -- the counts are screen-level state, see SessionScreen -- but the
|
||||||
|
// generation speed belongs under the reply it measured, and this is where that reply ends.
|
||||||
|
// Only onto the newest row, and only when that row is a reply: a turn whose usage arrives
|
||||||
|
// after a tool call has nothing here to put it on, which draws as a footer without it.
|
||||||
|
is SessionEvent.UsageDelta ->
|
||||||
|
when (val last = items.lastOrNull()) {
|
||||||
|
is TranscriptItem.AssistantMsg ->
|
||||||
|
items.dropLast(1) +
|
||||||
|
last.copy(
|
||||||
|
tokensPerSecond = event.tokensPerSecond,
|
||||||
|
prefillMs = event.prefillMs,
|
||||||
|
)
|
||||||
|
else -> items
|
||||||
|
}
|
||||||
|
is SessionEvent.ContextWindow -> items
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -474,9 +656,22 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
*/
|
*/
|
||||||
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
||||||
if (sessionWorking(state)) return items
|
if (sessionWorking(state)) return items
|
||||||
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
|
// A block the turn ended in the middle of is over, however it ended. Left open it spins for
|
||||||
if (last.settled) return items
|
// the rest of the conversation, which says the machine is working when nothing is.
|
||||||
return items.dropLast(1) + last.copy(settled = true)
|
val ended = closeThinking(items) { it.copy(open = false) }
|
||||||
|
val last = ended.lastOrNull() as? TranscriptItem.AssistantMsg ?: return ended
|
||||||
|
if (last.settled) return ended
|
||||||
|
return ended.dropLast(1) + last.copy(settled = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [change] applied to the newest thinking block still open, if there is one. */
|
||||||
|
private fun closeThinking(
|
||||||
|
items: List<TranscriptItem>,
|
||||||
|
change: (TranscriptItem.ThinkingRow) -> TranscriptItem.ThinkingRow,
|
||||||
|
): List<TranscriptItem> {
|
||||||
|
val at = items.indexOfLast { it is TranscriptItem.ThinkingRow && it.open }
|
||||||
|
if (at < 0) return items
|
||||||
|
return items.toMutableList().apply { this[at] = change(this[at] as TranscriptItem.ThinkingRow) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTool(
|
private fun updateTool(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -10,6 +11,9 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.text.selection.SelectionState
|
import androidx.compose.foundation.text.selection.SelectionState
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -49,6 +53,8 @@ fun TranscriptList(
|
|||||||
units: List<TranscriptUnit>,
|
units: List<TranscriptUnit>,
|
||||||
state: LazyListState,
|
state: LazyListState,
|
||||||
moreHistory: Boolean,
|
moreHistory: Boolean,
|
||||||
|
historyError: String?,
|
||||||
|
onRetryHistory: () -> Unit,
|
||||||
selection: SelectionState,
|
selection: SelectionState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
below: @Composable () -> Unit,
|
below: @Composable () -> Unit,
|
||||||
@@ -94,15 +100,29 @@ fun TranscriptList(
|
|||||||
DebugStats.count("unit composed")
|
DebugStats.count("unit composed")
|
||||||
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
||||||
}
|
}
|
||||||
// Standing in for everything not fetched yet. Only here while there is more -- its
|
// Standing in for everything not fetched yet. A failed fetch stays actionable here:
|
||||||
// appearance at the top edge is also roughly when the next page is asked for, so what
|
// when the loaded transcript is too short to scroll, this boundary is the only place
|
||||||
// it reports is a fetch in flight rather than an end reached.
|
// the reader can be given another way to ask.
|
||||||
if (moreHistory) {
|
if (moreHistory) {
|
||||||
item(key = "history", contentType = "history") {
|
item(key = "history", contentType = "history") {
|
||||||
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||||
CircularProgressIndicator(
|
if (historyError == null) {
|
||||||
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
CircularProgressIndicator(
|
||||||
)
|
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Couldn't load earlier messages. $historyError",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRetryHistory) { Text("Try again") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
*/
|
*/
|
||||||
class TranscriptSource(
|
class TranscriptSource(
|
||||||
private val settings: ServerSettings,
|
private val settings: ServerSettings,
|
||||||
private val sessionId: String,
|
private val address: TranscriptAddress,
|
||||||
val cache: SessionCache,
|
val cache: SessionCache,
|
||||||
) {
|
) {
|
||||||
private val stream = AtomicReference<EventStream?>(null)
|
private val stream = AtomicReference<EventStream?>(null)
|
||||||
@@ -65,7 +65,7 @@ class TranscriptSource(
|
|||||||
val tail = cache.tail() ?: return false
|
val tail = cache.tail() ?: return false
|
||||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
||||||
// the cursor when the server still has one there.
|
// the cursor when the server still has one there.
|
||||||
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
|
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
|
||||||
val matches =
|
val matches =
|
||||||
answer.size == 1 &&
|
answer.size == 1 &&
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +83,7 @@ class TranscriptSource(
|
|||||||
*/
|
*/
|
||||||
suspend fun fetchOpening(): List<SeqEvent> {
|
suspend fun fetchOpening(): List<SeqEvent> {
|
||||||
DebugStats.count("transcript page from server")
|
DebugStats.count("transcript page from server")
|
||||||
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
|
||||||
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||||
cache.flush()
|
cache.flush()
|
||||||
return page.map { it.second }
|
return page.map { it.second }
|
||||||
@@ -108,7 +108,7 @@ class TranscriptSource(
|
|||||||
val page =
|
val page =
|
||||||
fetchTranscript(
|
fetchTranscript(
|
||||||
settings,
|
settings,
|
||||||
sessionId,
|
address,
|
||||||
before = before,
|
before = before,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
coalesce = coalesce,
|
coalesce = coalesce,
|
||||||
@@ -131,7 +131,7 @@ class TranscriptSource(
|
|||||||
* well lose.
|
* well lose.
|
||||||
*/
|
*/
|
||||||
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||||
val opened = EventStream(settings, sessionId)
|
val opened = EventStream(settings, address)
|
||||||
stream.getAndSet(opened)?.close()
|
stream.getAndSet(opened)?.close()
|
||||||
try {
|
try {
|
||||||
opened.run(after, onOpen, onReset) { raw, entry ->
|
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ sealed class TranscriptUnit {
|
|||||||
get() = "u$seq:$ordinal"
|
get() = "u$seq:$ordinal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line under a finished reply: when it was sent, and how fast it was generated.
|
||||||
|
*
|
||||||
|
* A unit of its own rather than something drawn inside the last block, because a settled reply
|
||||||
|
* *is* its blocks -- there is no row left to hang it on, and the last block is a piece of
|
||||||
|
* markdown that knows nothing about the message it came from.
|
||||||
|
*/
|
||||||
|
data class ReplyFoot(
|
||||||
|
override val seq: Long,
|
||||||
|
override val ordinal: Int,
|
||||||
|
val ts: Double,
|
||||||
|
val tokensPerSecond: Double?,
|
||||||
|
val prefillMs: Long?,
|
||||||
|
override val gap: Dp,
|
||||||
|
) : TranscriptUnit() {
|
||||||
|
override val key: Any
|
||||||
|
get() = "f$seq"
|
||||||
|
}
|
||||||
|
|
||||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||||
data class Memory(
|
data class Memory(
|
||||||
override val seq: Long,
|
override val seq: Long,
|
||||||
@@ -237,6 +256,19 @@ fun transcriptUnits(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Unconditional, because being in this branch is what says the reply is over:
|
||||||
|
// [splitWanted] is settled-or-overtaken. The case to keep out is a message still
|
||||||
|
// arriving, whose "sent at" is not yet the one it ends up with, and that is drawn
|
||||||
|
// whole.
|
||||||
|
units +=
|
||||||
|
TranscriptUnit.ReplyFoot(
|
||||||
|
row.startSeq,
|
||||||
|
ordinal,
|
||||||
|
item.ts,
|
||||||
|
item.tokensPerSecond,
|
||||||
|
item.prefillMs,
|
||||||
|
gap(FOOT_SPACING),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
units += TranscriptUnit.Whole(row, rowGap)
|
units += TranscriptUnit.Whole(row, rowGap)
|
||||||
}
|
}
|
||||||
@@ -280,6 +312,14 @@ fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<Tra
|
|||||||
* length has lines that wrap, so its bubble is at the full width already and the slices match it
|
* length has lines that wrap, so its bubble is at the full width already and the slices match it
|
||||||
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
|
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The room between a reply's last block and the line under it.
|
||||||
|
*
|
||||||
|
* Tighter than the gap between blocks: the footer belongs to the message above it, and at a block's
|
||||||
|
* spacing it reads as a row of its own floating between two replies.
|
||||||
|
*/
|
||||||
|
private val FOOT_SPACING: Dp = 2.dp
|
||||||
|
|
||||||
const val USER_SPLIT_CHARS = 4000
|
const val USER_SPLIT_CHARS = 4000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +415,7 @@ private val TranscriptUnit?.kind: String
|
|||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
is TranscriptUnit.PeerBlock -> "peer block"
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
is TranscriptUnit.UserChunk -> "user slice"
|
||||||
is TranscriptUnit.Memory -> "memory note"
|
is TranscriptUnit.Memory -> "memory note"
|
||||||
|
is TranscriptUnit.ReplyFoot -> "reply footer"
|
||||||
is TranscriptUnit.Whole ->
|
is TranscriptUnit.Whole ->
|
||||||
when (val row = row) {
|
when (val row = row) {
|
||||||
is TranscriptRow.Tools -> "tool group"
|
is TranscriptRow.Tools -> "tool group"
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -30,7 +33,14 @@ import java.time.OffsetDateTime
|
|||||||
* own, so the only thing its Back could ever have meant was "put this away".
|
* own, so the only thing its Back could ever have meant was "put this away".
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
fun UsageDialog(
|
||||||
|
settings: ServerSettings,
|
||||||
|
feed: UsageFeed,
|
||||||
|
session: SessionSummary,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var signingIn by remember { mutableStateOf(false) }
|
||||||
|
val now = rememberUsageNow()
|
||||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||||
@@ -45,10 +55,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
// Deliberately not subtitled with the provider this was opened from. These
|
|
||||||
// numbers belong to an account on a particular machine -- naming the session's
|
|
||||||
// provider here made an echo session's screen read "echo" above a line reading
|
|
||||||
// "claude". Each machine names itself and the service it came from.
|
|
||||||
Text(
|
Text(
|
||||||
"Usage",
|
"Usage",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
@@ -65,10 +71,24 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Scrolls rather than being trimmed: a machine can report any number of windows and
|
// Scrolls rather than being trimmed: a machine can report any number of windows and
|
||||||
// there can be any number of machines, and a dialog is the one place where running
|
// a provider can report several billing pools, and a dialog is the one place where
|
||||||
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
|
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
||||||
|
// dialog.
|
||||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||||
UsageBody(feed.snapshots)
|
val state =
|
||||||
|
when (val snapshots = feed.snapshots) {
|
||||||
|
is LoadState.Loading -> LoadState.Loading
|
||||||
|
is LoadState.Error -> snapshots
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
LoadState.Loaded(
|
||||||
|
usageSnapshotsFor(
|
||||||
|
snapshots.value,
|
||||||
|
session.machine,
|
||||||
|
session.usageProvider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UsageBody(state, now, onSignIn = { signingIn = true })
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
@@ -76,29 +96,46 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (signingIn) {
|
||||||
|
ProviderLoginDialog(
|
||||||
|
settings = settings,
|
||||||
|
machineId = session.machine,
|
||||||
|
machineName = session.machineName,
|
||||||
|
provider = session.provider,
|
||||||
|
onDismiss = { signingIn = false },
|
||||||
|
onSignedIn = {
|
||||||
|
signingIn = false
|
||||||
|
feed.refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
private fun UsageBody(
|
||||||
|
state: LoadState<List<UsageSnapshot>>,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded ->
|
is LoadState.Loaded ->
|
||||||
if (current.value.isEmpty()) {
|
if (current.value.isEmpty()) {
|
||||||
// Not an error and not a blank screen: no machine offers a paid service, so
|
// Not an error and not a blank screen: this provider has no paid quota, so
|
||||||
// there is genuinely nothing to report and saying so is the answer.
|
// there is genuinely nothing to report and saying so is the answer.
|
||||||
Text(
|
Text(
|
||||||
"No machine here runs anything with usage limits.",
|
"This session's provider has no usage limits.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// No card around each machine. A card is a step up the surface ladder, and
|
// No card around each pool. A card is a step up the surface ladder, and inside
|
||||||
// inside a dialog -- itself a raised surface -- the step barely renders while
|
// a dialog -- itself a raised surface -- the step barely renders while costing
|
||||||
// costing 16dp on every side. What separates one machine from the next is the
|
// 16dp on every side. What separates one pool from the next is the line naming
|
||||||
// line naming it.
|
// it.
|
||||||
current.value.forEachIndexed { index, snapshot ->
|
current.value.forEachIndexed { index, snapshot ->
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -108,18 +145,18 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
|||||||
// read as a section of their own. Small and quiet, because the numbers
|
// read as a section of their own. Small and quiet, because the numbers
|
||||||
// below are what somebody opened this to see.
|
// below are what somebody opened this to see.
|
||||||
Text(
|
Text(
|
||||||
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
|
usageSectionTitle(snapshot),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
SnapshotState(snapshot)
|
SnapshotState(snapshot, onSignIn)
|
||||||
snapshot.windows.forEachIndexed { windowIndex, window ->
|
snapshot.windows.forEachIndexed { windowIndex, window ->
|
||||||
// Between the bars, not after the last one: a trailing gap here is what
|
// Between the bars, not after the last one: a trailing gap here is what
|
||||||
// put a band of empty dialog above the Close button.
|
// put a band of empty dialog above the Close button.
|
||||||
if (windowIndex > 0) {
|
if (windowIndex > 0) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
WindowBar(window)
|
WindowBar(window, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,20 +164,44 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
|
||||||
|
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
|
||||||
|
val provider = snapshot.provider
|
||||||
|
val pool =
|
||||||
|
if (provider == "codex" && snapshot.limitId != "codex") {
|
||||||
|
when (snapshot.limitName) {
|
||||||
|
"gpt-reserve" -> "Luna Reserve"
|
||||||
|
null -> snapshot.limitId
|
||||||
|
else -> snapshot.limitName
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
return listOfNotNull(machine, provider, pool).joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anything other than numbers: why this machine has none.
|
* Anything other than numbers: why this machine has none.
|
||||||
*
|
*
|
||||||
* The distinction the old single message could not draw. A machine nobody has logged in on is
|
* The distinction the old single message could not draw. A machine nobody has logged in on is
|
||||||
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
||||||
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
|
* the interface nagging about a decision already made. It still offers the direct sign-in action;
|
||||||
|
* unreachable and provider failures are the states coloured as faults.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
||||||
when (snapshot.state) {
|
when (snapshot.state) {
|
||||||
"ok" -> {}
|
"ok" -> {}
|
||||||
"notLoggedIn" ->
|
"notLoggedIn",
|
||||||
|
"loginRequired" -> {
|
||||||
Text(
|
Text(
|
||||||
"No Claude account on this machine.",
|
snapshot.detail ?: "No Claude account on this machine.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onSignIn) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
"authenticating" ->
|
||||||
|
Text(
|
||||||
|
"Claude sign-in is in progress.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -162,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WindowBar(window: UsageWindow) {
|
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
|
||||||
Column {
|
Column {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -173,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
|
|||||||
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
resetLine(window, now)?.let {
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
resetLine(window)?.let {
|
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
@@ -197,8 +254,8 @@ private fun WindowBar(window: UsageWindow) {
|
|||||||
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
||||||
* [WindowEnd], and the session bar words them the same way.
|
* [WindowEnd], and the session bar words them the same way.
|
||||||
*/
|
*/
|
||||||
private fun resetLine(window: UsageWindow): String? =
|
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
|
||||||
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
|
when (val end = windowEnd(window.resetsAt, now)) {
|
||||||
WindowEnd.NotRunning -> null
|
WindowEnd.NotRunning -> null
|
||||||
WindowEnd.Unreadable -> "reset time unreadable"
|
WindowEnd.Unreadable -> "reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
is WindowEnd.Ends ->
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class AuthenticationPromptTest {
|
||||||
|
@Test
|
||||||
|
fun an_authentication_failure_stays_actionable_through_its_terminal_status() {
|
||||||
|
val required =
|
||||||
|
authenticationPromptAfter(
|
||||||
|
false,
|
||||||
|
SessionEvent.AuthenticationRequired("sign in again"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(authenticationPromptAfter(required, SessionEvent.Status("idle")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_later_provider_response_makes_an_old_failure_stale() {
|
||||||
|
assertFalse(
|
||||||
|
authenticationPromptAfter(
|
||||||
|
true,
|
||||||
|
SessionEvent.AssistantText("Working again."),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class FilesNavigationTest {
|
||||||
|
@Test
|
||||||
|
fun `back walks through the common ancestor toward the project`() {
|
||||||
|
val project = "/home/bob/repos/project"
|
||||||
|
assertEquals("/", nextDirectoryToward("/etc", project))
|
||||||
|
assertEquals("/home", nextDirectoryToward("/", project))
|
||||||
|
assertEquals("/home/bob", nextDirectoryToward("/home", project))
|
||||||
|
assertEquals("/home/bob/repos", nextDirectoryToward("/home/bob", project))
|
||||||
|
assertEquals(project, nextDirectoryToward("/home/bob/repos", project))
|
||||||
|
assertEquals(null, nextDirectoryToward(project, project))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `back leaves a project descendant one directory at a time`() {
|
||||||
|
assertEquals(
|
||||||
|
"/home/bob/repos/project/src",
|
||||||
|
nextDirectoryToward("/home/bob/repos/project/src/main", "/home/bob/repos/project"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paths inside the machine home use tilde notation`() {
|
||||||
|
assertEquals("~", tildePath("/home/bob", "/home/bob"))
|
||||||
|
assertEquals("~/repos/project", tildePath("/home/bob/repos/project", "/home/bob/"))
|
||||||
|
assertEquals("/home/bobby/project", tildePath("/home/bobby/project", "/home/bob"))
|
||||||
|
assertEquals("/etc", tildePath("/etc", "/home/bob"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -338,6 +338,21 @@ class HighlighterTest {
|
|||||||
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a diff colours changes and identifies its framing separately`() {
|
||||||
|
val code = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n context\n+new"
|
||||||
|
assertSpans(code, Language.DIFF, Kind.DELETION, "-old")
|
||||||
|
assertSpans(code, Language.DIFF, Kind.ADDITION, "+new")
|
||||||
|
assertSpans(
|
||||||
|
code,
|
||||||
|
Language.DIFF,
|
||||||
|
Kind.METADATA,
|
||||||
|
"--- a/file",
|
||||||
|
"+++ b/file",
|
||||||
|
"@@ -1 +1 @@",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every language the fence table knows has a scanner`() {
|
fun `every language the fence table knows has a scanner`() {
|
||||||
Language.entries.forEach { spansOf("x", it) }
|
Language.entries.forEach { spansOf("x", it) }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the transcript says where a session ran out of quota.
|
||||||
|
*
|
||||||
|
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
|
||||||
|
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
|
||||||
|
* reader has no way of telling an invented one from a reported one.
|
||||||
|
*/
|
||||||
|
class LimitRowTest {
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reported reset time is shown as a time`() {
|
||||||
|
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
|
||||||
|
// whole string: the platform's own short-time format is what this asks for, and it
|
||||||
|
// differs by JDK and locale down to which space character separates the meridiem.
|
||||||
|
val summary = limitSummary(1_788_609_600.0, utc)
|
||||||
|
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
|
||||||
|
assertTrue(summary.contains("12:00"), summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a limit with no reset time says only what is known`() {
|
||||||
|
assertEquals("Usage limit reached", limitSummary(null, utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class MarkdownLinksTest {
|
||||||
|
@Test
|
||||||
|
fun `absolute file paths are opened on the session machine`() {
|
||||||
|
assertEquals(
|
||||||
|
"/home/bob/repos/ai app/Main.kt",
|
||||||
|
filePathOf("/home/bob/repos/ai%20app/Main.kt"),
|
||||||
|
)
|
||||||
|
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt"))
|
||||||
|
assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editor coordinates select the file itself`() {
|
||||||
|
assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42"))
|
||||||
|
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ordinary links keep their external meaning`() {
|
||||||
|
assertNull(filePathOf("https://example.com/source.kt"))
|
||||||
|
assertNull(filePathOf("docs/source.kt"))
|
||||||
|
assertNull(filePathOf("//example.com/source.kt"))
|
||||||
|
assertNull(filePathOf("file://example.com/source.kt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PendingMessagesTest {
|
||||||
|
private fun local(text: String = "keep this") =
|
||||||
|
QueuedMessage("local-1", text, emptyList(), local = true)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_server_queue_replaces_the_local_bridge_instead_of_duplicating_it() {
|
||||||
|
val queued =
|
||||||
|
reconcileQueuedMessage(
|
||||||
|
listOf(local()),
|
||||||
|
SessionEvent.MessageQueued("server-1", "keep this", emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, queued.size)
|
||||||
|
assertEquals("server-1", queued.single().id)
|
||||||
|
assertTrue(!queued.single().local)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun an_immediately_received_message_removes_its_local_bridge() {
|
||||||
|
val queued =
|
||||||
|
reconcileUserMessage(
|
||||||
|
listOf(local()),
|
||||||
|
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(queued.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_transport_failure_stays_on_its_message() {
|
||||||
|
val queued = markPendingFailure(listOf(local()), "local-1", "Can't reach the server")
|
||||||
|
|
||||||
|
assertEquals("Can't reach the server", queued.single().refusal)
|
||||||
|
assertTrue(queued.single().local)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun server_acceptance_keeps_the_bubble_until_the_provider_event() {
|
||||||
|
val queued = markPendingAccepted(listOf(local()), "local-1")
|
||||||
|
|
||||||
|
assertEquals(1, queued.size)
|
||||||
|
assertTrue(queued.single().serverAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun discarding_a_failed_send_removes_only_that_local_copy() {
|
||||||
|
val server = QueuedMessage("server-1", "already accepted", emptyList())
|
||||||
|
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
|
||||||
|
|
||||||
|
val discarded = discardPendingMessage(queued, "local-1")
|
||||||
|
|
||||||
|
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun identical_messages_are_reconciled_one_at_a_time() {
|
||||||
|
val queued = listOf(local(), local().copy(id = "local-2"))
|
||||||
|
val afterFirst =
|
||||||
|
reconcileUserMessage(
|
||||||
|
queued,
|
||||||
|
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("local-2"), afterFirst.map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class SessionImageTest {
|
||||||
|
@Test
|
||||||
|
fun `a full-height image hides both bars`() {
|
||||||
|
assertEquals(
|
||||||
|
ViewerBars(status = true, navigation = true),
|
||||||
|
viewerBars(
|
||||||
|
imageWidth = 1000,
|
||||||
|
imageHeight = 2000,
|
||||||
|
viewportWidth = 1000,
|
||||||
|
viewportHeight = 2000,
|
||||||
|
scale = 1f,
|
||||||
|
offset = Offset.Zero,
|
||||||
|
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a letterboxed image leaves both bars visible`() {
|
||||||
|
assertEquals(
|
||||||
|
ViewerBars(),
|
||||||
|
viewerBars(
|
||||||
|
imageWidth = 1000,
|
||||||
|
imageHeight = 500,
|
||||||
|
viewportWidth = 1000,
|
||||||
|
viewportHeight = 2000,
|
||||||
|
scale = 1f,
|
||||||
|
offset = Offset.Zero,
|
||||||
|
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `panning into the status bar hides only that bar`() {
|
||||||
|
assertEquals(
|
||||||
|
ViewerBars(status = true),
|
||||||
|
viewerBars(
|
||||||
|
imageWidth = 1000,
|
||||||
|
imageHeight = 500,
|
||||||
|
viewportWidth = 1000,
|
||||||
|
viewportHeight = 2000,
|
||||||
|
scale = 2f,
|
||||||
|
offset = Offset(0f, -500f),
|
||||||
|
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native scale reverses fitting a tall image`() {
|
||||||
|
assertEquals(
|
||||||
|
1.25f,
|
||||||
|
nativeScale(
|
||||||
|
imageWidth = 1000,
|
||||||
|
imageHeight = 2000,
|
||||||
|
viewportWidth = 1000,
|
||||||
|
viewportHeight = 1600,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `native scale leaves a small image alone`() {
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
nativeScale(
|
||||||
|
imageWidth = 500,
|
||||||
|
imageHeight = 500,
|
||||||
|
viewportWidth = 1000,
|
||||||
|
viewportHeight = 1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zoom keeps the region panned to in the center`() {
|
||||||
|
assertEquals(
|
||||||
|
Offset(240f, -160f),
|
||||||
|
zoomOffset(
|
||||||
|
offset = Offset(120f, -80f),
|
||||||
|
centroid = Offset(500f, 1000f),
|
||||||
|
pan = Offset.Zero,
|
||||||
|
oldScale = 2f,
|
||||||
|
newScale = 4f,
|
||||||
|
viewportCenter = Offset(500f, 1000f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zoom keeps an off-center pinch beneath moving fingers`() {
|
||||||
|
assertEquals(
|
||||||
|
Offset(460f, 420f),
|
||||||
|
zoomOffset(
|
||||||
|
offset = Offset(100f, -100f),
|
||||||
|
centroid = Offset(250f, 400f),
|
||||||
|
pan = Offset(10f, 20f),
|
||||||
|
oldScale = 2f,
|
||||||
|
newScale = 4f,
|
||||||
|
viewportCenter = Offset(500f, 1000f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class SessionUsageTest {
|
||||||
|
@Test
|
||||||
|
fun `usage snapshots stay with the session's machine and provider`() {
|
||||||
|
val claude = snapshot("machine", "claude", null)
|
||||||
|
val codex = snapshot("machine", "codex", "codex")
|
||||||
|
val reserve = snapshot("machine", "codex", "gpt-reserve")
|
||||||
|
val elsewhere = snapshot("other", "codex", "codex")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(codex, reserve),
|
||||||
|
usageSnapshotsFor(
|
||||||
|
listOf(claude, codex, reserve, elsewhere),
|
||||||
|
machine = "machine",
|
||||||
|
provider = "codex",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a session without a meter has no usage snapshots`() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList(),
|
||||||
|
usageSnapshotsFor(
|
||||||
|
listOf(snapshot("machine", "claude", null)),
|
||||||
|
machine = "machine",
|
||||||
|
provider = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the model selects its named pool and other models use the generic pool`() {
|
||||||
|
val generic = snapshot("machine", "codex", "codex")
|
||||||
|
val spark =
|
||||||
|
snapshot(
|
||||||
|
"machine",
|
||||||
|
"codex",
|
||||||
|
"codex_bengalfox",
|
||||||
|
limitName = "GPT-5.3-Codex-Spark",
|
||||||
|
)
|
||||||
|
val reserve =
|
||||||
|
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
|
||||||
|
val pools = listOf(generic, spark, reserve)
|
||||||
|
|
||||||
|
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
|
||||||
|
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
|
||||||
|
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bar uses the shortest reported cycle`() {
|
||||||
|
val weekly = window("Weekly", 10_080)
|
||||||
|
val hourly = window("5-hour window", 300)
|
||||||
|
|
||||||
|
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
|
||||||
|
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor follows elapsed time through the window`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0.4f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor clamps at the window ends`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor is absent without a usable duration and reset time`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
|
||||||
|
assertEquals(
|
||||||
|
null,
|
||||||
|
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
|
||||||
|
)
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
machine: String,
|
||||||
|
provider: String,
|
||||||
|
limitId: String?,
|
||||||
|
limitName: String? = null,
|
||||||
|
) =
|
||||||
|
UsageSnapshot(
|
||||||
|
provider = provider,
|
||||||
|
machine = machine,
|
||||||
|
machineName = machine,
|
||||||
|
limitId = limitId,
|
||||||
|
limitName = limitName,
|
||||||
|
state = "ok",
|
||||||
|
detail = null,
|
||||||
|
windows = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
|
||||||
|
UsageWindow(
|
||||||
|
kind = "test",
|
||||||
|
label = label,
|
||||||
|
percent = 12.0,
|
||||||
|
durationMinutes = durationMinutes,
|
||||||
|
resetsAt = resetsAt,
|
||||||
|
active = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model's working as its own row, and the line under a finished reply.
|
||||||
|
*
|
||||||
|
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
|
||||||
|
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
|
||||||
|
* generation speed has no figure -- neither may borrow one.
|
||||||
|
*/
|
||||||
|
class ThinkingTest {
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
private var seq = 0L
|
||||||
|
|
||||||
|
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
|
||||||
|
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
|
||||||
|
|
||||||
|
private fun fold(vararg events: SessionEvent) =
|
||||||
|
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
||||||
|
|
||||||
|
private fun thinking(items: List<TranscriptItem>) =
|
||||||
|
items.filterIsInstance<TranscriptItem.ThinkingRow>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deltas accumulate into one block that ends with its duration`() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.Thinking("the user "),
|
||||||
|
SessionEvent.Thinking("wants a card"),
|
||||||
|
SessionEvent.ThinkingDone(12_400),
|
||||||
|
SessionEvent.AssistantText("Here it is."),
|
||||||
|
)
|
||||||
|
val block = thinking(items).single()
|
||||||
|
assertEquals("the user wants a card", block.text)
|
||||||
|
assertEquals(12_400, block.ms)
|
||||||
|
assertEquals("Thought for 12.4s", thinkingHeadline(block))
|
||||||
|
// Its own row, above the reply rather than inside it.
|
||||||
|
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block the turn ended in the middle of stops without naming a span`() {
|
||||||
|
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
|
||||||
|
val block = thinking(items).single()
|
||||||
|
assertNull(block.ms)
|
||||||
|
assertTrue(!block.open)
|
||||||
|
assertEquals("Thought", thinkingHeadline(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block still being thought says so`() {
|
||||||
|
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
|
||||||
|
assertTrue(block.open)
|
||||||
|
assertEquals("Thinking", thinkingHeadline(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `thinking between two replies is two replies and two blocks`() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.Thinking("first"),
|
||||||
|
SessionEvent.ThinkingDone(1_000),
|
||||||
|
SessionEvent.AssistantText("One."),
|
||||||
|
SessionEvent.Thinking("second"),
|
||||||
|
SessionEvent.ThinkingDone(2_000),
|
||||||
|
SessionEvent.AssistantText("Two."),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
|
||||||
|
assertEquals(
|
||||||
|
listOf("One.", "Two."),
|
||||||
|
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reply carries when it was sent and what it cost to produce`() {
|
||||||
|
val items =
|
||||||
|
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
|
||||||
|
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
|
||||||
|
}
|
||||||
|
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
|
||||||
|
assertEquals(1_788_609_600.0, reply.ts)
|
||||||
|
assertEquals(18.37, reply.tokensPerSecond)
|
||||||
|
assertEquals(9_489, reply.prefillMs)
|
||||||
|
|
||||||
|
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
|
||||||
|
// The clock reading rather than the whole string: the platform's own short-time format
|
||||||
|
// differs by JDK and locale, which is the point of asking it for one.
|
||||||
|
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
|
||||||
|
assertTrue(footer.contains("12:00"), footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the clock stays at the end however much the provider measured`() {
|
||||||
|
// What a provider that measures nothing leaves: the time, and nothing in front of it.
|
||||||
|
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
|
||||||
|
assertTrue(bare!!.contains("12:00"), bare)
|
||||||
|
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
|
||||||
|
// Every shape ends with the same thing, which is the whole point of the order: the clock
|
||||||
|
// does not move because the session is on a provider that measures more or less.
|
||||||
|
val shapes =
|
||||||
|
listOf(
|
||||||
|
bare,
|
||||||
|
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
|
||||||
|
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
|
||||||
|
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
|
||||||
|
)
|
||||||
|
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
|
||||||
|
// A reply with nothing to say has no line at all rather than an empty one.
|
||||||
|
assertNull(replyFooterText(0.0, null, null, utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block cut by a page boundary is one block, and it is not still going`() {
|
||||||
|
// Each page folded on its own, as the app does: the older one holds the fragments before
|
||||||
|
// the cut and no ending, the newer one the rest and the ending.
|
||||||
|
val older = fold(SessionEvent.Thinking("half a "))
|
||||||
|
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
|
||||||
|
|
||||||
|
val joined = joinPages(older, newer)
|
||||||
|
val block = thinking(joined).single()
|
||||||
|
assertEquals("half a thought", block.text)
|
||||||
|
assertEquals(2_000, block.ms)
|
||||||
|
assertTrue(!block.open)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two blocks meeting at a page boundary stay two`() {
|
||||||
|
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
|
||||||
|
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
|
||||||
|
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `usage that lands after a tool call is not folded onto an older reply`() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Reading it."),
|
||||||
|
SessionEvent.ToolStart("t1", "Read", "{}"),
|
||||||
|
SessionEvent.ToolEnd("t1", "done"),
|
||||||
|
SessionEvent.UsageDelta(42, 100, 18.0, 500),
|
||||||
|
)
|
||||||
|
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ToolInputTest {
|
||||||
|
@Test
|
||||||
|
fun `bash wrapper ignores double quotes inside its outer pair`() {
|
||||||
|
assertEquals(
|
||||||
|
"rg -n \"needle\" server app",
|
||||||
|
renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bash wrapper ignores single quotes inside its outer pair`() {
|
||||||
|
assertEquals(
|
||||||
|
"printf 'hello'",
|
||||||
|
renderedBashScript("/bin/bash -lc 'printf 'hello''"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unquoted or unfamiliar commands stay intact`() {
|
||||||
|
assertNull(renderedBashScript("/usr/bin/bash -lc echo hello"))
|
||||||
|
assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing optional input is not displayed as null`() {
|
||||||
|
assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `collaboration calls say what they do and omit empty completion`() {
|
||||||
|
assertEquals("Spawn agent", toolDisplayName("Task"))
|
||||||
|
assertEquals("Wait for agents", toolDisplayName("TaskOutput"))
|
||||||
|
assertEquals("", toolDisplayOutput("TaskOutput", "completed"))
|
||||||
|
assertEquals("failed", toolDisplayOutput("TaskOutput", "failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a run of tool calls is cut into rows: the call still running, the last call in the
|
||||||
|
* transcript, and one held out because the reader has it open are drawn on their own, and every
|
||||||
|
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
|
||||||
|
* down, and a key that moves takes the reader's place with it.
|
||||||
|
*/
|
||||||
|
class ToolRowsTest {
|
||||||
|
private var seq = 0L
|
||||||
|
|
||||||
|
private fun call(id: String, runId: String = id, done: Boolean = true) =
|
||||||
|
TranscriptItem.ToolRun(
|
||||||
|
seq = ++seq,
|
||||||
|
id = id,
|
||||||
|
runId = runId,
|
||||||
|
tool = "Bash",
|
||||||
|
input = "{}",
|
||||||
|
output = if (done) "ok" else "",
|
||||||
|
done = done,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Something that is not a tool call, to put behind the run so its last call folds in. */
|
||||||
|
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
|
||||||
|
|
||||||
|
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
|
||||||
|
when (row) {
|
||||||
|
is TranscriptRow.Tools -> row.calls.map { it.id }
|
||||||
|
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertKeysDistinct(rows: List<TranscriptRow>) =
|
||||||
|
assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun the_call_still_running_is_a_row_of_its_own() {
|
||||||
|
val rows =
|
||||||
|
groupToolRuns(
|
||||||
|
listOf(
|
||||||
|
call("a"),
|
||||||
|
call("b", runId = "a"),
|
||||||
|
call("c", runId = "a", done = false),
|
||||||
|
call("d", runId = "a"),
|
||||||
|
reply(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
|
||||||
|
shape(rows),
|
||||||
|
)
|
||||||
|
assertKeysDistinct(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() {
|
||||||
|
val rows =
|
||||||
|
groupToolRuns(
|
||||||
|
listOf(
|
||||||
|
call("a"),
|
||||||
|
call("b", runId = "a", done = false),
|
||||||
|
call("c", runId = "a"),
|
||||||
|
call("d", runId = "a"),
|
||||||
|
reply(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
|
||||||
|
shape(rows),
|
||||||
|
)
|
||||||
|
assertKeysDistinct(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A call held out is one the reader opened while it stood on its own; being overtaken while
|
||||||
|
* they read it does not fold it away, and closing it hands it back to its run.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun a_held_out_call_stays_out_of_its_group() {
|
||||||
|
val calls =
|
||||||
|
listOf(
|
||||||
|
call("a"),
|
||||||
|
call("b", runId = "a"),
|
||||||
|
call("c", runId = "a"),
|
||||||
|
call("d", runId = "a"),
|
||||||
|
reply(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val whileHeld = groupToolRuns(calls, heldOut = setOf("d"))
|
||||||
|
val afterItCloses = groupToolRuns(calls)
|
||||||
|
|
||||||
|
assertEquals(listOf(listOf("a", "b", "c"), listOf("d"), listOf("reply")), shape(whileHeld))
|
||||||
|
assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld")
|
||||||
|
assertKeysDistinct(whileHeld)
|
||||||
|
assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses))
|
||||||
|
assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one case where the run's name is a call that is not in the run's first row: a page of
|
||||||
|
* history joined onto a run whose own first call is still going ([joinPages] renames the older
|
||||||
|
* calls to the newer run's name). Both rows would key on that name.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() {
|
||||||
|
val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false)))
|
||||||
|
assertEquals(listOf(listOf("a"), listOf("b")), shape(rows))
|
||||||
|
assertKeysDistinct(rows)
|
||||||
|
assertEquals("b", rows.first().key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_run_that_reappears_after_another_row_keeps_distinct_keys() {
|
||||||
|
val rows =
|
||||||
|
groupToolRuns(
|
||||||
|
listOf(
|
||||||
|
call("older", runId = "exec-1"),
|
||||||
|
call("older-2", runId = "exec-1"),
|
||||||
|
reply(),
|
||||||
|
call("exec-1", runId = "exec-1"),
|
||||||
|
call("newer", runId = "exec-1"),
|
||||||
|
reply(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(rows[0] is TranscriptRow.Tools, "$rows")
|
||||||
|
assertTrue(rows[2] is TranscriptRow.Tools, "$rows")
|
||||||
|
assertKeysDistinct(rows)
|
||||||
|
assertEquals("exec-1", rows[0].key)
|
||||||
|
assertEquals("exec-1/exec-1", rows[2].key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
|
||||||
|
* last command and is writing its reply leaves that command standing until the reply starts.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
|
||||||
|
val a = call("a")
|
||||||
|
val running = call("b", runId = "a", done = false)
|
||||||
|
val finished = running.copy(done = true)
|
||||||
|
val whileRunning = groupToolRuns(listOf(a, running))
|
||||||
|
val afterItEnds = groupToolRuns(listOf(a, finished))
|
||||||
|
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
|
||||||
|
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
|
||||||
|
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
|
||||||
|
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
|
||||||
|
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
|
||||||
|
// than losing its anchor.
|
||||||
|
assertEquals(whileRunning.first().key, afterItEnds.first().key)
|
||||||
|
assertEquals(whileRunning.first().key, afterTheReply.first().key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
|
||||||
|
val rows =
|
||||||
|
groupToolRuns(
|
||||||
|
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
|
||||||
|
)
|
||||||
|
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
|
||||||
|
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
|
|||||||
|
|
||||||
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||||
|
|
||||||
private fun session(id: String = "s") = cache().session(id)
|
private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
|
||||||
|
|
||||||
private fun line(seq: Long, type: String = "toolStart") =
|
private fun line(seq: Long, type: String = "toolStart") =
|
||||||
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
|
||||||
|
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
|
||||||
|
* in the middle of a sentence.
|
||||||
|
*/
|
||||||
|
class TranscriptItemsTest {
|
||||||
|
private var seq = 0L
|
||||||
|
|
||||||
|
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
|
||||||
|
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
|
||||||
|
|
||||||
|
private fun fold(vararg events: SessionEvent) =
|
||||||
|
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
||||||
|
|
||||||
|
private fun texts(items: List<TranscriptItem>) =
|
||||||
|
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun an_authentication_failure_stays_visible_as_an_error_row() {
|
||||||
|
val entry =
|
||||||
|
SeqEvent(
|
||||||
|
seq = 7,
|
||||||
|
ts = 1.0,
|
||||||
|
event = SessionEvent.AuthenticationRequired("sign in again"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
TranscriptItem.ErrorMsg(7, "sign in again"),
|
||||||
|
foldEvent(emptyList(), entry).single(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
|
||||||
|
SessionEvent.Status("idle"),
|
||||||
|
SessionEvent.AssistantText("Dev Updater fix is pushed."),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
|
||||||
|
texts(items),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deltas_of_one_reply_still_accumulate_into_it() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Still "),
|
||||||
|
SessionEvent.AssistantText("running "),
|
||||||
|
SessionEvent.Status("running"),
|
||||||
|
SessionEvent.AssistantText("its tests."),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("Still running its tests."), texts(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completed_text_replaces_provisional_deltas_live() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"),
|
||||||
|
SessionEvent.AssistantTextFinal(
|
||||||
|
"I’ll inspect the color-correction TODO and the relevant design."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("I’ll inspect the color-correction TODO and the relevant design."),
|
||||||
|
texts(items),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completed_text_discards_provisional_deltas_across_a_page_boundary() {
|
||||||
|
val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n"))
|
||||||
|
val later =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("1. final first section\n\n2. final second section"),
|
||||||
|
texts(joinPages(earlier, later)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() {
|
||||||
|
val earlier =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Previous answer."),
|
||||||
|
SessionEvent.Status("idle"),
|
||||||
|
)
|
||||||
|
val later = fold(SessionEvent.AssistantTextFinal("Next answer."))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Previous answer.", "Next answer."),
|
||||||
|
texts(joinPages(earlier, later)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
|
||||||
|
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
|
||||||
|
* abutting, and only the break says they are two.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Launched it."),
|
||||||
|
SessionEvent.Status("waiting"),
|
||||||
|
SessionEvent.AssistantText("Noted."),
|
||||||
|
)
|
||||||
|
assertEquals(3, items.size, "$items")
|
||||||
|
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
|
||||||
|
assertEquals(listOf("Launched it.", "Noted."), texts(items))
|
||||||
|
// Distinct keys: the break shares the reply's seq, and two items with one key take the
|
||||||
|
// app down.
|
||||||
|
assertEquals(3, items.map { it.key }.toSet().size, "$items")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Launched it."),
|
||||||
|
SessionEvent.Status("idle"),
|
||||||
|
SessionEvent.UserMessage("carry on", null, emptyList()),
|
||||||
|
SessionEvent.AssistantText("Noted."),
|
||||||
|
)
|
||||||
|
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_repeated_tool_start_is_still_one_row() {
|
||||||
|
val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}")
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
start,
|
||||||
|
SessionEvent.AssistantText("The test run is still going."),
|
||||||
|
start,
|
||||||
|
SessionEvent.ToolEnd("exec-1", "finished"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tools = items.filterIsInstance<TranscriptItem.ToolRun>()
|
||||||
|
assertEquals(1, tools.size, "$items")
|
||||||
|
assertEquals("finished", tools.single().output)
|
||||||
|
assertTrue(tools.single().done)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
|
||||||
|
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
|
||||||
|
* back puts the run-together paragraph straight back.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
|
||||||
|
val cut =
|
||||||
|
joinPages(
|
||||||
|
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
|
||||||
|
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("half a sentence"), texts(cut))
|
||||||
|
|
||||||
|
val whole =
|
||||||
|
joinPages(
|
||||||
|
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
|
||||||
|
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
||||||
|
// And the rule between them, which the fold that would have made it never got to see.
|
||||||
|
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ GLYPHS=(
|
|||||||
U+F0193 # md-content_save
|
U+F0193 # md-content_save
|
||||||
U+F0224 # md-file_outline
|
U+F0224 # md-file_outline
|
||||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
||||||
|
U+F035C # md-menu -- the burger, as a row's drag handle
|
||||||
|
U+F07B7 # md-console_line -- a backgrounded command
|
||||||
|
U+F06A9 # md-robot -- a subagent
|
||||||
|
U+F04AA # md-sitemap -- a workflow
|
||||||
|
U+F0625 # md-help_circle_outline -- a background task of a kind this build does not know
|
||||||
)
|
)
|
||||||
|
|
||||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
|
|||||||
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||||
|
|
||||||
echo "==> Importing"
|
echo "==> Importing"
|
||||||
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||||
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
||||||
"https://127.0.0.1:$PORT/sessions" \
|
"https://127.0.0.1:$PORT/sessions" \
|
||||||
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
-d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||||
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||||
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# emulator" is three places for the memory check that was missing from all of
|
# emulator" is three places for the memory check that was missing from all of
|
||||||
# them.
|
# them.
|
||||||
#
|
#
|
||||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||||
# which can also be sourced directly for one-off commands.
|
# which can also be sourced directly for one-off commands.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
include(":androidApp")
|
include(":androidApp")
|
||||||
|
|
||||||
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
|
|
||||||
// separate module from :androidApp so the ~13,000 lines of working Compose
|
|
||||||
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
|
|
||||||
include(":shellApp")
|
|
||||||
|
|
||||||
// The app half of wg-app-link, resolved by path through the submodule so
|
// The app half of wg-app-link, resolved by path through the submodule so
|
||||||
// this checkout and the crate it consumes move together -- the same
|
// this checkout and the crate it consumes move together -- the same
|
||||||
// arrangement `server/` uses for the Rust half. See that repo's README.
|
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
plugins { alias(libs.plugins.androidApplication) }
|
|
||||||
|
|
||||||
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
|
||||||
// into Rust (`../../android-shell`). Deliberately its own module rather
|
|
||||||
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
|
||||||
// of working Compose UI this experiment does not touch, and the two can be
|
|
||||||
// installed side by side on the same development device (see
|
|
||||||
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
|
||||||
// and Keystore alias are not the production app's). No Compose plugin, no
|
|
||||||
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
|
||||||
// Java, and the CA constant below is generated as Java too.
|
|
||||||
//
|
|
||||||
// The CA this build pins is baked in the same way `androidApp`'s does --
|
|
||||||
// see that module's `build.gradle.kts` comment for the reasoning (the
|
|
||||||
// trust boundary follows the machine that builds, never a pasted copy).
|
|
||||||
// `PinnedCa.java`'s package must match `android-shell`'s
|
|
||||||
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
|
||||||
val pinnedCaPath: String =
|
|
||||||
System.getenv("AI_APP_CA")
|
|
||||||
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
|
||||||
"/ai-app/certs/ca.pem"
|
|
||||||
|
|
||||||
abstract class GeneratePinnedCa : DefaultTask() {
|
|
||||||
@get:Input abstract val caPath: Property<String>
|
|
||||||
|
|
||||||
@get:InputFile
|
|
||||||
@get:Optional
|
|
||||||
@get:PathSensitive(PathSensitivity.NONE)
|
|
||||||
abstract val caCertificate: RegularFileProperty
|
|
||||||
|
|
||||||
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
|
||||||
|
|
||||||
@TaskAction
|
|
||||||
fun generate() {
|
|
||||||
val path = caPath.get()
|
|
||||||
val ca = File(path)
|
|
||||||
if (!ca.isFile) {
|
|
||||||
throw GradleException(
|
|
||||||
"No CA certificate at $path.\n" +
|
|
||||||
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
|
|
||||||
"generates the CA this build pins.\n" +
|
|
||||||
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val pem = ca.readText().trim()
|
|
||||||
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
||||||
throw GradleException("$path is not a PEM certificate.")
|
|
||||||
}
|
|
||||||
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
|
|
||||||
dir.mkdirs()
|
|
||||||
// Same reasoning as androidApp's generatePinnedCert: the text block
|
|
||||||
// must start immediately after the opening `"""`, or
|
|
||||||
// CertificateFactory stops recognising the "-----BEGIN" preamble.
|
|
||||||
File(dir, "PinnedCa.java")
|
|
||||||
.writeText(
|
|
||||||
"""
|
|
||||||
|// Generated from $path by the generatePinnedCa task. Do not edit.
|
|
||||||
|package com.example.aiapp.shell;
|
|
||||||
|
|
|
||||||
|public final class PinnedCa {
|
|
||||||
| private PinnedCa() {}
|
|
||||||
| public static final String PINNED_CA_PEM = ""${'"'}
|
|
||||||
|$pem""${'"'};
|
|
||||||
|}
|
|
||||||
|"""
|
|
||||||
.trimMargin()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val generatePinnedCa =
|
|
||||||
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
|
|
||||||
val ca = file(pinnedCaPath)
|
|
||||||
caPath.set(pinnedCaPath)
|
|
||||||
if (ca.isFile) {
|
|
||||||
caCertificate.set(ca)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.example.aiapp.shell"
|
|
||||||
compileSdk = 37
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.example.aiapp.shell"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 37
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0"
|
|
||||||
}
|
|
||||||
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
|
|
||||||
// signs its own, Gradle-free build with this same keystore, and the two can only
|
|
||||||
// `adb install -r` over each other if they carry the same certificate.
|
|
||||||
val keystore = System.getenv("AI_APP_KEYSTORE")
|
|
||||||
signingConfigs {
|
|
||||||
if (keystore != null) {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(keystore)
|
|
||||||
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = "ai-app"
|
|
||||||
keyPassword = storePassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("release") {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
|
|
||||||
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
|
|
||||||
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
|
|
||||||
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
|
|
||||||
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
|
|
||||||
// which would hand back .aar files d8 cannot read directly.
|
|
||||||
val artifactType = Attribute.of("artifactType", String::class.java)
|
|
||||||
|
|
||||||
tasks.register("printRuntimeClasspathJars") {
|
|
||||||
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
|
|
||||||
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
|
|
||||||
outputs.file(outputFile)
|
|
||||||
val jars =
|
|
||||||
configurations
|
|
||||||
.getByName("releaseRuntimeClasspath")
|
|
||||||
.incoming
|
|
||||||
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
|
|
||||||
.files
|
|
||||||
// Captured as a plain FileCollection (not the ArtifactView itself, which the
|
|
||||||
// configuration cache cannot serialize) so this task is still cacheable.
|
|
||||||
inputs.files(jars)
|
|
||||||
doLast {
|
|
||||||
val file = outputFile.get().asFile
|
|
||||||
file.parentFile.mkdirs()
|
|
||||||
file.writeText(jars.joinToString("\n") { it.absolutePath })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
androidComponents {
|
|
||||||
onVariants { variant ->
|
|
||||||
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
|
||||||
// android-shell's settings.rs calls into this Kotlin class directly
|
|
||||||
// over JNI rather than re-sealing the token in Rust; see that file's
|
|
||||||
// module doc.
|
|
||||||
implementation(project(":link"))
|
|
||||||
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
|
||||||
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
|
||||||
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
|
||||||
// the library that already has it, rather than being re-derived as a
|
|
||||||
// set of Build.VERSION.SDK_INT branches in Rust.
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
}
|
|
||||||
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user